@hienlh/ppm 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +18 -1
  3. package/bun.lock +57 -59
  4. package/dist/ppm +0 -0
  5. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  6. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  7. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  8. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  9. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  10. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  11. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  12. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  13. package/dist/web/assets/{terminal-tab-sGlqTp7k.js → terminal-tab-BEFAYT4S.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  15. package/dist/web/index.html +35 -8
  16. package/dist/web/sw.js +1 -1
  17. package/docs/codebase-summary.md +13 -8
  18. package/docs/project-roadmap.md +22 -4
  19. package/docs/system-architecture.md +59 -0
  20. package/package.json +6 -14
  21. package/src/providers/claude-agent-sdk.ts +2 -2
  22. package/src/providers/registry.ts +12 -11
  23. package/src/server/routes/projects.ts +43 -0
  24. package/src/server/routes/settings.ts +42 -8
  25. package/src/server/ws/chat.ts +2 -2
  26. package/src/services/config.service.ts +5 -1
  27. package/src/services/project.service.ts +1 -0
  28. package/src/types/config.ts +37 -0
  29. package/src/types/project.ts +1 -0
  30. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  35. package/src/web/app.tsx +43 -5
  36. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  37. package/src/web/components/chat/chat-tab.tsx +27 -19
  38. package/src/web/components/editor/code-editor.tsx +78 -197
  39. package/src/web/components/editor/diff-viewer.tsx +59 -176
  40. package/src/web/components/layout/add-project-form.tsx +151 -0
  41. package/src/web/components/layout/command-palette.tsx +3 -1
  42. package/src/web/components/layout/editor-panel.tsx +6 -4
  43. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  44. package/src/web/components/layout/mobile-nav.tsx +89 -6
  45. package/src/web/components/layout/panel-layout.tsx +16 -10
  46. package/src/web/components/layout/project-bar.tsx +329 -0
  47. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  48. package/src/web/components/layout/sidebar.tsx +56 -142
  49. package/src/web/components/layout/tab-bar.tsx +1 -6
  50. package/src/web/components/layout/tab-content.tsx +0 -10
  51. package/src/web/components/ui/dialog.tsx +1 -1
  52. package/src/web/lib/project-avatar.ts +45 -0
  53. package/src/web/lib/project-palette.ts +18 -0
  54. package/src/web/lib/use-monaco-theme.ts +29 -0
  55. package/src/web/stores/panel-store.ts +96 -9
  56. package/src/web/stores/project-store.ts +87 -3
  57. package/src/web/stores/settings-store.ts +31 -4
  58. package/src/web/stores/tab-store.ts +0 -2
  59. package/vite.config.ts +6 -2
  60. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  61. package/dist/web/assets/button-CQ5h5gxS.js +0 -41
  62. package/dist/web/assets/chat-tab-Cfw__7vJ.js +0 -6
  63. package/dist/web/assets/code-editor-D8Pz69sx.js +0 -2
  64. package/dist/web/assets/dialog-BL9i7XEo.js +0 -5
  65. package/dist/web/assets/diff-viewer-CWS5n7ur.js +0 -4
  66. package/dist/web/assets/dist-0XHv8Vwc.js +0 -1
  67. package/dist/web/assets/dist-Ca3N8Xbh.js +0 -46
  68. package/dist/web/assets/git-graph-DwA62J8-.js +0 -1
  69. package/dist/web/assets/git-status-panel-DaB-zzSF.js +0 -1
  70. package/dist/web/assets/index-BYIXPY6U.css +0 -2
  71. package/dist/web/assets/index-DbTCLiox.js +0 -17
  72. package/dist/web/assets/project-list-Z4lhtp6P.js +0 -1
  73. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  74. package/dist/web/assets/settings-tab-BW6MGcir.js +0 -1
  75. package/dist/web/assets/trash-2-CGlFXde_.js +0 -1
  76. package/dist/web/assets/x-C0Rw5Giw.js +0 -1
  77. /package/dist/web/assets/{api-client-DzH9zCD7.js → api-client-BCjah751.js} +0 -0
  78. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  79. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  80. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  81. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  82. /package/dist/web/assets/{utils-D6me7KDg.js → utils-B-_GCz7E.js} +0 -0
@@ -0,0 +1,329 @@
1
+ import { useState, useCallback } from "react";
2
+ import { Plus, Settings, ChevronUp, ChevronDown, Pencil, Trash2, Palette, Bug } from "lucide-react";
3
+ import { openBugReport } from "@/lib/report-bug";
4
+ import { useProjectStore, resolveOrder } from "@/stores/project-store";
5
+ import { useTabStore } from "@/stores/tab-store";
6
+ import { useSettingsStore } from "@/stores/settings-store";
7
+ import { resolveProjectColor, PROJECT_PALETTE } from "@/lib/project-palette";
8
+ import { getProjectInitials } from "@/lib/project-avatar";
9
+ import {
10
+ ContextMenu,
11
+ ContextMenuContent,
12
+ ContextMenuItem,
13
+ ContextMenuSeparator,
14
+ ContextMenuTrigger,
15
+ } from "@/components/ui/context-menu";
16
+ import {
17
+ Dialog,
18
+ DialogContent,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ DialogFooter,
22
+ } from "@/components/ui/dialog";
23
+ import { AddProjectForm } from "@/components/layout/add-project-form";
24
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
25
+ import { cn } from "@/lib/utils";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Avatar circle
29
+ // ---------------------------------------------------------------------------
30
+ function ProjectAvatar({ name, color, active, allNames }: {
31
+ name: string; color: string; active: boolean; allNames: string[];
32
+ }) {
33
+ const initials = getProjectInitials(name, allNames);
34
+ return (
35
+ <div
36
+ className={cn(
37
+ "size-10 rounded-full flex items-center justify-center text-xs font-bold text-white select-none shrink-0",
38
+ active && "ring-2 ring-primary ring-offset-2 ring-offset-background",
39
+ )}
40
+ style={{ background: color }}
41
+ >
42
+ {initials}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Color picker popover (inline in dialog)
49
+ // ---------------------------------------------------------------------------
50
+ function ColorPicker({ current, onChange }: { current: string; onChange: (c: string) => void }) {
51
+ return (
52
+ <div className="flex flex-wrap gap-2">
53
+ {PROJECT_PALETTE.map((c) => (
54
+ <button
55
+ key={c}
56
+ type="button"
57
+ onClick={() => onChange(c)}
58
+ className={cn(
59
+ "size-8 rounded-full border-2 transition-all",
60
+ current === c ? "border-primary scale-110" : "border-transparent hover:scale-105",
61
+ )}
62
+ style={{ background: c }}
63
+ />
64
+ ))}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // ProjectBar
71
+ // ---------------------------------------------------------------------------
72
+ export function ProjectBar() {
73
+ const { projects, activeProject, setActiveProject, setProjectColor, moveProject, renameProject, deleteProject, customOrder } = useProjectStore();
74
+ const openTab = useTabStore((s) => s.openTab);
75
+ const version = useSettingsStore((s) => s.version);
76
+ const handleReportBug = useCallback(() => openBugReport(version), [version]);
77
+
78
+ const ordered = resolveOrder(projects, customOrder);
79
+ const allNames = ordered.map((p) => p.name);
80
+
81
+ // Rename dialog
82
+ const [renameOpen, setRenameOpen] = useState(false);
83
+ const [renameTarget, setRenameTarget] = useState("");
84
+ const [renameValue, setRenameValue] = useState("");
85
+
86
+ // Delete confirm dialog
87
+ const [deleteOpen, setDeleteOpen] = useState(false);
88
+ const [deleteTarget, setDeleteTarget] = useState("");
89
+
90
+ // Add project dialog
91
+ const [addOpen, setAddOpen] = useState(false);
92
+
93
+ // Color picker dialog
94
+ const [colorOpen, setColorOpen] = useState(false);
95
+ const [colorTarget, setColorTarget] = useState("");
96
+ const [colorValue, setColorValue] = useState("");
97
+ const [colorSaving, setColorSaving] = useState(false);
98
+
99
+ const openRename = useCallback((name: string) => {
100
+ setRenameTarget(name);
101
+ setRenameValue(name);
102
+ setRenameOpen(true);
103
+ }, []);
104
+
105
+ const openDelete = useCallback((name: string) => {
106
+ setDeleteTarget(name);
107
+ setDeleteOpen(true);
108
+ }, []);
109
+
110
+ const openColor = useCallback((name: string, currentColor: string) => {
111
+ setColorTarget(name);
112
+ setColorValue(currentColor);
113
+ setColorOpen(true);
114
+ }, []);
115
+
116
+ async function handleRename() {
117
+ if (!renameValue.trim() || renameValue === renameTarget) { setRenameOpen(false); return; }
118
+ try { await renameProject(renameTarget, renameValue.trim()); } catch { /* ignore */ }
119
+ setRenameOpen(false);
120
+ }
121
+
122
+ async function handleDelete() {
123
+ try { await deleteProject(deleteTarget); } catch { /* ignore */ }
124
+ setDeleteOpen(false);
125
+ }
126
+
127
+ async function handleColorSave() {
128
+ setColorSaving(true);
129
+ try {
130
+ await setProjectColor(colorTarget, colorValue);
131
+ setColorOpen(false);
132
+ } catch (e) {
133
+ console.error("Failed to save color:", e);
134
+ } finally {
135
+ setColorSaving(false);
136
+ }
137
+ }
138
+
139
+ function handleAddProject() {
140
+ setAddOpen(true);
141
+ }
142
+
143
+ function handleSettings() {
144
+ openTab({ type: "settings", title: "Settings", projectId: null, closable: true });
145
+ }
146
+
147
+ return (
148
+ <aside className="hidden md:flex flex-col w-[52px] min-w-[52px] bg-background border-r border-border overflow-hidden">
149
+ {/* Logo + version */}
150
+ <div className="shrink-0 flex flex-col items-center justify-center h-[41px] border-b border-border gap-0.5">
151
+ <span className="text-[11px] font-bold text-primary leading-none">PPM</span>
152
+ {version && (
153
+ <span className="text-[8px] text-text-subtle leading-none">v{version}</span>
154
+ )}
155
+ </div>
156
+
157
+ {/* Project avatar list */}
158
+ <div className="flex-1 overflow-y-auto py-2 flex flex-col items-center gap-2 min-h-0">
159
+ {ordered.map((project, idx) => {
160
+ const color = resolveProjectColor(project.color, idx);
161
+ const isActive = activeProject?.name === project.name;
162
+ return (
163
+ <ContextMenu key={project.name}>
164
+ <Tooltip>
165
+ <TooltipTrigger asChild>
166
+ <ContextMenuTrigger asChild>
167
+ <button
168
+ onClick={() => setActiveProject(project)}
169
+ className="p-1 rounded-lg hover:bg-surface-elevated transition-colors"
170
+ >
171
+ <ProjectAvatar name={project.name} color={color} active={isActive} allNames={allNames} />
172
+ </button>
173
+ </ContextMenuTrigger>
174
+ </TooltipTrigger>
175
+ <TooltipContent side="right" className="max-w-[200px]">
176
+ <p className="font-medium">{project.name}</p>
177
+ <p className="text-xs text-text-subtle truncate">{project.path}</p>
178
+ </TooltipContent>
179
+ </Tooltip>
180
+ <ContextMenuContent>
181
+ <ContextMenuItem onClick={() => openRename(project.name)}>
182
+ <Pencil className="size-3.5 mr-2" /> Rename
183
+ </ContextMenuItem>
184
+ <ContextMenuItem onClick={() => openColor(project.name, color)}>
185
+ <Palette className="size-3.5 mr-2" /> Change Color
186
+ </ContextMenuItem>
187
+ <ContextMenuSeparator />
188
+ <ContextMenuItem
189
+ disabled={idx === 0}
190
+ onClick={() => moveProject(project.name, "up")}
191
+ >
192
+ <ChevronUp className="size-3.5 mr-2" /> Move Up
193
+ </ContextMenuItem>
194
+ <ContextMenuItem
195
+ disabled={idx === ordered.length - 1}
196
+ onClick={() => moveProject(project.name, "down")}
197
+ >
198
+ <ChevronDown className="size-3.5 mr-2" /> Move Down
199
+ </ContextMenuItem>
200
+ <ContextMenuSeparator />
201
+ <ContextMenuItem
202
+ className="text-destructive focus:text-destructive"
203
+ onClick={() => openDelete(project.name)}
204
+ >
205
+ <Trash2 className="size-3.5 mr-2" /> Delete
206
+ </ContextMenuItem>
207
+ </ContextMenuContent>
208
+ </ContextMenu>
209
+ );
210
+ })}
211
+
212
+ {/* Add project button */}
213
+ <Tooltip>
214
+ <TooltipTrigger asChild>
215
+ <button
216
+ onClick={handleAddProject}
217
+ className="size-10 rounded-full border-2 border-dashed border-border flex items-center justify-center text-text-subtle hover:border-primary hover:text-primary transition-colors"
218
+ >
219
+ <Plus className="size-4" />
220
+ </button>
221
+ </TooltipTrigger>
222
+ <TooltipContent side="right">Add Project</TooltipContent>
223
+ </Tooltip>
224
+ </div>
225
+
226
+ {/* Footer: report bug + settings */}
227
+ <div className="shrink-0 flex flex-col items-center gap-1 py-2 border-t border-border">
228
+ <Tooltip>
229
+ <TooltipTrigger asChild>
230
+ <button
231
+ onClick={handleReportBug}
232
+ className="flex items-center justify-center size-8 rounded-md text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
233
+ >
234
+ <Bug className="size-4" />
235
+ </button>
236
+ </TooltipTrigger>
237
+ <TooltipContent side="right">Report Bug</TooltipContent>
238
+ </Tooltip>
239
+ <Tooltip>
240
+ <TooltipTrigger asChild>
241
+ <button
242
+ onClick={handleSettings}
243
+ className="flex items-center justify-center size-8 rounded-md text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
244
+ >
245
+ <Settings className="size-4" />
246
+ </button>
247
+ </TooltipTrigger>
248
+ <TooltipContent side="right">Settings</TooltipContent>
249
+ </Tooltip>
250
+ </div>
251
+
252
+ {/* Add project dialog */}
253
+ <Dialog open={addOpen} onOpenChange={setAddOpen}>
254
+ <DialogContent className="sm:max-w-sm">
255
+ <DialogHeader>
256
+ <DialogTitle>Add Project</DialogTitle>
257
+ </DialogHeader>
258
+ <AddProjectForm
259
+ onSuccess={() => setAddOpen(false)}
260
+ onCancel={() => setAddOpen(false)}
261
+ />
262
+ </DialogContent>
263
+ </Dialog>
264
+
265
+ {/* Rename dialog */}
266
+ <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
267
+ <DialogContent className="sm:max-w-sm">
268
+ <DialogHeader>
269
+ <DialogTitle>Rename Project</DialogTitle>
270
+ </DialogHeader>
271
+ <input
272
+ type="text"
273
+ value={renameValue}
274
+ onChange={(e) => setRenameValue(e.target.value)}
275
+ onKeyDown={(e) => { if (e.key === "Enter") handleRename(); }}
276
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
277
+ autoFocus
278
+ />
279
+ <DialogFooter>
280
+ <button onClick={() => setRenameOpen(false)} className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors">
281
+ Cancel
282
+ </button>
283
+ <button onClick={handleRename} className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors">
284
+ Rename
285
+ </button>
286
+ </DialogFooter>
287
+ </DialogContent>
288
+ </Dialog>
289
+
290
+ {/* Delete confirm dialog */}
291
+ <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
292
+ <DialogContent className="sm:max-w-sm">
293
+ <DialogHeader>
294
+ <DialogTitle>Delete Project</DialogTitle>
295
+ </DialogHeader>
296
+ <p className="text-sm text-text-secondary">
297
+ Remove <strong className="text-foreground">{deleteTarget}</strong> from PPM? The files on disk won't be deleted.
298
+ </p>
299
+ <DialogFooter>
300
+ <button onClick={() => setDeleteOpen(false)} className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors">
301
+ Cancel
302
+ </button>
303
+ <button onClick={handleDelete} className="px-3 py-1.5 text-sm bg-destructive text-white rounded-md hover:bg-destructive/90 transition-colors">
304
+ Delete
305
+ </button>
306
+ </DialogFooter>
307
+ </DialogContent>
308
+ </Dialog>
309
+
310
+ {/* Color picker dialog */}
311
+ <Dialog open={colorOpen} onOpenChange={setColorOpen}>
312
+ <DialogContent className="sm:max-w-sm">
313
+ <DialogHeader>
314
+ <DialogTitle>Change Color</DialogTitle>
315
+ </DialogHeader>
316
+ <ColorPicker current={colorValue} onChange={setColorValue} />
317
+ <DialogFooter>
318
+ <button onClick={() => setColorOpen(false)} className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors">
319
+ Cancel
320
+ </button>
321
+ <button onClick={handleColorSave} disabled={colorSaving} className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50">
322
+ {colorSaving ? "Saving…" : "Save"}
323
+ </button>
324
+ </DialogFooter>
325
+ </DialogContent>
326
+ </Dialog>
327
+ </aside>
328
+ );
329
+ }
@@ -0,0 +1,345 @@
1
+ import { useState, useRef, useCallback } from "react";
2
+ import { X, Check, Plus, Settings, ChevronUp, ChevronDown, Pencil, Trash2, Palette, ArrowLeft } from "lucide-react";
3
+ import { useProjectStore, resolveOrder } from "@/stores/project-store";
4
+ import { useTabStore } from "@/stores/tab-store";
5
+ import { useSettingsStore } from "@/stores/settings-store";
6
+ import { AddProjectForm } from "@/components/layout/add-project-form";
7
+ import { resolveProjectColor, PROJECT_PALETTE } from "@/lib/project-palette";
8
+ import { getProjectInitials } from "@/lib/project-avatar";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ interface ProjectBottomSheetProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ }
15
+
16
+ // Action sheet for long-press context menu
17
+ interface ActionSheetItem {
18
+ label: string;
19
+ icon: React.ElementType;
20
+ onClick: () => void;
21
+ destructive?: boolean;
22
+ }
23
+
24
+ function ProjectAvatar({ name, color, allNames }: { name: string; color: string; allNames: string[] }) {
25
+ const initials = getProjectInitials(name, allNames);
26
+ return (
27
+ <div
28
+ className="size-10 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
29
+ style={{ background: color }}
30
+ >
31
+ {initials}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps) {
37
+ const { projects, activeProject, setActiveProject, setProjectColor, moveProject, renameProject, deleteProject, customOrder } = useProjectStore();
38
+ const openTab = useTabStore((s) => s.openTab);
39
+ const version = useSettingsStore((s) => s.version);
40
+
41
+ const ordered = resolveOrder(projects, customOrder);
42
+ const allNames = ordered.map((p) => p.name);
43
+
44
+ // View: "list" | "add"
45
+ const [view, setView] = useState<"list" | "add">("list");
46
+
47
+ // Long-press state for action sheet
48
+ const [actionTarget, setActionTarget] = useState<string | null>(null);
49
+ const [actionColor, setActionColor] = useState("");
50
+ const [colorPickerOpen, setColorPickerOpen] = useState(false);
51
+ const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
52
+
53
+ // Rename inline state
54
+ const [renameTarget, setRenameTarget] = useState<string | null>(null);
55
+ const [renameValue, setRenameValue] = useState("");
56
+
57
+ const startLongPress = useCallback((name: string) => {
58
+ longPressTimer.current = setTimeout(() => setActionTarget(name), 400);
59
+ }, []);
60
+
61
+ const cancelLongPress = useCallback(() => {
62
+ if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; }
63
+ }, []);
64
+
65
+ function handleClose() {
66
+ setView("list");
67
+ onClose();
68
+ }
69
+
70
+ function handleSelectProject(name: string) {
71
+ const project = projects.find((p) => p.name === name);
72
+ if (project) { setActiveProject(project); handleClose(); }
73
+ }
74
+
75
+ function handleAddProject() {
76
+ setView("add");
77
+ }
78
+
79
+ function handleSettings() {
80
+ openTab({ type: "settings", title: "Settings", projectId: null, closable: true });
81
+ handleClose();
82
+ }
83
+
84
+ async function handleRename() {
85
+ if (!renameTarget || !renameValue.trim() || renameValue === renameTarget) {
86
+ setRenameTarget(null);
87
+ return;
88
+ }
89
+ try { await renameProject(renameTarget, renameValue.trim()); } catch { /* ignore */ }
90
+ setRenameTarget(null);
91
+ }
92
+
93
+ async function handleDelete(name: string) {
94
+ setActionTarget(null);
95
+ try { await deleteProject(name); } catch { /* ignore */ }
96
+ }
97
+
98
+ async function handleColorSave(name: string, color: string) {
99
+ try {
100
+ await setProjectColor(name, color);
101
+ setColorPickerOpen(false);
102
+ setActionTarget(null);
103
+ } catch (e) {
104
+ console.error("Failed to save color:", e);
105
+ }
106
+ }
107
+
108
+ const actionProject = actionTarget ? ordered.find((p) => p.name === actionTarget) : null;
109
+ const actionIdx = actionTarget ? ordered.findIndex((p) => p.name === actionTarget) : -1;
110
+
111
+ const actionItems: ActionSheetItem[] = actionTarget ? [
112
+ {
113
+ label: "Rename",
114
+ icon: Pencil,
115
+ onClick: () => {
116
+ setRenameValue(actionTarget);
117
+ setRenameTarget(actionTarget);
118
+ setActionTarget(null);
119
+ },
120
+ },
121
+ {
122
+ label: "Change Color",
123
+ icon: Palette,
124
+ onClick: () => {
125
+ const idx = ordered.findIndex((p) => p.name === actionTarget);
126
+ const project = ordered[idx];
127
+ setActionColor(resolveProjectColor(project?.color, idx));
128
+ setColorPickerOpen(true);
129
+ },
130
+ },
131
+ ...(actionIdx > 0 ? [{
132
+ label: "Move Up",
133
+ icon: ChevronUp,
134
+ onClick: async () => {
135
+ await moveProject(actionTarget, "up");
136
+ setActionTarget(null);
137
+ },
138
+ }] : []),
139
+ ...(actionIdx < ordered.length - 1 ? [{
140
+ label: "Move Down",
141
+ icon: ChevronDown,
142
+ onClick: async () => {
143
+ await moveProject(actionTarget, "down");
144
+ setActionTarget(null);
145
+ },
146
+ }] : []),
147
+ {
148
+ label: "Delete",
149
+ icon: Trash2,
150
+ destructive: true,
151
+ onClick: () => handleDelete(actionTarget),
152
+ },
153
+ ] : [];
154
+
155
+ return (
156
+ <>
157
+ {/* Backdrop */}
158
+ <div
159
+ className={cn(
160
+ "fixed inset-0 z-50 md:hidden transition-opacity duration-200",
161
+ isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
162
+ )}
163
+ onClick={handleClose}
164
+ style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
165
+ />
166
+
167
+ {/* Sheet */}
168
+ <div
169
+ className={cn(
170
+ "fixed bottom-0 left-0 right-0 z-50 md:hidden bg-background rounded-t-2xl border-t border-border shadow-2xl",
171
+ "transition-transform duration-300 ease-out",
172
+ isOpen ? "translate-y-0" : "translate-y-full",
173
+ )}
174
+ >
175
+ {/* Drag handle */}
176
+ <div className="flex justify-center pt-3 pb-1">
177
+ <div className="w-10 h-1 rounded-full bg-border" />
178
+ </div>
179
+
180
+ {/* Header */}
181
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border">
182
+ <div className="flex items-center gap-2">
183
+ {view === "add" && (
184
+ <button
185
+ onClick={() => setView("list")}
186
+ className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
187
+ >
188
+ <ArrowLeft className="size-4" />
189
+ </button>
190
+ )}
191
+ <span className="text-sm font-semibold">{view === "add" ? "Add Project" : "Projects"}</span>
192
+ </div>
193
+ <button
194
+ onClick={handleClose}
195
+ className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
196
+ >
197
+ <X className="size-4" />
198
+ </button>
199
+ </div>
200
+
201
+ {/* Add project form */}
202
+ {view === "add" && (
203
+ <div className="px-4 py-4">
204
+ <AddProjectForm
205
+ onSuccess={() => { setView("list"); onClose(); }}
206
+ onCancel={() => setView("list")}
207
+ footerClassName="pt-2"
208
+ />
209
+ </div>
210
+ )}
211
+
212
+ {/* Project list */}
213
+ <div className={view === "add" ? "hidden" : "max-h-[60vh] overflow-y-auto"}>
214
+ {ordered.map((project, idx) => {
215
+ const color = resolveProjectColor(project.color, idx);
216
+ const isActive = activeProject?.name === project.name;
217
+ const isRenaming = renameTarget === project.name;
218
+
219
+ return (
220
+ <div
221
+ key={project.name}
222
+ className={cn(
223
+ "flex items-center gap-3 px-4 py-3 transition-colors active:bg-surface-elevated",
224
+ isActive && "bg-accent/10",
225
+ )}
226
+ onClick={() => !isRenaming && handleSelectProject(project.name)}
227
+ onTouchStart={() => startLongPress(project.name)}
228
+ onTouchEnd={cancelLongPress}
229
+ onTouchMove={cancelLongPress}
230
+ >
231
+ <ProjectAvatar name={project.name} color={color} allNames={allNames} />
232
+
233
+ <div className="flex-1 min-w-0">
234
+ {isRenaming ? (
235
+ <input
236
+ type="text"
237
+ value={renameValue}
238
+ onChange={(e) => setRenameValue(e.target.value)}
239
+ onKeyDown={(e) => {
240
+ if (e.key === "Enter") handleRename();
241
+ if (e.key === "Escape") setRenameTarget(null);
242
+ }}
243
+ onBlur={handleRename}
244
+ onClick={(e) => e.stopPropagation()}
245
+ className="w-full bg-transparent border-b border-primary text-sm outline-none"
246
+ autoFocus
247
+ />
248
+ ) : (
249
+ <p className="text-sm font-medium truncate">{project.name}</p>
250
+ )}
251
+ <p className="text-xs text-text-subtle truncate">{project.path}</p>
252
+ </div>
253
+
254
+ {isActive && <Check className="size-4 text-primary shrink-0" />}
255
+ </div>
256
+ );
257
+ })}
258
+ </div>
259
+
260
+ {/* Footer actions */}
261
+ <div className="border-t border-border">
262
+ <button
263
+ onClick={handleAddProject}
264
+ className="w-full flex items-center gap-3 px-4 py-3 text-text-secondary hover:bg-surface-elevated transition-colors"
265
+ >
266
+ <Plus className="size-4 shrink-0" />
267
+ <span className="text-sm">Add Project</span>
268
+ </button>
269
+ </div>
270
+ <div className="flex items-center justify-between px-4 py-3 border-t border-border">
271
+ <button
272
+ onClick={handleSettings}
273
+ className="flex items-center gap-2 text-text-secondary hover:text-foreground transition-colors"
274
+ >
275
+ <Settings className="size-4" />
276
+ <span className="text-sm">Settings</span>
277
+ </button>
278
+ {version && <span className="text-xs text-text-subtle">v{version}</span>}
279
+ </div>
280
+ </div>
281
+
282
+ {/* Long-press action sheet */}
283
+ {actionTarget && !colorPickerOpen && (
284
+ <>
285
+ <div className="fixed inset-0 z-[60] md:hidden" onClick={() => setActionTarget(null)} />
286
+ <div className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface border-t border-border rounded-t-2xl shadow-2xl animate-in slide-in-from-bottom-2 duration-150">
287
+ <div className="px-4 py-2 border-b border-border">
288
+ <p className="text-xs font-medium text-text-secondary">{actionTarget}</p>
289
+ </div>
290
+ {actionItems.map((item) => {
291
+ const Icon = item.icon;
292
+ return (
293
+ <button
294
+ key={item.label}
295
+ onClick={item.onClick}
296
+ className={cn(
297
+ "w-full flex items-center gap-3 px-4 py-3 text-sm transition-colors active:bg-surface-elevated",
298
+ item.destructive ? "text-destructive" : "text-foreground",
299
+ )}
300
+ >
301
+ <Icon className="size-4 shrink-0" />
302
+ {item.label}
303
+ </button>
304
+ );
305
+ })}
306
+ </div>
307
+ </>
308
+ )}
309
+
310
+ {/* Color picker sheet */}
311
+ {colorPickerOpen && actionTarget && (
312
+ <>
313
+ <div className="fixed inset-0 z-[60] md:hidden" onClick={() => { setColorPickerOpen(false); setActionTarget(null); }} />
314
+ <div className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface border-t border-border rounded-t-2xl shadow-2xl p-4 space-y-4">
315
+ <p className="text-sm font-medium">Change Color</p>
316
+ <div className="flex flex-wrap gap-3">
317
+ {PROJECT_PALETTE.map((c) => (
318
+ <button
319
+ key={c}
320
+ type="button"
321
+ onClick={() => setActionColor(c)}
322
+ className={cn(
323
+ "size-9 rounded-full border-2 transition-all",
324
+ actionColor === c ? "border-primary scale-110" : "border-transparent",
325
+ )}
326
+ style={{ background: c }}
327
+ />
328
+ ))}
329
+ </div>
330
+ <div className="flex gap-2 pt-2">
331
+ <button
332
+ onClick={() => { setColorPickerOpen(false); setActionTarget(null); }}
333
+ className="flex-1 py-2 text-sm text-text-secondary border border-border rounded-md"
334
+ >Cancel</button>
335
+ <button
336
+ onClick={() => handleColorSave(actionTarget, actionColor)}
337
+ className="flex-1 py-2 text-sm bg-primary text-white rounded-md"
338
+ >Save</button>
339
+ </div>
340
+ </div>
341
+ </>
342
+ )}
343
+ </>
344
+ );
345
+ }