feat: CodePilot - desktop GUI for Claude Code
Full-featured desktop application built with Next.js + Electron: - Streaming chat with Claude Code via Agent SDK - Session management with SQLite persistence - Project file tree and file preview - MCP server and custom skills management - Permission controls with Code/Plan/Ask modes - Dark/light theme, token tracking - macOS DMG packaging with electron-builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6776cc6297
commit
ff1fb38735
149 files changed
+31117
-122
No files matched your search
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import type { FilePreview as FilePreviewType } from "@/types";
|
||||
|
||||
interface FilePreviewProps {
|
||||
filePath: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
|
||||
const [preview, setPreview] = useState<FilePreviewType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPreview() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files/preview?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to load file");
|
||||
}
|
||||
const data = await res.json();
|
||||
setPreview(data.preview);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadPreview();
|
||||
}, [filePath]);
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
await navigator.clipboard.writeText(filePath);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = filePath.split("/").pop() || filePath;
|
||||
|
||||
// Build breadcrumb segments
|
||||
const segments = filePath.split("/").filter(Boolean);
|
||||
const displaySegments = segments.slice(-3); // show last 3 segments
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Back to file tree</span>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{displaySegments.length < segments.length && ".../"}{displaySegments.join("/")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleCopyPath}>
|
||||
{copied ? (
|
||||
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">Copy path</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
{preview && (
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{preview.language}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{preview.line_count} lines
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="mt-2 text-xs"
|
||||
>
|
||||
Back to file tree
|
||||
</Button>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="rounded-md border border-border text-xs">
|
||||
<SyntaxHighlighter
|
||||
language={preview.language}
|
||||
style={atomOneDark}
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.5em",
|
||||
paddingRight: "8px",
|
||||
color: "#636d83",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{preview.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RefreshIcon, Search01Icon, SourceCodeIcon, CodeIcon, File01Icon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileTreeNode } from "@/types";
|
||||
import {
|
||||
FileTree as AIFileTree,
|
||||
FileTreeFolder,
|
||||
FileTreeFile,
|
||||
} from "@/components/ai-elements/file-tree";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface FileTreeProps {
|
||||
workingDirectory: string;
|
||||
onFileSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
function getFileIcon(extension?: string): ReactNode {
|
||||
switch (extension) {
|
||||
case "ts":
|
||||
case "tsx":
|
||||
case "js":
|
||||
case "jsx":
|
||||
case "py":
|
||||
case "rb":
|
||||
case "rs":
|
||||
case "go":
|
||||
case "java":
|
||||
case "c":
|
||||
case "cpp":
|
||||
case "h":
|
||||
case "hpp":
|
||||
case "cs":
|
||||
case "swift":
|
||||
case "kt":
|
||||
case "dart":
|
||||
case "lua":
|
||||
case "php":
|
||||
case "zig":
|
||||
return <HugeiconsIcon icon={SourceCodeIcon} className="size-4 text-muted-foreground" />;
|
||||
case "json":
|
||||
case "yaml":
|
||||
case "yml":
|
||||
case "toml":
|
||||
return <HugeiconsIcon icon={CodeIcon} className="size-4 text-muted-foreground" />;
|
||||
case "md":
|
||||
case "mdx":
|
||||
case "txt":
|
||||
case "csv":
|
||||
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
function containsMatch(node: FileTreeNode, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
if (node.name.toLowerCase().includes(q)) return true;
|
||||
if (node.children) {
|
||||
return node.children.some((child) => containsMatch(child, q));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
|
||||
if (!query) return nodes;
|
||||
return nodes
|
||||
.filter((node) => containsMatch(node, query))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? filterTree(node.children, query) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) {
|
||||
const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes;
|
||||
|
||||
return (
|
||||
<>
|
||||
{filtered.map((node) => {
|
||||
if (node.type === "directory") {
|
||||
return (
|
||||
<FileTreeFolder key={node.path} path={node.path} name={node.name}>
|
||||
{node.children && (
|
||||
<RenderTreeNodes nodes={node.children} searchQuery={searchQuery} />
|
||||
)}
|
||||
</FileTreeFolder>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileTreeFile
|
||||
key={node.path}
|
||||
path={node.path}
|
||||
name={node.name}
|
||||
icon={getFileIcon(node.extension)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileTree({ workingDirectory, onFileSelect }: FileTreeProps) {
|
||||
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const fetchTree = useCallback(async () => {
|
||||
if (!workingDirectory) {
|
||||
setTree([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files?dir=${encodeURIComponent(workingDirectory)}&depth=4`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTree(data.tree || []);
|
||||
} else {
|
||||
setTree([]);
|
||||
}
|
||||
} catch {
|
||||
setTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workingDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
// Build default expanded set from first-level directories
|
||||
const defaultExpanded = new Set(
|
||||
tree.filter((n) => n.type === "directory").map((n) => n.path)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-border/30">
|
||||
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={workingDirectory}>
|
||||
{workingDirectory || 'No directory selected'}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={fetchTree}
|
||||
disabled={loading}
|
||||
className="h-6 w-6 shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
|
||||
<span className="sr-only">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative p-2 border-b border-border/30">
|
||||
<HugeiconsIcon icon={Search01Icon} className="absolute left-4 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
{loading && tree.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<HugeiconsIcon icon={RefreshIcon} className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tree.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
{workingDirectory ? 'No files found' : 'Select a project folder to view files'}
|
||||
</p>
|
||||
) : (
|
||||
<AIFileTree
|
||||
defaultExpanded={defaultExpanded}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI Elements FileTree onSelect type conflicts with HTMLAttributes.onSelect
|
||||
onSelect={onFileSelect as any}
|
||||
className="border-0 rounded-none"
|
||||
>
|
||||
<RenderTreeNodes nodes={tree} searchQuery={searchQuery} />
|
||||
</AIFileTree>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Delete02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskItem, TaskStatus } from "@/types";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskItem;
|
||||
onUpdate: (id: string, updates: { title?: string; status?: TaskStatus }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<TaskStatus, string> = {
|
||||
pending: "bg-yellow-500",
|
||||
in_progress: "bg-blue-500",
|
||||
completed: "bg-green-500",
|
||||
failed: "bg-red-500",
|
||||
};
|
||||
|
||||
const nextStatus: Record<TaskStatus, TaskStatus> = {
|
||||
pending: "in_progress",
|
||||
in_progress: "completed",
|
||||
completed: "pending",
|
||||
failed: "pending",
|
||||
};
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const handleStatusClick = () => {
|
||||
onUpdate(task.id, { status: nextStatus[task.status] });
|
||||
};
|
||||
|
||||
const handleTitleSubmit = () => {
|
||||
const trimmed = editTitle.trim();
|
||||
if (trimmed && trimmed !== task.title) {
|
||||
onUpdate(task.id, { title: trimmed });
|
||||
} else {
|
||||
setEditTitle(task.title);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-start gap-2 rounded-md border border-border p-2 transition-colors hover:bg-accent/50"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<button
|
||||
onClick={handleStatusClick}
|
||||
className="mt-1 shrink-0"
|
||||
title={`Status: ${task.status} (click to change)`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-2.5 w-2.5 rounded-full transition-transform hover:scale-125",
|
||||
statusColors[task.status]
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={handleTitleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleTitleSubmit();
|
||||
if (e.key === "Escape") {
|
||||
setEditTitle(task.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border-none bg-transparent p-0 text-xs font-medium outline-none ring-1 ring-ring/30 px-1"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"cursor-pointer truncate text-xs font-medium",
|
||||
task.status === "completed" && "text-muted-foreground line-through"
|
||||
)}
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
)}
|
||||
{task.description && (
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{formatTime(task.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
{hovered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onDelete(task.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
|
||||
<span className="sr-only">Delete task</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { TaskItem, TaskStatus } from "@/types";
|
||||
|
||||
interface TaskListProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type FilterTab = "all" | "in_progress" | "completed";
|
||||
|
||||
export function TaskList({ sessionId }: TaskListProps) {
|
||||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [filter, setFilter] = useState<FilterTab>("all");
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/tasks?session_id=${encodeURIComponent(sessionId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const title = newTitle.trim();
|
||||
if (!title || !sessionId) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, title }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks((prev) => [...prev, data.task]);
|
||||
setNewTitle("");
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (
|
||||
id: string,
|
||||
updates: { title?: string; status?: TaskStatus }
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks((prev) => prev.map((t) => (t.id === id ? data.task : t)));
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = tasks.filter((task) => {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "in_progress")
|
||||
return task.status === "pending" || task.status === "in_progress";
|
||||
if (filter === "completed") return task.status === "completed";
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterTabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "in_progress", label: "Active" },
|
||||
{ key: "completed", label: "Done" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 pb-2">
|
||||
{filterTabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 px-2 text-[10px]",
|
||||
filter === tab.key && "bg-accent"
|
||||
)}
|
||||
onClick={() => setFilter(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add task input */}
|
||||
<div className="flex items-center gap-1 pb-2">
|
||||
<Input
|
||||
placeholder="Add a task..."
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newTitle.trim()}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Add task</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<ScrollArea className="flex-1">
|
||||
{loading && tasks.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
Loading tasks...
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
{tasks.length === 0 ? "No tasks yet" : "No matching tasks"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 pb-4">
|
||||
{filtered.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user