@hienlh/ppm 0.1.0

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 (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,850 @@
1
+ import { useEffect, useState, useCallback, useMemo } from "react";
2
+ import {
3
+ Plus,
4
+ Minus,
5
+ RefreshCw,
6
+ ArrowUpFromLine,
7
+ ArrowDownToLine,
8
+ Loader2,
9
+ Undo2,
10
+ List,
11
+ FolderTree,
12
+ ChevronRight,
13
+ ChevronDown,
14
+ } from "lucide-react";
15
+ import { api, projectUrl } from "@/lib/api-client";
16
+ import { useTabStore } from "@/stores/tab-store";
17
+ import { Button } from "@/components/ui/button";
18
+ import { ScrollArea } from "@/components/ui/scroll-area";
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ } from "@/components/ui/dialog";
27
+ import type { GitStatus, GitFileChange } from "../../../types/git";
28
+
29
+ interface GitStatusPanelProps {
30
+ metadata?: Record<string, unknown>;
31
+ tabId?: string;
32
+ }
33
+
34
+ type ViewMode = "flat" | "tree";
35
+
36
+ const STATUS_COLORS: Record<string, string> = {
37
+ M: "text-yellow-500",
38
+ A: "text-green-500",
39
+ D: "text-red-500",
40
+ R: "text-blue-500",
41
+ C: "text-purple-500",
42
+ "?": "text-gray-400",
43
+ };
44
+
45
+ /** Build a tree structure from flat file paths */
46
+ interface TreeNode {
47
+ name: string;
48
+ fullPath: string;
49
+ file?: GitFileChange;
50
+ children: TreeNode[];
51
+ }
52
+
53
+ function buildTree(files: GitFileChange[]): TreeNode[] {
54
+ const root: TreeNode[] = [];
55
+
56
+ for (const f of files) {
57
+ const parts = f.path.split("/");
58
+ let current = root;
59
+
60
+ for (let i = 0; i < parts.length; i++) {
61
+ const part = parts[i]!;
62
+ const fullPath = parts.slice(0, i + 1).join("/");
63
+ const isFile = i === parts.length - 1;
64
+
65
+ let existing = current.find((n) => n.name === part);
66
+ if (!existing) {
67
+ existing = {
68
+ name: part,
69
+ fullPath,
70
+ file: isFile ? f : undefined,
71
+ children: [],
72
+ };
73
+ current.push(existing);
74
+ }
75
+ if (isFile) {
76
+ existing.file = f;
77
+ }
78
+ current = existing.children;
79
+ }
80
+ }
81
+
82
+ return root;
83
+ }
84
+
85
+ /** Collect all file paths under a tree node (recursively) */
86
+ function collectFiles(node: TreeNode): GitFileChange[] {
87
+ const result: GitFileChange[] = [];
88
+ if (node.file) result.push(node.file);
89
+ for (const child of node.children) {
90
+ result.push(...collectFiles(child));
91
+ }
92
+ return result;
93
+ }
94
+
95
+ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
96
+ const projectName = metadata?.projectName as string | undefined;
97
+ const [status, setStatus] = useState<GitStatus | null>(null);
98
+ const [loading, setLoading] = useState(true);
99
+ const [error, setError] = useState<string | null>(null);
100
+ const [commitMsg, setCommitMsg] = useState("");
101
+ const [acting, setActing] = useState(false);
102
+ const [revertTarget, setRevertTarget] = useState<{
103
+ label: string;
104
+ files: string[];
105
+ } | null>(null);
106
+ const { openTab, updateTab } = useTabStore();
107
+
108
+ // Restore viewMode from tab metadata
109
+ const viewMode: ViewMode =
110
+ (metadata?.viewMode as ViewMode) === "tree" ? "tree" : "flat";
111
+
112
+ const setViewMode = (mode: ViewMode) => {
113
+ if (tabId) {
114
+ updateTab(tabId, { metadata: { ...metadata, viewMode: mode } });
115
+ }
116
+ };
117
+
118
+ const fetchStatus = useCallback(async () => {
119
+ if (!projectName) return;
120
+ try {
121
+ setLoading(true);
122
+ const data = await api.get<GitStatus>(
123
+ `${projectUrl(projectName)}/git/status`,
124
+ );
125
+ setStatus(data);
126
+ setError(null);
127
+ } catch (e) {
128
+ setError(e instanceof Error ? e.message : "Failed to fetch status");
129
+ } finally {
130
+ setLoading(false);
131
+ }
132
+ }, [projectName]);
133
+
134
+ useEffect(() => {
135
+ fetchStatus();
136
+ }, [fetchStatus]);
137
+
138
+ const stageFiles = async (files: string[]) => {
139
+ if (!projectName) return;
140
+ setActing(true);
141
+ try {
142
+ await api.post(`${projectUrl(projectName)}/git/stage`, { files });
143
+ await fetchStatus();
144
+ } catch (e) {
145
+ setError(e instanceof Error ? e.message : "Stage failed");
146
+ } finally {
147
+ setActing(false);
148
+ }
149
+ };
150
+
151
+ const unstageFiles = async (files: string[]) => {
152
+ if (!projectName) return;
153
+ setActing(true);
154
+ try {
155
+ await api.post(`${projectUrl(projectName)}/git/unstage`, { files });
156
+ await fetchStatus();
157
+ } catch (e) {
158
+ setError(e instanceof Error ? e.message : "Unstage failed");
159
+ } finally {
160
+ setActing(false);
161
+ }
162
+ };
163
+
164
+ const discardChanges = async (files: string[]) => {
165
+ if (!projectName) return;
166
+ setActing(true);
167
+ try {
168
+ await api.post(`${projectUrl(projectName)}/git/discard`, { files });
169
+ await fetchStatus();
170
+ } catch (e) {
171
+ setError(e instanceof Error ? e.message : "Discard failed");
172
+ } finally {
173
+ setActing(false);
174
+ }
175
+ };
176
+
177
+ const handleConfirmRevert = async () => {
178
+ if (!revertTarget) return;
179
+ await discardChanges(revertTarget.files);
180
+ setRevertTarget(null);
181
+ };
182
+
183
+ const handleCommit = async () => {
184
+ if (!projectName || !commitMsg.trim() || !status?.staged.length) return;
185
+ setActing(true);
186
+ try {
187
+ await api.post(`${projectUrl(projectName)}/git/commit`, {
188
+ message: commitMsg.trim(),
189
+ });
190
+ setCommitMsg("");
191
+ await fetchStatus();
192
+ } catch (e) {
193
+ setError(e instanceof Error ? e.message : "Commit failed");
194
+ } finally {
195
+ setActing(false);
196
+ }
197
+ };
198
+
199
+ const handlePush = async () => {
200
+ if (!projectName) return;
201
+ setActing(true);
202
+ try {
203
+ await api.post(`${projectUrl(projectName)}/git/push`, {});
204
+ await fetchStatus();
205
+ } catch (e) {
206
+ setError(e instanceof Error ? e.message : "Push failed");
207
+ } finally {
208
+ setActing(false);
209
+ }
210
+ };
211
+
212
+ const handlePull = async () => {
213
+ if (!projectName) return;
214
+ setActing(true);
215
+ try {
216
+ await api.post(`${projectUrl(projectName)}/git/pull`, {});
217
+ await fetchStatus();
218
+ } catch (e) {
219
+ setError(e instanceof Error ? e.message : "Pull failed");
220
+ } finally {
221
+ setActing(false);
222
+ }
223
+ };
224
+
225
+ const openDiff = (file: GitFileChange) => {
226
+ openTab({
227
+ type: "git-diff",
228
+ title: file.path.split("/").pop() ?? file.path,
229
+ closable: true,
230
+ metadata: {
231
+ projectName,
232
+ filePath: file.path,
233
+ },
234
+ projectId: projectName ?? null,
235
+ });
236
+ };
237
+
238
+ const allUnstaged = useMemo(
239
+ () => [
240
+ ...(status?.unstaged ?? []),
241
+ ...(status?.untracked.map(
242
+ (p): GitFileChange => ({ path: p, status: "?" }),
243
+ ) ?? []),
244
+ ],
245
+ [status],
246
+ );
247
+
248
+ if (!projectName) {
249
+ return (
250
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
251
+ No project selected.
252
+ </div>
253
+ );
254
+ }
255
+
256
+ if (loading && !status) {
257
+ return (
258
+ <div className="flex items-center justify-center h-full gap-2 text-muted-foreground">
259
+ <Loader2 className="size-5 animate-spin" />
260
+ <span className="text-sm">Loading git status...</span>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ if (error && !status) {
266
+ return (
267
+ <div className="flex flex-col items-center justify-center h-full gap-2 text-destructive text-sm">
268
+ <p>{error}</p>
269
+ <Button variant="outline" size="sm" onClick={fetchStatus}>
270
+ Retry
271
+ </Button>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ return (
277
+ <div className="flex flex-col h-full overflow-hidden">
278
+ {/* Header */}
279
+ <div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
280
+ <span className="text-sm font-medium">
281
+ {status?.current ? `On: ${status.current}` : "Git Status"}
282
+ </span>
283
+ <div className="flex items-center gap-1">
284
+ <Button
285
+ variant={viewMode === "flat" ? "secondary" : "ghost"}
286
+ size="icon-xs"
287
+ onClick={() => setViewMode("flat")}
288
+ title="Flat view"
289
+ >
290
+ <List className="size-3.5" />
291
+ </Button>
292
+ <Button
293
+ variant={viewMode === "tree" ? "secondary" : "ghost"}
294
+ size="icon-xs"
295
+ onClick={() => setViewMode("tree")}
296
+ title="Tree view"
297
+ >
298
+ <FolderTree className="size-3.5" />
299
+ </Button>
300
+ <Button
301
+ variant="ghost"
302
+ size="icon-xs"
303
+ onClick={fetchStatus}
304
+ disabled={acting}
305
+ >
306
+ <RefreshCw className={loading ? "animate-spin" : ""} />
307
+ </Button>
308
+ </div>
309
+ </div>
310
+
311
+ {error && (
312
+ <div className="px-3 py-1.5 text-xs text-destructive bg-destructive/10 shrink-0">
313
+ {error}
314
+ </div>
315
+ )}
316
+
317
+ <ScrollArea className="flex-1 overflow-hidden">
318
+ <div className="p-2 space-y-3 overflow-hidden">
319
+ {/* Staged Changes */}
320
+ <FileSection
321
+ title="Staged Changes"
322
+ count={status?.staged.length ?? 0}
323
+ files={status?.staged ?? []}
324
+ viewMode={viewMode}
325
+ actionIcon={<Minus className="size-3.5" />}
326
+ actionTitle="Unstage"
327
+ onAction={(f) => unstageFiles([f.path])}
328
+ onActionAll={
329
+ status?.staged.length
330
+ ? () => unstageFiles(status.staged.map((f) => f.path))
331
+ : undefined
332
+ }
333
+ actionAllLabel="Unstage All"
334
+ onFolderAction={(files) => unstageFiles(files.map((f) => f.path))}
335
+ onClickFile={openDiff}
336
+ disabled={acting}
337
+ />
338
+
339
+ {/* Unstaged Changes */}
340
+ <FileSection
341
+ title="Changes"
342
+ count={allUnstaged.length}
343
+ files={allUnstaged}
344
+ viewMode={viewMode}
345
+ actionIcon={<Plus className="size-3.5" />}
346
+ actionTitle="Stage"
347
+ onAction={(f) => stageFiles([f.path])}
348
+ onActionAll={
349
+ allUnstaged.length
350
+ ? () => stageFiles(allUnstaged.map((f) => f.path))
351
+ : undefined
352
+ }
353
+ actionAllLabel="Stage All"
354
+ onFolderAction={(files) => stageFiles(files.map((f) => f.path))}
355
+ onClickFile={openDiff}
356
+ disabled={acting}
357
+ showRevert
358
+ onRevert={(f) =>
359
+ setRevertTarget({ label: f.path, files: [f.path] })
360
+ }
361
+ onFolderRevert={(files, folderName) =>
362
+ setRevertTarget({
363
+ label: `${folderName}/ (${files.length} files)`,
364
+ files: files.map((f) => f.path),
365
+ })
366
+ }
367
+ />
368
+ </div>
369
+ </ScrollArea>
370
+
371
+ {/* Commit section */}
372
+ <div className="border-t p-2 space-y-2 shrink-0">
373
+ <textarea
374
+ className="w-full h-16 px-2 py-1.5 text-sm bg-muted/50 border rounded resize-none focus:outline-none focus:ring-1 focus:ring-ring"
375
+ placeholder="Commit message..."
376
+ value={commitMsg}
377
+ onChange={(e) => setCommitMsg(e.target.value)}
378
+ onKeyDown={(e) => {
379
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
380
+ handleCommit();
381
+ }
382
+ }}
383
+ />
384
+ <Button
385
+ size="sm"
386
+ className="w-full"
387
+ disabled={
388
+ acting || !commitMsg.trim() || !status?.staged.length
389
+ }
390
+ onClick={handleCommit}
391
+ >
392
+ {acting ? (
393
+ <Loader2 className="size-3 animate-spin" />
394
+ ) : (
395
+ `Commit (${status?.staged.length ?? 0})`
396
+ )}
397
+ </Button>
398
+
399
+ <div className="flex gap-2">
400
+ <Button
401
+ variant="outline"
402
+ size="sm"
403
+ className="flex-1"
404
+ disabled={acting}
405
+ onClick={handlePush}
406
+ >
407
+ <ArrowUpFromLine className="size-3" />
408
+ Push
409
+ </Button>
410
+ <Button
411
+ variant="outline"
412
+ size="sm"
413
+ className="flex-1"
414
+ disabled={acting}
415
+ onClick={handlePull}
416
+ >
417
+ <ArrowDownToLine className="size-3" />
418
+ Pull
419
+ </Button>
420
+ </div>
421
+ </div>
422
+
423
+ {/* Revert confirmation dialog */}
424
+ <Dialog
425
+ open={!!revertTarget}
426
+ onOpenChange={(open) => !open && setRevertTarget(null)}
427
+ >
428
+ <DialogContent showCloseButton={false}>
429
+ <DialogHeader>
430
+ <DialogTitle>Discard Changes</DialogTitle>
431
+ <DialogDescription>
432
+ Are you sure you want to discard all changes to{" "}
433
+ <code className="px-1 py-0.5 rounded bg-muted text-sm font-mono">
434
+ {revertTarget?.label}
435
+ </code>
436
+ ? This action cannot be undone.
437
+ </DialogDescription>
438
+ </DialogHeader>
439
+ <DialogFooter>
440
+ <Button
441
+ variant="outline"
442
+ onClick={() => setRevertTarget(null)}
443
+ >
444
+ Cancel
445
+ </Button>
446
+ <Button
447
+ variant="destructive"
448
+ onClick={handleConfirmRevert}
449
+ disabled={acting}
450
+ >
451
+ {acting ? (
452
+ <Loader2 className="size-3 animate-spin" />
453
+ ) : (
454
+ "Discard"
455
+ )}
456
+ </Button>
457
+ </DialogFooter>
458
+ </DialogContent>
459
+ </Dialog>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ /* ------------------------------------------------------------------ */
465
+ /* Action buttons */
466
+ /* ------------------------------------------------------------------ */
467
+
468
+ /** Inline action buttons for a file / folder row */
469
+ function ActionButtons({
470
+ showRevert,
471
+ onRevert,
472
+ onAction,
473
+ actionIcon,
474
+ actionTitle,
475
+ disabled,
476
+ }: {
477
+ showRevert?: boolean;
478
+ onRevert?: () => void;
479
+ onAction: () => void;
480
+ actionIcon: React.ReactNode;
481
+ actionTitle: string;
482
+ disabled: boolean;
483
+ }) {
484
+ return (
485
+ <div className="flex items-center gap-0.5 shrink-0 ml-1">
486
+ {showRevert && onRevert && (
487
+ <button
488
+ type="button"
489
+ className="flex items-center justify-center size-7 rounded border border-border/60 bg-muted/60 text-muted-foreground hover:bg-destructive/15 hover:text-destructive hover:border-destructive/40 active:scale-95 transition-colors"
490
+ onClick={(e) => {
491
+ e.stopPropagation();
492
+ onRevert();
493
+ }}
494
+ disabled={disabled}
495
+ title="Discard changes"
496
+ >
497
+ <Undo2 className="size-3.5" />
498
+ </button>
499
+ )}
500
+ <button
501
+ type="button"
502
+ className="flex items-center justify-center size-7 rounded border border-border/60 bg-muted/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground active:scale-95 transition-colors"
503
+ onClick={(e) => {
504
+ e.stopPropagation();
505
+ onAction();
506
+ }}
507
+ disabled={disabled}
508
+ title={actionTitle}
509
+ >
510
+ {actionIcon}
511
+ </button>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ /* ------------------------------------------------------------------ */
517
+ /* FileSection */
518
+ /* ------------------------------------------------------------------ */
519
+
520
+ function FileSection({
521
+ title,
522
+ count,
523
+ files,
524
+ viewMode,
525
+ actionIcon,
526
+ actionTitle,
527
+ onAction,
528
+ onActionAll,
529
+ actionAllLabel,
530
+ onFolderAction,
531
+ onClickFile,
532
+ disabled,
533
+ showRevert,
534
+ onRevert,
535
+ onFolderRevert,
536
+ }: {
537
+ title: string;
538
+ count: number;
539
+ files: GitFileChange[];
540
+ viewMode: ViewMode;
541
+ actionIcon: React.ReactNode;
542
+ actionTitle: string;
543
+ onAction: (f: GitFileChange) => void;
544
+ onActionAll?: () => void;
545
+ actionAllLabel: string;
546
+ onFolderAction?: (files: GitFileChange[]) => void;
547
+ onClickFile: (f: GitFileChange) => void;
548
+ disabled: boolean;
549
+ showRevert?: boolean;
550
+ onRevert?: (f: GitFileChange) => void;
551
+ onFolderRevert?: (files: GitFileChange[], folderName: string) => void;
552
+ }) {
553
+ return (
554
+ <div>
555
+ <div className="flex items-center justify-between mb-1">
556
+ <span className="text-xs font-medium text-muted-foreground uppercase">
557
+ {title} ({count})
558
+ </span>
559
+ {onActionAll && count > 0 && (
560
+ <Button
561
+ variant="ghost"
562
+ size="xs"
563
+ onClick={onActionAll}
564
+ disabled={disabled}
565
+ title={actionAllLabel}
566
+ >
567
+ {actionAllLabel}
568
+ </Button>
569
+ )}
570
+ </div>
571
+ {files.length === 0 ? (
572
+ <p className="text-xs text-muted-foreground px-1">No changes</p>
573
+ ) : viewMode === "flat" ? (
574
+ <div className="divide-y divide-border/40 w-full overflow-hidden">
575
+ {files.map((f) => (
576
+ <FileRow
577
+ key={f.path}
578
+ file={f}
579
+ actionIcon={actionIcon}
580
+ actionTitle={actionTitle}
581
+ onAction={onAction}
582
+ onClickFile={onClickFile}
583
+ disabled={disabled}
584
+ showRevert={showRevert}
585
+ onRevert={onRevert}
586
+ />
587
+ ))}
588
+ </div>
589
+ ) : (
590
+ <TreeView
591
+ files={files}
592
+ actionIcon={actionIcon}
593
+ actionTitle={actionTitle}
594
+ onAction={onAction}
595
+ onFolderAction={onFolderAction}
596
+ onClickFile={onClickFile}
597
+ disabled={disabled}
598
+ showRevert={showRevert}
599
+ onRevert={onRevert}
600
+ onFolderRevert={onFolderRevert}
601
+ />
602
+ )}
603
+ </div>
604
+ );
605
+ }
606
+
607
+ /* ------------------------------------------------------------------ */
608
+ /* FileRow */
609
+ /* ------------------------------------------------------------------ */
610
+
611
+ function FileRow({
612
+ file,
613
+ actionIcon,
614
+ actionTitle,
615
+ onAction,
616
+ onClickFile,
617
+ disabled,
618
+ showRevert,
619
+ onRevert,
620
+ displayName,
621
+ }: {
622
+ file: GitFileChange;
623
+ actionIcon: React.ReactNode;
624
+ actionTitle: string;
625
+ onAction: (f: GitFileChange) => void;
626
+ onClickFile: (f: GitFileChange) => void;
627
+ disabled: boolean;
628
+ showRevert?: boolean;
629
+ onRevert?: (f: GitFileChange) => void;
630
+ displayName?: string;
631
+ }) {
632
+ return (
633
+ <div className="flex items-center gap-1 hover:bg-muted/50 rounded px-1 py-1 w-full min-w-0">
634
+ <span
635
+ className={`text-xs font-mono w-4 text-center shrink-0 ${STATUS_COLORS[file.status] ?? ""}`}
636
+ >
637
+ {file.status}
638
+ </span>
639
+ <button
640
+ type="button"
641
+ className="flex-1 text-left text-xs font-mono truncate hover:underline min-w-0"
642
+ onClick={() => onClickFile(file)}
643
+ title={file.path}
644
+ >
645
+ {displayName ?? file.path}
646
+ </button>
647
+ <ActionButtons
648
+ showRevert={showRevert}
649
+ onRevert={onRevert ? () => onRevert(file) : undefined}
650
+ onAction={() => onAction(file)}
651
+ actionIcon={actionIcon}
652
+ actionTitle={actionTitle}
653
+ disabled={disabled}
654
+ />
655
+ </div>
656
+ );
657
+ }
658
+
659
+ /* ------------------------------------------------------------------ */
660
+ /* TreeView */
661
+ /* ------------------------------------------------------------------ */
662
+
663
+ function TreeView({
664
+ files,
665
+ actionIcon,
666
+ actionTitle,
667
+ onAction,
668
+ onFolderAction,
669
+ onClickFile,
670
+ disabled,
671
+ showRevert,
672
+ onRevert,
673
+ onFolderRevert,
674
+ }: {
675
+ files: GitFileChange[];
676
+ actionIcon: React.ReactNode;
677
+ actionTitle: string;
678
+ onAction: (f: GitFileChange) => void;
679
+ onFolderAction?: (files: GitFileChange[]) => void;
680
+ onClickFile: (f: GitFileChange) => void;
681
+ disabled: boolean;
682
+ showRevert?: boolean;
683
+ onRevert?: (f: GitFileChange) => void;
684
+ onFolderRevert?: (files: GitFileChange[], folderName: string) => void;
685
+ }) {
686
+ const tree = useMemo(() => buildTree(files), [files]);
687
+
688
+ return (
689
+ <div>
690
+ {tree.map((node, i) => (
691
+ <TreeNodeView
692
+ key={node.fullPath}
693
+ node={node}
694
+ depth={0}
695
+ isLast={i === tree.length - 1}
696
+ actionIcon={actionIcon}
697
+ actionTitle={actionTitle}
698
+ onAction={onAction}
699
+ onFolderAction={onFolderAction}
700
+ onClickFile={onClickFile}
701
+ disabled={disabled}
702
+ showRevert={showRevert}
703
+ onRevert={onRevert}
704
+ onFolderRevert={onFolderRevert}
705
+ />
706
+ ))}
707
+ </div>
708
+ );
709
+ }
710
+
711
+ /* ------------------------------------------------------------------ */
712
+ /* TreeNodeView */
713
+ /* ------------------------------------------------------------------ */
714
+
715
+ function TreeNodeView({
716
+ node,
717
+ depth,
718
+ isLast,
719
+ actionIcon,
720
+ actionTitle,
721
+ onAction,
722
+ onFolderAction,
723
+ onClickFile,
724
+ disabled,
725
+ showRevert,
726
+ onRevert,
727
+ onFolderRevert,
728
+ }: {
729
+ node: TreeNode;
730
+ depth: number;
731
+ isLast: boolean;
732
+ actionIcon: React.ReactNode;
733
+ actionTitle: string;
734
+ onAction: (f: GitFileChange) => void;
735
+ onFolderAction?: (files: GitFileChange[]) => void;
736
+ onClickFile: (f: GitFileChange) => void;
737
+ disabled: boolean;
738
+ showRevert?: boolean;
739
+ onRevert?: (f: GitFileChange) => void;
740
+ onFolderRevert?: (files: GitFileChange[], folderName: string) => void;
741
+ }) {
742
+ const [expanded, setExpanded] = useState(true);
743
+ const isDir = node.children.length > 0 && !node.file;
744
+
745
+ if (node.file) {
746
+ return (
747
+ <div
748
+ className="relative overflow-hidden border-b border-border/30"
749
+ style={{ paddingLeft: depth * 16 }}
750
+ >
751
+ {/* Vertical indent line */}
752
+ {depth > 0 && (
753
+ <div
754
+ className="absolute top-0 bottom-0 border-l border-border/30"
755
+ style={{ left: depth * 16 - 8 }}
756
+ />
757
+ )}
758
+ <FileRow
759
+ file={node.file}
760
+ displayName={node.name}
761
+ actionIcon={actionIcon}
762
+ actionTitle={actionTitle}
763
+ onAction={onAction}
764
+ onClickFile={onClickFile}
765
+ disabled={disabled}
766
+ showRevert={showRevert}
767
+ onRevert={onRevert}
768
+ />
769
+ </div>
770
+ );
771
+ }
772
+
773
+ if (isDir) {
774
+ const folderFiles = collectFiles(node);
775
+
776
+ return (
777
+ <div className="relative overflow-hidden">
778
+ {/* Vertical indent line for this level */}
779
+ {depth > 0 && (
780
+ <div
781
+ className="absolute top-0 border-l border-border/30"
782
+ style={{ left: depth * 16 - 8, bottom: isLast ? "50%" : 0 }}
783
+ />
784
+ )}
785
+ {/* Folder row */}
786
+ <div
787
+ className="flex items-center hover:bg-muted/50 rounded py-1 pr-1 border-b border-border/30"
788
+ style={{ paddingLeft: depth * 16 + 4 }}
789
+ >
790
+ <button
791
+ type="button"
792
+ className="flex items-center gap-1 flex-1 min-w-0 text-xs font-mono text-muted-foreground"
793
+ onClick={() => setExpanded(!expanded)}
794
+ >
795
+ {expanded ? (
796
+ <ChevronDown className="size-3.5 shrink-0" />
797
+ ) : (
798
+ <ChevronRight className="size-3.5 shrink-0" />
799
+ )}
800
+ <span className="truncate font-semibold">{node.name}</span>
801
+ <span className="text-[10px] opacity-60 shrink-0">
802
+ ({folderFiles.length})
803
+ </span>
804
+ </button>
805
+ <ActionButtons
806
+ showRevert={showRevert}
807
+ onRevert={
808
+ onFolderRevert
809
+ ? () => onFolderRevert(folderFiles, node.fullPath)
810
+ : undefined
811
+ }
812
+ onAction={() => onFolderAction?.(folderFiles)}
813
+ actionIcon={actionIcon}
814
+ actionTitle={`${actionTitle} ${node.name}/`}
815
+ disabled={disabled}
816
+ />
817
+ </div>
818
+ {/* Children with vertical guide line */}
819
+ {expanded && (
820
+ <div className="relative">
821
+ {/* Continuous vertical line for children */}
822
+ <div
823
+ className="absolute top-0 bottom-0 border-l border-border/30"
824
+ style={{ left: depth * 16 + 8 }}
825
+ />
826
+ {node.children.map((child, i) => (
827
+ <TreeNodeView
828
+ key={child.fullPath}
829
+ node={child}
830
+ depth={depth + 1}
831
+ isLast={i === node.children.length - 1}
832
+ actionIcon={actionIcon}
833
+ actionTitle={actionTitle}
834
+ onAction={onAction}
835
+ onFolderAction={onFolderAction}
836
+ onClickFile={onClickFile}
837
+ disabled={disabled}
838
+ showRevert={showRevert}
839
+ onRevert={onRevert}
840
+ onFolderRevert={onFolderRevert}
841
+ />
842
+ ))}
843
+ </div>
844
+ )}
845
+ </div>
846
+ );
847
+ }
848
+
849
+ return null;
850
+ }