diff --git a/src/App.tsx b/src/App.tsx index c35ae83..7ef519f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,44 +1,17 @@ import { useState } from "react"; -import { ThemeProvider, useTheme } from "next-themes"; -import { ArrowLeft, Moon, Sun } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/sonner"; -import { Button } from "@/components/ui/button"; import { ProjectListPage } from "@/routes/ProjectListPage"; import { ProjectEditorPage } from "@/routes/ProjectEditorPage"; -import { LanguageSelect } from "@/components/LanguageSelect"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { Header } from "@/components/Header"; +import { UnsavedChangesDialog } from "@/components/UnsavedChangesDialog"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -type View = { mode: "list" } | { mode: "edit"; projectId: string | null }; - -function ThemeToggle() { - const { resolvedTheme, setTheme } = useTheme(); - const { t } = useTranslation(); - return ( - - ); -} +export type View = + | { mode: "list" } + | { mode: "edit"; projectId: string | null }; function AppShell() { - const { t } = useTranslation(); const [view, setView] = useState({ mode: "list" }); const [isDirty, setIsDirty] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); @@ -52,32 +25,15 @@ function AppShell() { } } + function handleConfirmDiscard() { + setShowUnsavedDialog(false); + setIsDirty(false); + setView({ mode: "list" }); + } + return (
-
-
- {isEditing ? ( - - ) : ( -
- CodeLaunch - CodeLaunch -
- )} -
- - -
-
-
+
setView({ mode: "list" })}> {isEditing ? ( @@ -95,29 +51,11 @@ function AppShell() { )} - - - - {t("editor.unsavedChangesDialog.title")} - {t("editor.unsavedChangesDialog.description")} - - - - - - - + setShowUnsavedDialog(false)} + onConfirm={handleConfirmDiscard} + />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..86a574f --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,41 @@ +import { ArrowLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { LanguageSelect } from "./LanguageSelect"; +import { ThemeToggle } from "./ThemeToggle"; +import { Button } from "./ui/button"; + +interface HeaderProps { + isEditing: boolean; + onBack?: () => void; +} + +export function Header({ isEditing, onBack }: HeaderProps) { + const { t } = useTranslation(); + + return ( +
+
+ {isEditing ? ( + + ) : ( +
+ CodeLaunch + CodeLaunch +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..3119c94 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,21 @@ +import { Sun, Moon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useTranslation } from "react-i18next"; +import { Button } from "./ui/button"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const { t } = useTranslation(); + return ( + + ); +} diff --git a/src/components/UnsavedChangesDialog.tsx b/src/components/UnsavedChangesDialog.tsx new file mode 100644 index 0000000..c247230 --- /dev/null +++ b/src/components/UnsavedChangesDialog.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +interface UnsavedChangesDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function UnsavedChangesDialog({ + open, + onClose, + onConfirm, +}: UnsavedChangesDialogProps) { + const { t } = useTranslation(); + + return ( + !isOpen && onClose()}> + + + {t("editor.unsavedChangesDialog.title")} + + {t("editor.unsavedChangesDialog.description")} + + + + + + + + + ); +} diff --git a/src/components/editor/EditorBottomBar.tsx b/src/components/editor/EditorBottomBar.tsx new file mode 100644 index 0000000..e88e538 --- /dev/null +++ b/src/components/editor/EditorBottomBar.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; + +interface EditorBottomBarProps { + onSave: (andOpen: boolean) => void; +} + +export function EditorBottomBar({ onSave }: EditorBottomBarProps) { + const { t } = useTranslation(); + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/editor/ProjectBasicInfoSection.tsx b/src/components/editor/ProjectBasicInfoSection.tsx new file mode 100644 index 0000000..018efbe --- /dev/null +++ b/src/components/editor/ProjectBasicInfoSection.tsx @@ -0,0 +1,93 @@ +import { ExternalLink } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { IdeKind, Project } from "@/lib/types"; +import { IDE_OPTIONS } from "@/lib/types"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const isMac = + typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent); + +interface ProjectBasicInfoSectionProps { + project: Project; + onUpdate: (patch: Partial) => void; + onActivateTerminal: () => void; +} + +export function ProjectBasicInfoSection({ + project, + onUpdate, + onActivateTerminal, +}: ProjectBasicInfoSectionProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ + onUpdate({ name: e.target.value })} + /> +
+
+ + + onUpdate({ group: e.target.value.trim() ? e.target.value : null }) + } + placeholder={t("editor.groupPlaceholder")} + /> +
+
+ + +
+
+

{t("editor.comingSoon")}

+ {project.ide === "terminal" && isMac && ( +
+ + {t("editor.terminalMacNotice")} + + +
+ )} +
+ ); +} diff --git a/src/components/editor/ProjectFoldersSection.tsx b/src/components/editor/ProjectFoldersSection.tsx new file mode 100644 index 0000000..07940a1 --- /dev/null +++ b/src/components/editor/ProjectFoldersSection.tsx @@ -0,0 +1,73 @@ +import { FolderPlus, Sparkles, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { Folder } from "@/lib/types"; +import { Button } from "@/components/ui/button"; + +interface ProjectFoldersSectionProps { + folders: Folder[]; + onAddFolder: () => void; + onRemoveFolder: (id: string) => void; + onDetectScripts: (folder: Folder) => void; +} + +export function ProjectFoldersSection({ + folders, + onAddFolder, + onRemoveFolder, + onDetectScripts, +}: ProjectFoldersSectionProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t("editor.folders")}

+ +
+ + {folders.length === 0 ? ( +

{t("editor.noFolders")}

+ ) : ( +
+ {folders.map((f) => ( +
+
+

{f.name}

+

+ {f.path} +

+
+
+ + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/editor/WorkspacePreviewSection.tsx b/src/components/editor/WorkspacePreviewSection.tsx new file mode 100644 index 0000000..02e31bc --- /dev/null +++ b/src/components/editor/WorkspacePreviewSection.tsx @@ -0,0 +1,32 @@ +import { ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { IdeKind } from "@/lib/types"; +import { Button } from "@/components/ui/button"; + +interface WorkspacePreviewSectionProps { + ide: IdeKind; + preview: string | null; + onPreview: () => void; +} + +export function WorkspacePreviewSection({ + ide, + preview, + onPreview, +}: WorkspacePreviewSectionProps) { + const { t } = useTranslation(); + + return ( +
+ + {preview && ( +
+          {preview}
+        
+ )} +
+ ); +} diff --git a/src/components/projects/BatchActionBar.tsx b/src/components/projects/BatchActionBar.tsx new file mode 100644 index 0000000..6b5d1d9 --- /dev/null +++ b/src/components/projects/BatchActionBar.tsx @@ -0,0 +1,45 @@ +import { Folder, Play } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; + +interface BatchActionBarProps { + selectedCount: number; + busy: boolean; + onGroupSelected: () => void; + onLaunchSelected: () => void; +} + +export function BatchActionBar({ + selectedCount, + busy, + onGroupSelected, + onLaunchSelected, +}: BatchActionBarProps) { + const { t } = useTranslation(); + + if (selectedCount === 0) return null; + + return ( +
+
+ + {t("projects.selectedCount", { count: selectedCount })} + +
+ + +
+
+
+ ); +} diff --git a/src/components/projects/DeleteProjectDialog.tsx b/src/components/projects/DeleteProjectDialog.tsx new file mode 100644 index 0000000..83bbff4 --- /dev/null +++ b/src/components/projects/DeleteProjectDialog.tsx @@ -0,0 +1,71 @@ +import { useTranslation, Trans } from "react-i18next"; +import type { ProjectSummary } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface DeleteProjectDialogProps { + project: ProjectSummary | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteProjectDialog({ + project, + busy, + onClose, + onConfirm, +}: DeleteProjectDialogProps) { + const { t } = useTranslation(); + + return ( + { + if (!open && !busy) { + onClose(); + } + }} + > + + + {t("projects.deleteDialog.title")} + + , + }} + /> + + + + + + + + + ); +} diff --git a/src/components/projects/EmptyProjectsState.tsx b/src/components/projects/EmptyProjectsState.tsx new file mode 100644 index 0000000..201c2b5 --- /dev/null +++ b/src/components/projects/EmptyProjectsState.tsx @@ -0,0 +1,40 @@ +import { Rocket, Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; + +interface EmptyProjectsStateProps { + search?: string; + onClearSearch?: () => void; +} + +export function EmptyProjectsState({ + search, + onClearSearch, +}: EmptyProjectsStateProps) { + const { t } = useTranslation(); + + if (search) { + return ( +
+ +

+ {t("projects.noSearchResults", { query: search })} +

+ {onClearSearch && ( + + )} +
+ ); + } + + return ( +
+ +

+ {t("projects.emptyDescription")} +

+
+ ); +} diff --git a/src/components/projects/ProjectGroupSection.tsx b/src/components/projects/ProjectGroupSection.tsx new file mode 100644 index 0000000..325959d --- /dev/null +++ b/src/components/projects/ProjectGroupSection.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from "react"; +import { + ChevronDown, + ChevronRight, + Edit2, + Folder, + FolderX, + MoreHorizontal, + Play, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface ProjectGroupSectionProps { + title: string; + count: number; + isCollapsed: boolean; + onToggleCollapse: () => void; + allSelected: boolean; + onToggleSelect: () => void; + shortcutLabel?: string; + busy: boolean; + onLaunchGroup?: () => void; + onRenameGroup?: () => void; + onUngroupAll?: () => void; + children: ReactNode; +} + +export function ProjectGroupSection({ + title, + count, + isCollapsed, + onToggleCollapse, + allSelected, + onToggleSelect, + shortcutLabel, + busy, + onLaunchGroup, + onRenameGroup, + onUngroupAll, + children, +}: ProjectGroupSectionProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ + + {onLaunchGroup && } + {title} + + {count} + +
+ + {onLaunchGroup && ( +
+ + {(onRenameGroup || onUngroupAll) && ( + + + + + + {onRenameGroup && ( + + + {t("projects.actions.renameGroup")} + + )} + {onUngroupAll && ( + + + {t("projects.actions.ungroupAll")} + + )} + + + )} +
+ )} +
+ + {!isCollapsed && children} +
+ ); +} diff --git a/src/components/projects/ProjectListHeader.tsx b/src/components/projects/ProjectListHeader.tsx new file mode 100644 index 0000000..63d2f2a --- /dev/null +++ b/src/components/projects/ProjectListHeader.tsx @@ -0,0 +1,67 @@ +import { FolderInput, Plus, Search, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface ProjectListHeaderProps { + search: string; + onSearchChange: (search: string) => void; + hasProjects: boolean; + busy: boolean; + onImport: () => void; + onNewProject: () => void; +} + +export function ProjectListHeader({ + search, + onSearchChange, + hasProjects, + busy, + onImport, + onNewProject, +}: ProjectListHeaderProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t("projects.title")} +

+
+ + +
+
+ + {hasProjects && ( +
+ + onSearchChange(e.target.value)} + placeholder={t("projects.searchPlaceholder")} + className="h-8 pl-8 pr-8 text-xs" + /> + {search && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/projects/ProjectTable.tsx b/src/components/projects/ProjectTable.tsx new file mode 100644 index 0000000..03a8bc8 --- /dev/null +++ b/src/components/projects/ProjectTable.tsx @@ -0,0 +1,153 @@ +import { Copy, Folder, MoreHorizontal, Play } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ProjectSummary } from "@/lib/types"; +import { IDE_LABELS } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface ProjectTableProps { + items: ProjectSummary[]; + selected: Set; + onToggle: (id: string) => void; + showGroupBadge?: boolean; + shortcutsMap: Map; + busy: boolean; + onLaunch: (id: string) => void; + onEdit: (id: string) => void; + onDuplicate: (id: string) => void; + onAssignGroup: (project: ProjectSummary) => void; + onDelete: (project: ProjectSummary) => void; +} + +export function ProjectTable({ + items, + selected, + onToggle, + showGroupBadge = false, + shortcutsMap, + busy, + onLaunch, + onEdit, + onDuplicate, + onAssignGroup, + onDelete, +}: ProjectTableProps) { + const { t } = useTranslation(); + + return ( + + + + + {t("projects.table.name")} + {t("projects.table.openWith")} + {t("projects.table.folders")} + {t("projects.table.terminals")} + + + + + {items.map((p) => { + const shortcut = shortcutsMap.get(`project:${p.id}`); + return ( + + + onToggle(p.id)} + aria-label={`Select ${p.name}`} + /> + + +
+ {p.name} + {p.group && showGroupBadge && ( + + {p.group} + + )} +
+
+ + {IDE_LABELS[p.ide] ?? p.ide} + + {p.folder_count} + + {p.terminal_group_count > 0 + ? t("projects.table.groupCount", { + count: p.terminal_group_count, + }) + : "—"} + + +
+ + + + + + + onEdit(p.id)}> + {t("projects.actions.edit")} + + onDuplicate(p.id)}> + + {t("projects.actions.duplicate")} + + onAssignGroup(p)}> + + {t("projects.actions.assignGroup")} + + onDelete(p)} + > + {t("projects.actions.delete")} + + + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/hooks/useProjectEditor.ts b/src/hooks/useProjectEditor.ts new file mode 100644 index 0000000..f971ed8 --- /dev/null +++ b/src/hooks/useProjectEditor.ts @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { + DetectedCommand, + Folder, + Project, + Terminal, + TerminalGroup, +} from "@/lib/types"; +import { emptyProject, IDE_LABELS, newId } from "@/lib/types"; +import { + detectFolderCommands, + launchProject, + loadProject, + openTerminalSettings, + previewWorkspace, + saveProject, +} from "@/lib/tauriApi"; + +function sanitizeProject(p: Project): Project { + const validFolderIds = new Set(p.folders.map((f) => f.id)); + const fallbackFolderId = p.folders[0]?.id ?? ""; + return { + ...p, + terminal_groups: (p.terminal_groups || []).map((g) => ({ + ...g, + terminals: (g.terminals || []).map((t) => + validFolderIds.has(t.folder_id) + ? t + : { ...t, folder_id: fallbackFolderId } + ), + })), + }; +} + +interface UseProjectEditorOptions { + projectId: string | null; + onDirtyChange?: (isDirty: boolean) => void; +} + +export function useProjectEditor({ + projectId, + onDirtyChange, +}: UseProjectEditorOptions) { + const { t } = useTranslation(); + const [project, setProject] = useState(null); + const [preview, setPreview] = useState(null); + const [detectFolder, setDetectFolder] = useState(null); + const [isDetectOpen, setIsDetectOpen] = useState(false); + const initialJsonRef = useRef(null); + + useEffect(() => { + if (projectId) { + loadProject(projectId) + .then((raw) => { + const p = sanitizeProject(raw); + setProject(p); + initialJsonRef.current = JSON.stringify(p); + onDirtyChange?.(false); + }) + .catch((e) => toast.error(String(e))); + } else { + const initial = emptyProject(t("editor.defaultProjectName")); + setProject(initial); + initialJsonRef.current = JSON.stringify(initial); + onDirtyChange?.(false); + } + }, [projectId, t, onDirtyChange]); + + const handleSaveRef = useRef<(andOpen: boolean) => Promise>(() => Promise.resolve()); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + handleSaveRef.current(false); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + useEffect(() => { + if (!project || !initialJsonRef.current) return; + const isDirty = JSON.stringify(project) !== initialJsonRef.current; + onDirtyChange?.(isDirty); + }, [project, onDirtyChange]); + + function update(patch: Partial) { + setProject((prev) => (prev ? { ...prev, ...patch } : prev)); + } + + async function addFolder() { + const result = await open({ directory: true, multiple: true }); + if (!result) return; + const paths = Array.isArray(result) ? result : [result]; + const newFolders: Folder[] = paths.map((path) => ({ + id: newId(), + name: path.split(/[/\\]/).filter(Boolean).pop() ?? "folder", + path, + })); + const updatedFolders = [...project!.folders, ...newFolders]; + const firstFolderId = updatedFolders[0]?.id; + // Heal any terminals that may be missing a valid folder reference + const updatedGroups = project!.terminal_groups.map((g) => ({ + ...g, + terminals: g.terminals.map((t) => + !updatedFolders.some((f) => f.id === t.folder_id) && firstFolderId + ? { ...t, folder_id: firstFolderId } + : t + ), + })); + update({ folders: updatedFolders, terminal_groups: updatedGroups }); + + if (newFolders.length === 1) { + const added = newFolders[0]; + detectFolderCommands(added.path) + .then((cmds) => { + if (cmds.length > 0) { + setDetectFolder(added); + setIsDetectOpen(true); + } + }) + .catch(() => {}); + } + } + + function handleAddDetectedCommands( + selected: DetectedCommand[], + targetGroupId: string, + newGroupName?: string + ) { + if (!project || !detectFolder || selected.length === 0) return; + + let groups = [...project.terminal_groups]; + let targetGroup: TerminalGroup; + + if (targetGroupId === "__new__" || groups.length === 0) { + const nextOrder = groups.length + 1; + const group: TerminalGroup = { + id: newId(), + name: newGroupName?.trim() || `group${nextOrder}`, + order: nextOrder, + terminals: [], + }; + groups.push(group); + targetGroup = group; + } else { + targetGroup = groups.find((g) => g.id === targetGroupId) ?? groups[0]; + } + + const existingLabels = new Set(); + groups.forEach((g) => { + g.terminals.forEach((t) => existingLabels.add(t.label)); + }); + + const newTerminals: Terminal[] = selected.map((cmd) => { + let label = cmd.label; + let counter = 2; + while (existingLabels.has(label)) { + label = `${cmd.label} (${counter})`; + counter++; + } + existingLabels.add(label); + + return { + id: newId(), + label, + folder_id: detectFolder.id, + command: cmd.command, + keep_alive: true, + order: 0, + }; + }); + + const updatedGroups = groups.map((g) => { + if (g.id !== targetGroup.id) return g; + const combined = [...g.terminals, ...newTerminals].map((t, idx) => ({ + ...t, + order: idx, + })); + return { ...g, terminals: combined }; + }); + + update({ + terminal_groups: updatedGroups, + terminals_enabled: true, + }); + + toast.success( + t("editor.toasts.detectedAdded", { + count: selected.length, + folder: detectFolder.name, + }) + ); + } + + function removeFolder(id: string) { + const remainingFolders = project!.folders.filter((f) => f.id !== id); + const fallbackFolderId = remainingFolders[0]?.id ?? ""; + const updatedGroups = project!.terminal_groups.map((g) => ({ + ...g, + terminals: g.terminals.map((t) => + t.folder_id === id ? { ...t, folder_id: fallbackFolderId } : t + ), + })); + update({ folders: remainingFolders, terminal_groups: updatedGroups }); + } + + function addGroup() { + const nextOrder = project!.terminal_groups.length + 1; + const group: TerminalGroup = { + id: newId(), + name: `group${nextOrder}`, + order: nextOrder, + terminals: [], + }; + update({ terminal_groups: [...project!.terminal_groups, group] }); + } + + function removeGroup(groupId: string) { + update({ terminal_groups: project!.terminal_groups.filter((g) => g.id !== groupId) }); + } + + function addTerminal(groupId: string) { + if (project!.folders.length === 0) { + toast.error(t("editor.toasts.addFolderFirst")); + return; + } + const totalTerminals = project!.terminal_groups.reduce((n, g) => n + g.terminals.length, 0); + update({ + terminal_groups: project!.terminal_groups.map((g) => + g.id !== groupId + ? g + : { + ...g, + terminals: [ + ...g.terminals, + { + id: newId(), + label: `${t("terminals.terminal")} ${totalTerminals + 1}`, + folder_id: project!.folders[0].id, + command: null, + keep_alive: true, + order: g.terminals.length, + } satisfies Terminal, + ], + } + ), + }); + } + + function updateTerminal(groupId: string, terminalId: string, patch: Partial) { + update({ + terminal_groups: project!.terminal_groups.map((g) => + g.id !== groupId + ? g + : { ...g, terminals: g.terminals.map((t) => (t.id === terminalId ? { ...t, ...patch } : t)) } + ), + }); + } + + function updateGroupName(groupId: string, name: string) { + update({ + terminal_groups: project!.terminal_groups.map((g) => + g.id === groupId ? { ...g, name } : g + ), + }); + } + + function moveGroup(groupId: string, direction: "up" | "down") { + const groups = [...project!.terminal_groups]; + const index = groups.findIndex((g) => g.id === groupId); + if (index === -1) return; + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= groups.length) return; + const [moved] = groups.splice(index, 1); + groups.splice(targetIndex, 0, moved); + update({ + terminal_groups: groups.map((g, i) => ({ ...g, order: i + 1 })), + }); + } + + function moveTerminal(groupId: string, terminalId: string, direction: "left" | "right") { + update({ + terminal_groups: project!.terminal_groups.map((g) => { + if (g.id !== groupId) return g; + const index = g.terminals.findIndex((t) => t.id === terminalId); + if (index === -1) return g; + const targetIndex = direction === "left" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= g.terminals.length) return g; + const newTerminals = [...g.terminals]; + const [moved] = newTerminals.splice(index, 1); + newTerminals.splice(targetIndex, 0, moved); + return { + ...g, + terminals: newTerminals.map((t, i) => ({ ...t, order: i })), + }; + }), + }); + } + + function removeTerminal(groupId: string, terminalId: string) { + update({ + terminal_groups: project!.terminal_groups.map((g) => + g.id !== groupId ? g : { ...g, terminals: g.terminals.filter((t) => t.id !== terminalId) } + ), + }); + } + + async function handleSave(andOpen: boolean) { + if (!project!.name.trim()) { + toast.error(t("editor.toasts.nameRequired")); + return; + } + if ( + project!.terminals_enabled && + project!.folders.length === 0 && + project!.terminal_groups.some((g) => g.terminals.length > 0) + ) { + toast.error(t("editor.toasts.addFolderFirst")); + return; + } + try { + const saved = await saveProject(project!); + setProject(saved); + initialJsonRef.current = JSON.stringify(saved); + onDirtyChange?.(false); + if (andOpen) { + await launchProject(saved.id); + toast.success( + t("editor.toasts.savedAndOpened", { + ide: IDE_LABELS[saved.ide] ?? "IDE", + }) + ); + } else { + toast.success(t("editor.toasts.saved")); + } + } catch (e) { + toast.error(String(e)); + } + } + + handleSaveRef.current = handleSave; + + async function handlePreview() { + try { + setPreview(await previewWorkspace(project!)); + } catch (e) { + toast.error(String(e)); + } + } + + async function handleActivateTerminal() { + try { + await openTerminalSettings(); + } catch (e) { + toast.error(String(e)); + } + } + + return { + project, + update, + preview, + setPreview, + detectFolder, + setDetectFolder, + isDetectOpen, + setIsDetectOpen, + addFolder, + removeFolder, + handleAddDetectedCommands, + addGroup, + removeGroup, + addTerminal, + updateTerminal, + removeTerminal, + updateGroupName, + moveGroup, + moveTerminal, + handleSave, + handlePreview, + handleActivateTerminal, + }; +} diff --git a/src/hooks/useProjectList.ts b/src/hooks/useProjectList.ts new file mode 100644 index 0000000..8180b5a --- /dev/null +++ b/src/hooks/useProjectList.ts @@ -0,0 +1,345 @@ +import { useEffect, useMemo, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { Project, ProjectSummary } from "@/lib/types"; +import { newId } from "@/lib/types"; +import { + deleteProject, + importVsCodeWorkspace, + launchMany, + launchProject, + listProjects, + loadProject, + renameProjectGroup, + saveProject, + setProjectsGroup, +} from "@/lib/tauriApi"; + +export function useProjectList() { + const { t } = useTranslation(); + const [projects, setProjects] = useState([]); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); + + // Grouping state + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [groupToRename, setGroupToRename] = useState(""); + const [targetProjectsForGroup, setTargetProjectsForGroup] = useState([]); + const [initialGroupNameForDialog, setInitialGroupNameForDialog] = useState(undefined); + + const refresh = () => listProjects().then(setProjects).catch((e) => toast.error(String(e))); + + useEffect(() => { + refresh(); + }, []); + + async function handleDuplicate(id: string) { + setBusy(true); + try { + const original = await loadProject(id); + const now = new Date().toISOString(); + const newFolders = original.folders.map((f) => ({ ...f, id: newId() })); + const folderIdMap = new Map(); + original.folders.forEach((f, i) => { + folderIdMap.set(f.id, newFolders[i].id); + }); + const duplicated: Project = { + ...original, + id: newId(), + name: `${original.name} (${t("common.copy")})`, + folders: newFolders, + terminal_groups: original.terminal_groups.map((g) => ({ + ...g, + id: newId(), + terminals: g.terminals.map((term) => ({ + ...term, + id: newId(), + folder_id: folderIdMap.get(term.folder_id) ?? (newFolders[0]?.id || ""), + })), + })), + created_at: now, + updated_at: now, + }; + await saveProject(duplicated); + toast.success(t("projects.toasts.duplicated", { name: duplicated.name })); + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + function toggle(id: string) { + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + async function handleImport() { + const path = await open({ + multiple: false, + filters: [{ name: t("projects.vsCodeWorkspace"), extensions: ["code-workspace"] }], + }); + if (!path || Array.isArray(path)) return; + setBusy(true); + try { + const project = await importVsCodeWorkspace(path); + toast.success(t("projects.toasts.imported", { name: project.name })); + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleLaunch(id: string) { + setBusy(true); + try { + await launchProject(id); + toast.success(t("projects.toasts.workspaceOpened")); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleLaunchSelected() { + setBusy(true); + try { + const errors = await launchMany([...selected]); + if (errors.length === 0) { + toast.success(t("projects.toasts.workspacesOpened", { count: selected.size })); + } else { + toast.error(t("projects.toasts.completedWithErrors", { errors: errors.join("; ") })); + } + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleConfirmDelete() { + if (!projectToDelete) return; + setBusy(true); + try { + await deleteProject(projectToDelete.id); + setSelected((prev) => { + const next = new Set(prev); + next.delete(projectToDelete.id); + return next; + }); + toast.success(t("projects.toasts.projectDeleted", { name: projectToDelete.name })); + setProjectToDelete(null); + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + function toggleGroupCollapse(groupName: string) { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupName)) { + next.delete(groupName); + } else { + next.add(groupName); + } + return next; + }); + } + + function handleToggleGroupSelect(groupProjects: ProjectSummary[]) { + const allSelected = + groupProjects.length > 0 && groupProjects.every((p) => selected.has(p.id)); + setSelected((prev) => { + const next = new Set(prev); + groupProjects.forEach((p) => { + if (allSelected) { + next.delete(p.id); + } else { + next.add(p.id); + } + }); + return next; + }); + } + + async function handleLaunchGroup(groupProjects: ProjectSummary[]) { + setBusy(true); + try { + const errors = await launchMany(groupProjects.map((p) => p.id)); + if (errors.length === 0) { + toast.success( + t("projects.toasts.workspacesOpened", { count: groupProjects.length }) + ); + } else { + toast.error( + t("projects.toasts.completedWithErrors", { errors: errors.join("; ") }) + ); + } + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleSaveGroup(groupName: string | null) { + if (targetProjectsForGroup.length === 0) return; + setBusy(true); + try { + await setProjectsGroup(targetProjectsForGroup, groupName); + if (groupName) { + toast.success( + t("projects.toasts.groupUpdated", { count: targetProjectsForGroup.length }) + ); + } else { + toast.success( + t("projects.toasts.groupRemoved", { count: targetProjectsForGroup.length }) + ); + } + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleRenameGroup(oldName: string, newName: string) { + setBusy(true); + try { + await renameProjectGroup(oldName, newName); + toast.success(t("projects.toasts.groupRenamed", { name: newName })); + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + async function handleUngroupAll(groupName: string) { + const ids = projects + .filter((p) => p.group?.trim() === groupName) + .map((p) => p.id); + if (ids.length === 0) return; + setBusy(true); + try { + await setProjectsGroup(ids, null); + toast.success(t("projects.toasts.groupRemoved", { count: ids.length })); + refresh(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + } + + function openGroupDialogForSelection() { + const ids = [...selected]; + setTargetProjectsForGroup(ids); + const selectedProjects = projects.filter((p) => selected.has(p.id)); + const firstGroup = selectedProjects[0]?.group ?? undefined; + const allSame = selectedProjects.every((p) => p.group === firstGroup); + setInitialGroupNameForDialog(allSame ? (firstGroup ?? undefined) : undefined); + setIsGroupDialogOpen(true); + } + + function openGroupDialogForSingle(p: ProjectSummary) { + setTargetProjectsForGroup([p.id]); + setInitialGroupNameForDialog(p.group ?? undefined); + setIsGroupDialogOpen(true); + } + + const query = search.toLowerCase().trim(); + const filteredProjects = query + ? projects.filter( + (p) => + p.name.toLowerCase().includes(query) || + (p.group && p.group.toLowerCase().includes(query)) + ) + : projects; + + const { groups, ungrouped, existingGroups } = useMemo(() => { + const map = new Map(); + const ungroupedList: ProjectSummary[] = []; + + filteredProjects.forEach((p) => { + const g = p.group?.trim(); + if (g) { + if (!map.has(g)) map.set(g, []); + map.get(g)!.push(p); + } else { + ungroupedList.push(p); + } + }); + + const allGroups = Array.from( + new Set(projects.map((p) => p.group?.trim()).filter(Boolean) as string[]) + ).sort(); + + return { + groups: Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)), + ungrouped: ungroupedList, + existingGroups: allGroups, + }; + }, [filteredProjects, projects]); + + const hasGroupedProjects = targetProjectsForGroup.some((id) => { + const p = projects.find((proj) => proj.id === id); + return Boolean(p?.group); + }); + + return { + projects, + search, + setSearch, + selected, + busy, + projectToDelete, + setProjectToDelete, + collapsedGroups, + isGroupDialogOpen, + setIsGroupDialogOpen, + isRenameDialogOpen, + setIsRenameDialogOpen, + groupToRename, + setGroupToRename, + targetProjectsForGroup, + initialGroupNameForDialog, + filteredProjects, + groups, + ungrouped, + existingGroups, + hasGroupedProjects, + refresh, + toggle, + handleDuplicate, + handleImport, + handleLaunch, + handleLaunchSelected, + handleConfirmDelete, + toggleGroupCollapse, + handleToggleGroupSelect, + handleLaunchGroup, + handleSaveGroup, + handleRenameGroup, + handleUngroupAll, + openGroupDialogForSelection, + openGroupDialogForSingle, + }; +} diff --git a/src/hooks/useProjectShortcuts.ts b/src/hooks/useProjectShortcuts.ts new file mode 100644 index 0000000..4bf3fe0 --- /dev/null +++ b/src/hooks/useProjectShortcuts.ts @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { ProjectSummary } from "@/lib/types"; + +const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent || navigator.platform); + +interface UseProjectShortcutsOptions { + groups: [string, ProjectSummary[]][]; + filteredProjects: ProjectSummary[]; + collapsedGroups: Set; + ungrouped: ProjectSummary[]; + busy: boolean; + isModalOpen: boolean; + onLaunchProject: (id: string) => void; + onLaunchGroup: (groupProjects: ProjectSummary[]) => void; +} + +export function useProjectShortcuts({ + groups, + filteredProjects, + collapsedGroups, + ungrouped, + busy, + isModalOpen, + onLaunchProject, + onLaunchGroup, +}: UseProjectShortcutsOptions) { + const launchableShortcuts = useMemo(() => { + const list: { + key: string; + digit: number; + label: string; + action: () => void; + }[] = []; + + let currentDigit = 1; + + function addShortcut(key: string, action: () => void) { + if (currentDigit <= 9) { + list.push({ + key, + digit: currentDigit, + label: isMac ? `⌘${currentDigit}` : `Ctrl+${currentDigit}`, + action, + }); + currentDigit++; + } + } + + if (groups.length === 0) { + filteredProjects.forEach((p) => { + addShortcut(`project:${p.id}`, () => onLaunchProject(p.id)); + }); + } else { + groups.forEach(([groupName, groupProjects]) => { + addShortcut(`group:${groupName}`, () => onLaunchGroup(groupProjects)); + + if (!collapsedGroups.has(groupName)) { + groupProjects.forEach((p) => { + addShortcut(`project:${p.id}`, () => onLaunchProject(p.id)); + }); + } + }); + + if (ungrouped.length > 0 && !collapsedGroups.has("__ungrouped__")) { + ungrouped.forEach((p) => { + addShortcut(`project:${p.id}`, () => onLaunchProject(p.id)); + }); + } + } + + const map = new Map void }>(); + list.forEach((item) => { + map.set(item.key, item); + }); + + return { list, map }; + }, [groups, filteredProjects, collapsedGroups, ungrouped, busy, onLaunchProject, onLaunchGroup]); + + const shortcutsRef = useRef(launchableShortcuts.list); + shortcutsRef.current = launchableShortcuts.list; + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (busy || isModalOpen) return; + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target as HTMLElement)?.isContentEditable + ) { + return; + } + + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { + const digit = parseInt(e.key, 10); + if (digit >= 1 && digit <= 9) { + const item = shortcutsRef.current.find((i) => i.digit === digit); + if (item) { + e.preventDefault(); + item.action(); + } + } + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [busy, isModalOpen]); + + return launchableShortcuts; +} diff --git a/src/routes/ProjectEditorPage.tsx b/src/routes/ProjectEditorPage.tsx index 17e7813..72acd36 100644 --- a/src/routes/ProjectEditorPage.tsx +++ b/src/routes/ProjectEditorPage.tsx @@ -1,534 +1,94 @@ -import { useEffect, useRef, useState } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; -import { toast } from "sonner"; -import { ChevronDown, ExternalLink, FolderPlus, Sparkles, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { DetectedCommand, Folder, IdeKind, Project, Terminal, TerminalGroup } from "@/lib/types"; -import { emptyProject, IDE_LABELS, IDE_OPTIONS, newId } from "@/lib/types"; -import { detectFolderCommands, launchProject, loadProject, openTerminalSettings, previewWorkspace, saveProject } from "@/lib/tauriApi"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { TerminalGrid } from "@/components/TerminalGrid"; import { AutoDetectDialog } from "@/components/AutoDetectDialog"; +import { useProjectEditor } from "@/hooks/useProjectEditor"; +import { ProjectBasicInfoSection } from "@/components/editor/ProjectBasicInfoSection"; +import { ProjectFoldersSection } from "@/components/editor/ProjectFoldersSection"; +import { WorkspacePreviewSection } from "@/components/editor/WorkspacePreviewSection"; +import { EditorBottomBar } from "@/components/editor/EditorBottomBar"; interface Props { projectId: string | null; onDirtyChange?: (isDirty: boolean) => void; } -function sanitizeProject(p: Project): Project { - const validFolderIds = new Set(p.folders.map((f) => f.id)); - const fallbackFolderId = p.folders[0]?.id ?? ""; - return { - ...p, - terminal_groups: (p.terminal_groups || []).map((g) => ({ - ...g, - terminals: (g.terminals || []).map((t) => - validFolderIds.has(t.folder_id) - ? t - : { ...t, folder_id: fallbackFolderId } - ), - })), - }; -} - -const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent); - export function ProjectEditorPage({ projectId, onDirtyChange }: Props) { const { t } = useTranslation(); - const [project, setProject] = useState(null); - const [preview, setPreview] = useState(null); - const [detectFolder, setDetectFolder] = useState(null); - const [isDetectOpen, setIsDetectOpen] = useState(false); - const initialJsonRef = useRef(null); - - useEffect(() => { - if (projectId) { - loadProject(projectId) - .then((raw) => { - const p = sanitizeProject(raw); - setProject(p); - initialJsonRef.current = JSON.stringify(p); - onDirtyChange?.(false); - }) - .catch((e) => toast.error(String(e))); - } else { - const initial = emptyProject(t("editor.defaultProjectName")); - setProject(initial); - initialJsonRef.current = JSON.stringify(initial); - onDirtyChange?.(false); - } - }, [projectId, t, onDirtyChange]); - - const handleSaveRef = useRef<(andOpen: boolean) => Promise>(() => Promise.resolve()); - - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { - e.preventDefault(); - handleSaveRef.current(false); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, []); - - useEffect(() => { - if (!project || !initialJsonRef.current) return; - const isDirty = JSON.stringify(project) !== initialJsonRef.current; - onDirtyChange?.(isDirty); - }, [project, onDirtyChange]); - - if (!project) { - return

{t("common.loading")}

; - } - - function update(patch: Partial) { - setProject((prev) => (prev ? { ...prev, ...patch } : prev)); - } - - async function addFolder() { - const result = await open({ directory: true, multiple: true }); - if (!result) return; - const paths = Array.isArray(result) ? result : [result]; - const newFolders: Folder[] = paths.map((path) => ({ - id: newId(), - name: path.split(/[/\\]/).filter(Boolean).pop() ?? "folder", - path, - })); - const updatedFolders = [...project!.folders, ...newFolders]; - const firstFolderId = updatedFolders[0]?.id; - // Heal any terminals that may be missing a valid folder reference - const updatedGroups = project!.terminal_groups.map((g) => ({ - ...g, - terminals: g.terminals.map((t) => - !updatedFolders.some((f) => f.id === t.folder_id) && firstFolderId - ? { ...t, folder_id: firstFolderId } - : t - ), - })); - update({ folders: updatedFolders, terminal_groups: updatedGroups }); - - if (newFolders.length === 1) { - const added = newFolders[0]; - detectFolderCommands(added.path) - .then((cmds) => { - if (cmds.length > 0) { - setDetectFolder(added); - setIsDetectOpen(true); - } - }) - .catch(() => {}); - } - } - - function handleAddDetectedCommands( - selected: DetectedCommand[], - targetGroupId: string, - newGroupName?: string - ) { - if (!project || !detectFolder || selected.length === 0) return; + const editor = useProjectEditor({ projectId, onDirtyChange }); - let groups = [...project.terminal_groups]; - let targetGroup: TerminalGroup; - - if (targetGroupId === "__new__" || groups.length === 0) { - const nextOrder = groups.length + 1; - const group: TerminalGroup = { - id: newId(), - name: newGroupName?.trim() || `group${nextOrder}`, - order: nextOrder, - terminals: [], - }; - groups.push(group); - targetGroup = group; - } else { - targetGroup = groups.find((g) => g.id === targetGroupId) ?? groups[0]; - } - - const existingLabels = new Set(); - groups.forEach((g) => { - g.terminals.forEach((t) => existingLabels.add(t.label)); - }); - - const newTerminals: Terminal[] = selected.map((cmd) => { - let label = cmd.label; - let counter = 2; - while (existingLabels.has(label)) { - label = `${cmd.label} (${counter})`; - counter++; - } - existingLabels.add(label); - - return { - id: newId(), - label, - folder_id: detectFolder.id, - command: cmd.command, - keep_alive: true, - order: 0, - }; - }); - - const updatedGroups = groups.map((g) => { - if (g.id !== targetGroup.id) return g; - const combined = [...g.terminals, ...newTerminals].map((t, idx) => ({ - ...t, - order: idx, - })); - return { ...g, terminals: combined }; - }); - - update({ - terminal_groups: updatedGroups, - terminals_enabled: true, - }); - - toast.success( - t("editor.toasts.detectedAdded", { - count: selected.length, - folder: detectFolder.name, - }) + if (!editor.project) { + return ( +

+ {t("common.loading")} +

); } - - function removeFolder(id: string) { - const remainingFolders = project!.folders.filter((f) => f.id !== id); - const fallbackFolderId = remainingFolders[0]?.id ?? ""; - const updatedGroups = project!.terminal_groups.map((g) => ({ - ...g, - terminals: g.terminals.map((t) => - t.folder_id === id ? { ...t, folder_id: fallbackFolderId } : t - ), - })); - update({ folders: remainingFolders, terminal_groups: updatedGroups }); - } - - function addGroup() { - const nextOrder = project!.terminal_groups.length + 1; - const group: TerminalGroup = { - id: newId(), - name: `group${nextOrder}`, - order: nextOrder, - terminals: [], - }; - update({ terminal_groups: [...project!.terminal_groups, group] }); - } - - function removeGroup(groupId: string) { - update({ terminal_groups: project!.terminal_groups.filter((g) => g.id !== groupId) }); - } - - function addTerminal(groupId: string) { - if (project!.folders.length === 0) { - toast.error(t("editor.toasts.addFolderFirst")); - return; - } - // VS Code task labels must be unique across the whole workspace file, not just - // within a group — count every terminal in the project, not just this group's. - const totalTerminals = project!.terminal_groups.reduce((n, g) => n + g.terminals.length, 0); - update({ - terminal_groups: project!.terminal_groups.map((g) => - g.id !== groupId - ? g - : { - ...g, - terminals: [ - ...g.terminals, - { - id: newId(), - label: `${t("terminals.terminal")} ${totalTerminals + 1}`, - folder_id: project!.folders[0].id, - command: null, - keep_alive: true, - order: g.terminals.length, - } satisfies Terminal, - ], - } - ), - }); - } - - function updateTerminal(groupId: string, terminalId: string, patch: Partial) { - update({ - terminal_groups: project!.terminal_groups.map((g) => - g.id !== groupId - ? g - : { ...g, terminals: g.terminals.map((t) => (t.id === terminalId ? { ...t, ...patch } : t)) } - ), - }); - } - - function updateGroupName(groupId: string, name: string) { - update({ - terminal_groups: project!.terminal_groups.map((g) => - g.id === groupId ? { ...g, name } : g - ), - }); - } - - function moveGroup(groupId: string, direction: "up" | "down") { - const groups = [...project!.terminal_groups]; - const index = groups.findIndex((g) => g.id === groupId); - if (index === -1) return; - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= groups.length) return; - const [moved] = groups.splice(index, 1); - groups.splice(targetIndex, 0, moved); - update({ - terminal_groups: groups.map((g, i) => ({ ...g, order: i + 1 })), - }); - } - - function moveTerminal(groupId: string, terminalId: string, direction: "left" | "right") { - update({ - terminal_groups: project!.terminal_groups.map((g) => { - if (g.id !== groupId) return g; - const index = g.terminals.findIndex((t) => t.id === terminalId); - if (index === -1) return g; - const targetIndex = direction === "left" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= g.terminals.length) return g; - const newTerminals = [...g.terminals]; - const [moved] = newTerminals.splice(index, 1); - newTerminals.splice(targetIndex, 0, moved); - return { - ...g, - terminals: newTerminals.map((t, i) => ({ ...t, order: i })), - }; - }), - }); - } - - function removeTerminal(groupId: string, terminalId: string) { - update({ - terminal_groups: project!.terminal_groups.map((g) => - g.id !== groupId ? g : { ...g, terminals: g.terminals.filter((t) => t.id !== terminalId) } - ), - }); - } - - async function handleSave(andOpen: boolean) { - if (!project!.name.trim()) { - toast.error(t("editor.toasts.nameRequired")); - return; - } - if ( - project!.terminals_enabled && - project!.folders.length === 0 && - project!.terminal_groups.some((g) => g.terminals.length > 0) - ) { - toast.error(t("editor.toasts.addFolderFirst")); - return; - } - try { - const saved = await saveProject(project!); - setProject(saved); - initialJsonRef.current = JSON.stringify(saved); - onDirtyChange?.(false); - if (andOpen) { - await launchProject(saved.id); - toast.success( - t("editor.toasts.savedAndOpened", { - ide: IDE_LABELS[saved.ide] ?? "IDE", - }) - ); - } else { - toast.success(t("editor.toasts.saved")); - } - } catch (e) { - toast.error(String(e)); - } - } - - handleSaveRef.current = handleSave; - - async function handlePreview() { - try { - setPreview(await previewWorkspace(project!)); - } catch (e) { - toast.error(String(e)); - } - } - - async function handleActivateTerminal() { - try { - await openTerminalSettings(); - } catch (e) { - toast.error(String(e)); - } - } - return (
-
-
-
- - update({ name: e.target.value })} - /> -
-
- - - update({ group: e.target.value.trim() ? e.target.value : null }) - } - placeholder={t("editor.groupPlaceholder")} - /> -
-
- - -
-
-

{t("editor.comingSoon")}

- {project.ide === "terminal" && isMac && ( -
- - {t("editor.terminalMacNotice")} - - -
- )} -
+ -
-
-

{t("editor.folders")}

- -
- {project.folders.length === 0 ? ( -

{t("editor.noFolders")}

- ) : ( -
- {project.folders.map((f) => ( -
-
-

{f.name}

-

{f.path}

-
-
- - -
-
- ))} -
- )} -
+ { + editor.setDetectFolder(folder); + editor.setIsDetectOpen(true); + }} + />
- {project.terminals_enabled && ( + {editor.project.terminals_enabled && ( )}
-
- - {preview && ( -
-            {preview}
-          
- )} -
+ -
-
- - -
-
+
); } - diff --git a/src/routes/ProjectListPage.tsx b/src/routes/ProjectListPage.tsx index 9eea714..5cab205 100644 --- a/src/routes/ProjectListPage.tsx +++ b/src/routes/ProjectListPage.tsx @@ -1,61 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; -import { toast } from "sonner"; -import { - ChevronDown, - ChevronRight, - Copy, - Edit2, - Folder, - FolderInput, - FolderX, - MoreHorizontal, - Play, - Plus, - Rocket, - Search, - X, -} from "lucide-react"; -import { useTranslation, Trans } from "react-i18next"; -import type { Project, ProjectSummary } from "@/lib/types"; -import { IDE_LABELS, newId } from "@/lib/types"; -import { - deleteProject, - importVsCodeWorkspace, - launchMany, - launchProject, - listProjects, - loadProject, - renameProjectGroup, - saveProject, - setProjectsGroup, -} from "@/lib/tauriApi"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { useTranslation } from "react-i18next"; +import { useProjectList } from "@/hooks/useProjectList"; +import { useProjectShortcuts } from "@/hooks/useProjectShortcuts"; +import { ProjectListHeader } from "@/components/projects/ProjectListHeader"; +import { ProjectTable } from "@/components/projects/ProjectTable"; +import { ProjectGroupSection } from "@/components/projects/ProjectGroupSection"; +import { BatchActionBar } from "@/components/projects/BatchActionBar"; +import { DeleteProjectDialog } from "@/components/projects/DeleteProjectDialog"; +import { EmptyProjectsState } from "@/components/projects/EmptyProjectsState"; import { ProjectGroupDialog } from "@/components/ProjectGroupDialog"; import { RenameGroupDialog } from "@/components/RenameGroupDialog"; @@ -63,759 +14,160 @@ interface Props { onEdit: (id: string | null) => void; } -const isMac = - typeof navigator !== "undefined" && - /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent || navigator.platform); - export function ProjectListPage({ onEdit }: Props) { const { t } = useTranslation(); - const [projects, setProjects] = useState([]); - const [search, setSearch] = useState(""); - const [selected, setSelected] = useState>(new Set()); - const [busy, setBusy] = useState(false); - const [projectToDelete, setProjectToDelete] = useState(null); - - // Grouping state - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [groupToRename, setGroupToRename] = useState(""); - const [targetProjectsForGroup, setTargetProjectsForGroup] = useState([]); - const [initialGroupNameForDialog, setInitialGroupNameForDialog] = useState(undefined); - - const refresh = () => listProjects().then(setProjects).catch((e) => toast.error(String(e))); - - useEffect(() => { - refresh(); - }, []); - - async function handleDuplicate(id: string) { - setBusy(true); - try { - const original = await loadProject(id); - const now = new Date().toISOString(); - const newFolders = original.folders.map((f) => ({ ...f, id: newId() })); - const folderIdMap = new Map(); - original.folders.forEach((f, i) => { - folderIdMap.set(f.id, newFolders[i].id); - }); - const duplicated: Project = { - ...original, - id: newId(), - name: `${original.name} (${t("common.copy")})`, - folders: newFolders, - terminal_groups: original.terminal_groups.map((g) => ({ - ...g, - id: newId(), - terminals: g.terminals.map((term) => ({ - ...term, - id: newId(), - folder_id: folderIdMap.get(term.folder_id) ?? (newFolders[0]?.id || ""), - })), - })), - created_at: now, - updated_at: now, - }; - await saveProject(duplicated); - toast.success(t("projects.toasts.duplicated", { name: duplicated.name })); - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - function toggle(id: string) { - setSelected((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - } - - async function handleImport() { - const path = await open({ - multiple: false, - filters: [{ name: t("projects.vsCodeWorkspace"), extensions: ["code-workspace"] }], - }); - if (!path || Array.isArray(path)) return; - setBusy(true); - try { - const project = await importVsCodeWorkspace(path); - toast.success(t("projects.toasts.imported", { name: project.name })); - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleLaunch(id: string) { - setBusy(true); - try { - await launchProject(id); - toast.success(t("projects.toasts.workspaceOpened")); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleLaunchSelected() { - setBusy(true); - try { - const errors = await launchMany([...selected]); - if (errors.length === 0) { - toast.success(t("projects.toasts.workspacesOpened", { count: selected.size })); - } else { - toast.error(t("projects.toasts.completedWithErrors", { errors: errors.join("; ") })); - } - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleConfirmDelete() { - if (!projectToDelete) return; - setBusy(true); - try { - await deleteProject(projectToDelete.id); - setSelected((prev) => { - const next = new Set(prev); - next.delete(projectToDelete.id); - return next; - }); - toast.success(t("projects.toasts.projectDeleted", { name: projectToDelete.name })); - setProjectToDelete(null); - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - function toggleGroupCollapse(groupName: string) { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupName)) { - next.delete(groupName); - } else { - next.add(groupName); - } - return next; - }); - } - - function handleToggleGroupSelect(groupProjects: ProjectSummary[]) { - const allSelected = - groupProjects.length > 0 && groupProjects.every((p) => selected.has(p.id)); - setSelected((prev) => { - const next = new Set(prev); - groupProjects.forEach((p) => { - if (allSelected) { - next.delete(p.id); - } else { - next.add(p.id); - } - }); - return next; - }); - } - - async function handleLaunchGroup(groupProjects: ProjectSummary[]) { - setBusy(true); - try { - const errors = await launchMany(groupProjects.map((p) => p.id)); - if (errors.length === 0) { - toast.success( - t("projects.toasts.workspacesOpened", { count: groupProjects.length }) - ); - } else { - toast.error( - t("projects.toasts.completedWithErrors", { errors: errors.join("; ") }) - ); - } - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleSaveGroup(groupName: string | null) { - if (targetProjectsForGroup.length === 0) return; - setBusy(true); - try { - await setProjectsGroup(targetProjectsForGroup, groupName); - if (groupName) { - toast.success( - t("projects.toasts.groupUpdated", { count: targetProjectsForGroup.length }) - ); - } else { - toast.success( - t("projects.toasts.groupRemoved", { count: targetProjectsForGroup.length }) - ); - } - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleRenameGroup(oldName: string, newName: string) { - setBusy(true); - try { - await renameProjectGroup(oldName, newName); - toast.success(t("projects.toasts.groupRenamed", { name: newName })); - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - async function handleUngroupAll(groupName: string) { - const ids = projects - .filter((p) => p.group?.trim() === groupName) - .map((p) => p.id); - if (ids.length === 0) return; - setBusy(true); - try { - await setProjectsGroup(ids, null); - toast.success(t("projects.toasts.groupRemoved", { count: ids.length })); - refresh(); - } catch (e) { - toast.error(String(e)); - } finally { - setBusy(false); - } - } - - function openGroupDialogForSelection() { - const ids = [...selected]; - setTargetProjectsForGroup(ids); - const selectedProjects = projects.filter((p) => selected.has(p.id)); - const firstGroup = selectedProjects[0]?.group ?? undefined; - const allSame = selectedProjects.every((p) => p.group === firstGroup); - setInitialGroupNameForDialog(allSame ? (firstGroup ?? undefined) : undefined); - setIsGroupDialogOpen(true); - } - - function openGroupDialogForSingle(p: ProjectSummary) { - setTargetProjectsForGroup([p.id]); - setInitialGroupNameForDialog(p.group ?? undefined); - setIsGroupDialogOpen(true); - } - - const query = search.toLowerCase().trim(); - const filteredProjects = query - ? projects.filter( - (p) => - p.name.toLowerCase().includes(query) || - (p.group && p.group.toLowerCase().includes(query)) - ) - : projects; - - const { groups, ungrouped, existingGroups } = useMemo(() => { - const map = new Map(); - const ungroupedList: ProjectSummary[] = []; - - filteredProjects.forEach((p) => { - const g = p.group?.trim(); - if (g) { - if (!map.has(g)) map.set(g, []); - map.get(g)!.push(p); - } else { - ungroupedList.push(p); - } - }); - - const allGroups = Array.from( - new Set(projects.map((p) => p.group?.trim()).filter(Boolean) as string[]) - ).sort(); - - return { - groups: Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)), - ungrouped: ungroupedList, - existingGroups: allGroups, - }; - }, [filteredProjects, projects]); - - const hasGroupedProjects = targetProjectsForGroup.some((id) => { - const p = projects.find((proj) => proj.id === id); - return Boolean(p?.group); + const list = useProjectList(); + + const isModalOpen = + !!list.projectToDelete || + list.isGroupDialogOpen || + list.isRenameDialogOpen; + + const shortcuts = useProjectShortcuts({ + groups: list.groups, + filteredProjects: list.filteredProjects, + collapsedGroups: list.collapsedGroups, + ungrouped: list.ungrouped, + busy: list.busy, + isModalOpen, + onLaunchProject: list.handleLaunch, + onLaunchGroup: list.handleLaunchGroup, }); - const launchableShortcuts = useMemo(() => { - const list: { - key: string; - digit: number; - label: string; - action: () => void; - }[] = []; - - let currentDigit = 1; - - function addShortcut(key: string, action: () => void) { - if (currentDigit <= 9) { - list.push({ - key, - digit: currentDigit, - label: isMac ? `⌘${currentDigit}` : `Ctrl+${currentDigit}`, - action, - }); - currentDigit++; - } - } - - if (groups.length === 0) { - filteredProjects.forEach((p) => { - addShortcut(`project:${p.id}`, () => handleLaunch(p.id)); - }); - } else { - groups.forEach(([groupName, groupProjects]) => { - addShortcut(`group:${groupName}`, () => handleLaunchGroup(groupProjects)); - - if (!collapsedGroups.has(groupName)) { - groupProjects.forEach((p) => { - addShortcut(`project:${p.id}`, () => handleLaunch(p.id)); - }); - } - }); - - if (ungrouped.length > 0 && !collapsedGroups.has("__ungrouped__")) { - ungrouped.forEach((p) => { - addShortcut(`project:${p.id}`, () => handleLaunch(p.id)); - }); - } - } - - const map = new Map void }>(); - list.forEach((item) => { - map.set(item.key, item); - }); - - return { list, map }; - }, [groups, filteredProjects, collapsedGroups, ungrouped, busy]); - - const shortcutsRef = useRef(launchableShortcuts.list); - shortcutsRef.current = launchableShortcuts.list; - - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (busy) return; - if (projectToDelete || isGroupDialogOpen || isRenameDialogOpen) return; - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - (e.target as HTMLElement)?.isContentEditable - ) { - return; - } - - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { - const digit = parseInt(e.key, 10); - if (digit >= 1 && digit <= 9) { - const item = shortcutsRef.current.find((i) => i.digit === digit); - if (item) { - e.preventDefault(); - item.action(); - } - } - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [busy, projectToDelete, isGroupDialogOpen, isRenameDialogOpen]); - - function renderProjectTable(items: ProjectSummary[]) { - return ( - - - - - {t("projects.table.name")} - {t("projects.table.openWith")} - {t("projects.table.folders")} - {t("projects.table.terminals")} - - - - - {items.map((p) => ( - - - toggle(p.id)} - /> - - -
- {p.name} - {p.group && groups.length === 0 && ( - - {p.group} - - )} -
-
- - {IDE_LABELS[p.ide] ?? p.ide} - - {p.folder_count} - - {p.terminal_group_count > 0 - ? t("projects.table.groupCount", { - count: p.terminal_group_count, - }) - : "—"} - - -
- - - - - - - onEdit(p.id)}> - {t("projects.actions.edit")} - - handleDuplicate(p.id)}> - - {t("projects.actions.duplicate")} - - openGroupDialogForSingle(p)}> - - {t("projects.actions.assignGroup")} - - setProjectToDelete(p)} - > - {t("projects.actions.delete")} - - - -
-
-
- ))} -
-
- ); - } - return (
-
-
-

- {t("projects.title")} -

-
- - -
-
- - {projects.length > 0 && ( -
- - setSearch(e.target.value)} - placeholder={t("projects.searchPlaceholder")} - className="h-8 pl-8 pr-8 text-xs" - /> - {search && ( - - )} -
- )} -
+ 0} + busy={list.busy} + onImport={list.handleImport} + onNewProject={() => onEdit(null)} + /> - {projects.length === 0 ? ( -
- -

- {t("projects.emptyDescription")} -

-
- ) : filteredProjects.length === 0 ? ( -
- -

- {t("projects.noSearchResults", { query: search })} -

- -
- ) : groups.length === 0 ? ( - renderProjectTable(filteredProjects) + {list.projects.length === 0 ? ( + + ) : list.filteredProjects.length === 0 ? ( + list.setSearch("")} + /> + ) : list.groups.length === 0 ? ( + ) : (
- {groups.map(([groupName, groupProjects]) => { - const isCollapsed = collapsedGroups.has(groupName); + {list.groups.map(([groupName, groupProjects]) => { + const isCollapsed = list.collapsedGroups.has(groupName); const allSelected = groupProjects.length > 0 && - groupProjects.every((p) => selected.has(p.id)); + groupProjects.every((p) => list.selected.has(p.id)); + const groupShortcut = shortcuts.map.get(`group:${groupName}`); return ( -
list.toggleGroupCollapse(groupName)} + allSelected={allSelected} + onToggleSelect={() => list.handleToggleGroupSelect(groupProjects)} + shortcutLabel={groupShortcut?.label} + busy={list.busy} + onLaunchGroup={() => list.handleLaunchGroup(groupProjects)} + onRenameGroup={() => { + list.setGroupToRename(groupName); + list.setIsRenameDialogOpen(true); + }} + onUngroupAll={() => list.handleUngroupAll(groupName)} > -
-
- - - handleToggleGroupSelect(groupProjects) - } - /> - - {groupName} - - {groupProjects.length} - -
- -
- - - - - - - { - setGroupToRename(groupName); - setIsRenameDialogOpen(true); - }} - > - - {t("projects.actions.renameGroup")} - - handleUngroupAll(groupName)} - > - - {t("projects.actions.ungroupAll")} - - - -
-
- - {!isCollapsed && renderProjectTable(groupProjects)} -
+ + ); })} - {ungrouped.length > 0 && ( -
-
-
- - 0 && - ungrouped.every((p) => selected.has(p.id)) - } - onCheckedChange={() => handleToggleGroupSelect(ungrouped)} - /> - - {t("projects.ungroupedProjects")} - - - {ungrouped.length} - -
-
- - {!collapsedGroups.has("__ungrouped__") && - renderProjectTable(ungrouped)} -
+ {list.ungrouped.length > 0 && ( + list.toggleGroupCollapse("__ungrouped__")} + allSelected={ + list.ungrouped.length > 0 && + list.ungrouped.every((p) => list.selected.has(p.id)) + } + onToggleSelect={() => list.handleToggleGroupSelect(list.ungrouped)} + busy={list.busy} + > + + )}
)} - {selected.size > 0 && ( -
-
- - {t("projects.selectedCount", { count: selected.size })} - -
- - -
-
-
- )} + - { - if (!open && !busy) { - setProjectToDelete(null); - } - }} - > - - - {t("projects.deleteDialog.title")} - - , - }} - /> - - - - - - - - + list.setProjectToDelete(null)} + onConfirm={list.handleConfirmDelete} + />
);