@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,191 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { api, projectUrl } from "@/lib/api-client";
3
+ import type { FileNode } from "@/stores/file-store";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ } from "@/components/ui/dialog";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Input } from "@/components/ui/input";
14
+
15
+ interface FileActionsProps {
16
+ action: string;
17
+ node: FileNode;
18
+ projectName: string;
19
+ onClose: () => void;
20
+ onRefresh: () => void;
21
+ }
22
+
23
+ export function FileActions({
24
+ action,
25
+ node,
26
+ projectName,
27
+ onClose,
28
+ onRefresh,
29
+ }: FileActionsProps) {
30
+ const [name, setName] = useState("");
31
+ const [loading, setLoading] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const inputRef = useRef<HTMLInputElement>(null);
34
+
35
+ useEffect(() => {
36
+ if (action === "rename") {
37
+ setName(node.name);
38
+ } else {
39
+ setName("");
40
+ }
41
+ }, [action, node.name]);
42
+
43
+ useEffect(() => {
44
+ // Focus input after dialog mounts
45
+ const timer = setTimeout(() => inputRef.current?.focus(), 100);
46
+ return () => clearTimeout(timer);
47
+ }, []);
48
+
49
+ async function handleCreate(type: "file" | "directory") {
50
+ if (!name.trim()) return;
51
+ setLoading(true);
52
+ setError(null);
53
+ try {
54
+ const parentPath = node.type === "directory" ? node.path : node.path.split("/").slice(0, -1).join("/");
55
+ const fullPath = parentPath ? `${parentPath}/${name.trim()}` : name.trim();
56
+ await api.post(`${projectUrl(projectName)}/files/create`, {
57
+ path: fullPath,
58
+ type,
59
+ });
60
+ onRefresh();
61
+ onClose();
62
+ } catch (err) {
63
+ setError(err instanceof Error ? err.message : "Failed to create");
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ }
68
+
69
+ async function handleRename() {
70
+ if (!name.trim() || name.trim() === node.name) {
71
+ onClose();
72
+ return;
73
+ }
74
+ setLoading(true);
75
+ setError(null);
76
+ try {
77
+ const parentPath = node.path.split("/").slice(0, -1).join("/");
78
+ const newPath = parentPath ? `${parentPath}/${name.trim()}` : name.trim();
79
+ await api.post(`${projectUrl(projectName)}/files/rename`, {
80
+ oldPath: node.path,
81
+ newPath,
82
+ });
83
+ onRefresh();
84
+ onClose();
85
+ } catch (err) {
86
+ setError(err instanceof Error ? err.message : "Failed to rename");
87
+ } finally {
88
+ setLoading(false);
89
+ }
90
+ }
91
+
92
+ async function handleDelete() {
93
+ setLoading(true);
94
+ setError(null);
95
+ try {
96
+ await api.del(`${projectUrl(projectName)}/files/delete`, {
97
+ path: node.path,
98
+ });
99
+ onRefresh();
100
+ onClose();
101
+ } catch (err) {
102
+ setError(err instanceof Error ? err.message : "Failed to delete");
103
+ } finally {
104
+ setLoading(false);
105
+ }
106
+ }
107
+
108
+ function handleKeyDown(e: React.KeyboardEvent) {
109
+ if (e.key === "Enter") {
110
+ if (action === "new-file") handleCreate("file");
111
+ else if (action === "new-folder") handleCreate("directory");
112
+ else if (action === "rename") handleRename();
113
+ }
114
+ if (e.key === "Escape") onClose();
115
+ }
116
+
117
+ if (action === "delete") {
118
+ return (
119
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
120
+ <DialogContent>
121
+ <DialogHeader>
122
+ <DialogTitle>Delete {node.type === "directory" ? "Folder" : "File"}</DialogTitle>
123
+ <DialogDescription>
124
+ Are you sure you want to delete{" "}
125
+ <span className="font-mono font-semibold">{node.name}</span>?
126
+ {node.type === "directory" && " This will delete all contents."}
127
+ </DialogDescription>
128
+ </DialogHeader>
129
+ {error && <p className="text-sm text-error">{error}</p>}
130
+ <DialogFooter>
131
+ <Button variant="outline" onClick={onClose} disabled={loading}>
132
+ Cancel
133
+ </Button>
134
+ <Button variant="destructive" onClick={handleDelete} disabled={loading}>
135
+ {loading ? "Deleting..." : "Delete"}
136
+ </Button>
137
+ </DialogFooter>
138
+ </DialogContent>
139
+ </Dialog>
140
+ );
141
+ }
142
+
143
+ const title =
144
+ action === "new-file"
145
+ ? "New File"
146
+ : action === "new-folder"
147
+ ? "New Folder"
148
+ : "Rename";
149
+
150
+ const placeholder =
151
+ action === "rename" ? node.name : action === "new-file" ? "filename.ts" : "folder-name";
152
+
153
+ return (
154
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
155
+ <DialogContent>
156
+ <DialogHeader>
157
+ <DialogTitle>{title}</DialogTitle>
158
+ <DialogDescription>
159
+ {action === "rename"
160
+ ? `Rename "${node.name}" to:`
161
+ : `Create in ${node.type === "directory" ? node.path || "/" : node.path.split("/").slice(0, -1).join("/") || "/"}`}
162
+ </DialogDescription>
163
+ </DialogHeader>
164
+ <Input
165
+ ref={inputRef}
166
+ value={name}
167
+ onChange={(e) => setName(e.target.value)}
168
+ onKeyDown={handleKeyDown}
169
+ placeholder={placeholder}
170
+ disabled={loading}
171
+ />
172
+ {error && <p className="text-sm text-error">{error}</p>}
173
+ <DialogFooter>
174
+ <Button variant="outline" onClick={onClose} disabled={loading}>
175
+ Cancel
176
+ </Button>
177
+ <Button
178
+ onClick={() => {
179
+ if (action === "new-file") handleCreate("file");
180
+ else if (action === "new-folder") handleCreate("directory");
181
+ else handleRename();
182
+ }}
183
+ disabled={loading || !name.trim()}
184
+ >
185
+ {loading ? "Saving..." : action === "rename" ? "Rename" : "Create"}
186
+ </Button>
187
+ </DialogFooter>
188
+ </DialogContent>
189
+ </Dialog>
190
+ );
191
+ }
@@ -0,0 +1,298 @@
1
+ import { useEffect, useCallback, useState } from "react";
2
+ import {
3
+ Folder,
4
+ FolderOpen,
5
+ File,
6
+ FileCode,
7
+ FileJson,
8
+ FileText,
9
+ FileType,
10
+ ChevronRight,
11
+ ChevronDown,
12
+ Loader2,
13
+ } from "lucide-react";
14
+ import { useFileStore, type FileNode } from "@/stores/file-store";
15
+ import { useProjectStore } from "@/stores/project-store";
16
+ import { useTabStore } from "@/stores/tab-store";
17
+ import { cn } from "@/lib/utils";
18
+ import { ScrollArea } from "@/components/ui/scroll-area";
19
+ import {
20
+ ContextMenu,
21
+ ContextMenuContent,
22
+ ContextMenuItem,
23
+ ContextMenuSeparator,
24
+ ContextMenuTrigger,
25
+ } from "@/components/ui/context-menu";
26
+ import { FileActions } from "./file-actions";
27
+
28
+ const FILE_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
29
+ ts: FileCode,
30
+ tsx: FileCode,
31
+ js: FileCode,
32
+ jsx: FileCode,
33
+ py: FileCode,
34
+ rs: FileCode,
35
+ go: FileCode,
36
+ json: FileJson,
37
+ md: FileText,
38
+ txt: FileText,
39
+ yaml: FileType,
40
+ yml: FileType,
41
+ html: FileCode,
42
+ css: FileCode,
43
+ scss: FileCode,
44
+ };
45
+
46
+ function getFileIcon(name: string): React.ComponentType<{ className?: string }> {
47
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
48
+ return FILE_ICON_MAP[ext] ?? File;
49
+ }
50
+
51
+ interface TreeNodeProps {
52
+ node: FileNode;
53
+ depth: number;
54
+ projectName: string;
55
+ onAction: (action: string, node: FileNode) => void;
56
+ onFileOpen?: () => void;
57
+ }
58
+
59
+ function TreeNode({ node, depth, projectName, onAction, onFileOpen }: TreeNodeProps) {
60
+ const { expandedPaths, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore();
61
+ const openTab = useTabStore((s) => s.openTab);
62
+ const isExpanded = expandedPaths.has(node.path);
63
+ const isDir = node.type === "directory";
64
+ const isSelected = selectedFiles.includes(node.path);
65
+
66
+ function handleClick(e: React.MouseEvent) {
67
+ if (isDir) {
68
+ toggleExpand(node.path);
69
+ return;
70
+ }
71
+ // Ctrl/Cmd+Click: toggle file selection for compare
72
+ if (e.metaKey || e.ctrlKey) {
73
+ toggleFileSelect(node.path);
74
+ return;
75
+ }
76
+ openTab({
77
+ type: "editor",
78
+ title: node.name,
79
+ metadata: { filePath: node.path, projectName },
80
+ projectId: projectName,
81
+ closable: true,
82
+ });
83
+ onFileOpen?.();
84
+ }
85
+
86
+ const Icon = isDir
87
+ ? isExpanded
88
+ ? FolderOpen
89
+ : Folder
90
+ : getFileIcon(node.name);
91
+
92
+ const sortedChildren = node.children
93
+ ? [...node.children].sort((a, b) => {
94
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
95
+ return a.name.localeCompare(b.name);
96
+ })
97
+ : [];
98
+
99
+ return (
100
+ <div>
101
+ <ContextMenu>
102
+ <ContextMenuTrigger asChild>
103
+ <button
104
+ onClick={handleClick}
105
+ className={cn(
106
+ "flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
107
+ "min-h-[32px] hover:bg-surface-elevated transition-colors text-left",
108
+ "select-none",
109
+ isSelected && "bg-primary/15 ring-1 ring-primary/40",
110
+ )}
111
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
112
+ >
113
+ {isDir ? (
114
+ isExpanded ? (
115
+ <ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
116
+ ) : (
117
+ <ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
118
+ )
119
+ ) : (
120
+ <span className="w-3.5 shrink-0" />
121
+ )}
122
+ <Icon
123
+ className={cn(
124
+ "size-4 shrink-0",
125
+ isDir ? "text-primary" : "text-text-secondary",
126
+ )}
127
+ />
128
+ <span className="truncate">{node.name}</span>
129
+ </button>
130
+ </ContextMenuTrigger>
131
+ <ContextMenuContent>
132
+ {isDir && (
133
+ <>
134
+ <ContextMenuItem onClick={() => onAction("new-file", node)}>
135
+ New File
136
+ </ContextMenuItem>
137
+ <ContextMenuItem onClick={() => onAction("new-folder", node)}>
138
+ New Folder
139
+ </ContextMenuItem>
140
+ <ContextMenuSeparator />
141
+ </>
142
+ )}
143
+ <ContextMenuItem onClick={() => onAction("rename", node)}>
144
+ Rename
145
+ </ContextMenuItem>
146
+ <ContextMenuItem
147
+ variant="destructive"
148
+ onClick={() => onAction("delete", node)}
149
+ >
150
+ Delete
151
+ </ContextMenuItem>
152
+ <ContextMenuSeparator />
153
+ <ContextMenuItem onClick={() => onAction("copy-path", node)}>
154
+ Copy Path
155
+ </ContextMenuItem>
156
+ {!isDir && selectedFiles.length === 2 && (
157
+ <>
158
+ <ContextMenuSeparator />
159
+ <ContextMenuItem onClick={() => onAction("compare-selected", node)}>
160
+ Compare Selected
161
+ </ContextMenuItem>
162
+ </>
163
+ )}
164
+ </ContextMenuContent>
165
+ </ContextMenu>
166
+
167
+ {isDir && isExpanded && sortedChildren.map((child) => (
168
+ <TreeNode
169
+ key={child.path}
170
+ node={child}
171
+ depth={depth + 1}
172
+ projectName={projectName}
173
+ onAction={onAction}
174
+ onFileOpen={onFileOpen}
175
+ />
176
+ ))}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ interface FileTreeProps {
182
+ onFileOpen?: () => void;
183
+ }
184
+
185
+ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
186
+ const { tree, loading, error, fetchTree, reset, selectedFiles, clearSelection } = useFileStore();
187
+ const activeProject = useProjectStore((s) => s.activeProject);
188
+ const openTab = useTabStore((s) => s.openTab);
189
+ const [actionState, setActionState] = useState<{
190
+ action: string;
191
+ node: FileNode;
192
+ } | null>(null);
193
+
194
+ const loadTree = useCallback(() => {
195
+ if (activeProject) {
196
+ fetchTree(activeProject.name);
197
+ }
198
+ }, [activeProject, fetchTree]);
199
+
200
+ useEffect(() => {
201
+ if (activeProject) {
202
+ reset();
203
+ loadTree();
204
+ }
205
+ }, [activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
206
+
207
+ function handleAction(action: string, node: FileNode) {
208
+ if (action === "copy-path") {
209
+ navigator.clipboard.writeText(node.path).catch(() => {});
210
+ return;
211
+ }
212
+ if (action === "compare-selected" && selectedFiles.length === 2) {
213
+ const file1 = selectedFiles[0]!;
214
+ const file2 = selectedFiles[1]!;
215
+ const name1 = file1.split("/").pop() ?? file1;
216
+ const name2 = file2.split("/").pop() ?? file2;
217
+ openTab({
218
+ type: "git-diff",
219
+ title: `Compare ${name1} vs ${name2}`,
220
+ closable: true,
221
+ metadata: {
222
+ projectName: activeProject!.name,
223
+ file1,
224
+ file2,
225
+ },
226
+ projectId: activeProject!.name,
227
+ });
228
+ clearSelection();
229
+ return;
230
+ }
231
+ setActionState({ action, node });
232
+ }
233
+
234
+ if (!activeProject) {
235
+ return (
236
+ <div className="p-3 text-xs text-text-subtle">
237
+ Select a project to browse files.
238
+ </div>
239
+ );
240
+ }
241
+
242
+ if (loading && tree.length === 0) {
243
+ return (
244
+ <div className="flex items-center gap-2 p-3 text-xs text-text-secondary">
245
+ <Loader2 className="size-3 animate-spin" />
246
+ Loading files...
247
+ </div>
248
+ );
249
+ }
250
+
251
+ if (error) {
252
+ return (
253
+ <div className="p-3 text-xs text-error">
254
+ {error}
255
+ <button onClick={loadTree} className="block mt-1 text-primary underline">
256
+ Retry
257
+ </button>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ const sorted = [...tree].sort((a, b) => {
263
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
264
+ return a.name.localeCompare(b.name);
265
+ });
266
+
267
+ return (
268
+ <>
269
+ <ScrollArea className="flex-1">
270
+ <div className="py-1">
271
+ {sorted.map((node) => (
272
+ <TreeNode
273
+ key={node.path}
274
+ node={node}
275
+ depth={0}
276
+ projectName={activeProject.name}
277
+ onAction={handleAction}
278
+ onFileOpen={onFileOpen}
279
+ />
280
+ ))}
281
+ {sorted.length === 0 && (
282
+ <p className="p-3 text-xs text-text-subtle">Empty project.</p>
283
+ )}
284
+ </div>
285
+ </ScrollArea>
286
+
287
+ {actionState && (
288
+ <FileActions
289
+ action={actionState.action}
290
+ node={actionState.node}
291
+ projectName={activeProject.name}
292
+ onClose={() => setActionState(null)}
293
+ onRefresh={loadTree}
294
+ />
295
+ )}
296
+ </>
297
+ );
298
+ }