@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.
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +18 -1
- package/bun.lock +57 -59
- package/dist/ppm +0 -0
- package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
- package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
- package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
- package/dist/web/assets/index-3zt5mBwZ.css +2 -0
- package/dist/web/assets/index-CaUQy3Zs.js +21 -0
- package/dist/web/assets/input-CTnwfHVN.js +41 -0
- package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
- package/dist/web/assets/{terminal-tab-sGlqTp7k.js → terminal-tab-BEFAYT4S.js} +1 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
- package/dist/web/index.html +35 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +13 -8
- package/docs/project-roadmap.md +22 -4
- package/docs/system-architecture.md +59 -0
- package/package.json +6 -14
- package/src/providers/claude-agent-sdk.ts +2 -2
- package/src/providers/registry.ts +12 -11
- package/src/server/routes/projects.ts +43 -0
- package/src/server/routes/settings.ts +42 -8
- package/src/server/ws/chat.ts +2 -2
- package/src/services/config.service.ts +5 -1
- package/src/services/project.service.ts +1 -0
- package/src/types/config.ts +37 -0
- package/src/types/project.ts +1 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
- package/src/web/app.tsx +43 -5
- package/src/web/components/chat/chat-history-panel.tsx +106 -0
- package/src/web/components/chat/chat-tab.tsx +27 -19
- package/src/web/components/editor/code-editor.tsx +78 -197
- package/src/web/components/editor/diff-viewer.tsx +59 -176
- package/src/web/components/layout/add-project-form.tsx +151 -0
- package/src/web/components/layout/command-palette.tsx +3 -1
- package/src/web/components/layout/editor-panel.tsx +6 -4
- package/src/web/components/layout/mobile-drawer.tsx +48 -180
- package/src/web/components/layout/mobile-nav.tsx +89 -6
- package/src/web/components/layout/panel-layout.tsx +16 -10
- package/src/web/components/layout/project-bar.tsx +329 -0
- package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
- package/src/web/components/layout/sidebar.tsx +56 -142
- package/src/web/components/layout/tab-bar.tsx +1 -6
- package/src/web/components/layout/tab-content.tsx +0 -10
- package/src/web/components/ui/dialog.tsx +1 -1
- package/src/web/lib/project-avatar.ts +45 -0
- package/src/web/lib/project-palette.ts +18 -0
- package/src/web/lib/use-monaco-theme.ts +29 -0
- package/src/web/stores/panel-store.ts +96 -9
- package/src/web/stores/project-store.ts +87 -3
- package/src/web/stores/settings-store.ts +31 -4
- package/src/web/stores/tab-store.ts +0 -2
- package/vite.config.ts +6 -2
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
- package/dist/web/assets/button-CQ5h5gxS.js +0 -41
- package/dist/web/assets/chat-tab-Cfw__7vJ.js +0 -6
- package/dist/web/assets/code-editor-D8Pz69sx.js +0 -2
- package/dist/web/assets/dialog-BL9i7XEo.js +0 -5
- package/dist/web/assets/diff-viewer-CWS5n7ur.js +0 -4
- package/dist/web/assets/dist-0XHv8Vwc.js +0 -1
- package/dist/web/assets/dist-Ca3N8Xbh.js +0 -46
- package/dist/web/assets/git-graph-DwA62J8-.js +0 -1
- package/dist/web/assets/git-status-panel-DaB-zzSF.js +0 -1
- package/dist/web/assets/index-BYIXPY6U.css +0 -2
- package/dist/web/assets/index-DbTCLiox.js +0 -17
- package/dist/web/assets/project-list-Z4lhtp6P.js +0 -1
- package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
- package/dist/web/assets/settings-tab-BW6MGcir.js +0 -1
- package/dist/web/assets/trash-2-CGlFXde_.js +0 -1
- package/dist/web/assets/x-C0Rw5Giw.js +0 -1
- /package/dist/web/assets/{api-client-DzH9zCD7.js → api-client-BCjah751.js} +0 -0
- /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
- /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
- /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
- /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
- /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
|
+
}
|