@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,329 @@
1
+ import { useEffect, useState, useCallback, useRef } from "react";
2
+ import CodeMirror from "@uiw/react-codemirror";
3
+ import { oneDark } from "@codemirror/theme-one-dark";
4
+ import { javascript } from "@codemirror/lang-javascript";
5
+ import { python } from "@codemirror/lang-python";
6
+ import { html } from "@codemirror/lang-html";
7
+ import { css } from "@codemirror/lang-css";
8
+ import { json } from "@codemirror/lang-json";
9
+ import { markdown } from "@codemirror/lang-markdown";
10
+ import type { Extension } from "@codemirror/state";
11
+ import { api, projectUrl, getAuthToken } from "@/lib/api-client";
12
+ import { useTabStore } from "@/stores/tab-store";
13
+ import { Loader2, FileWarning, ExternalLink } from "lucide-react";
14
+
15
+ /** Image extensions renderable inline */
16
+ const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
17
+
18
+ /** PDF extension */
19
+ const PDF_EXT = "pdf";
20
+
21
+ function getFileExt(filename: string): string {
22
+ return filename.split(".").pop()?.toLowerCase() ?? "";
23
+ }
24
+
25
+ function getLanguageExtension(filename: string): Extension | null {
26
+ const ext = getFileExt(filename);
27
+ switch (ext) {
28
+ case "js":
29
+ case "jsx":
30
+ return javascript({ jsx: true });
31
+ case "ts":
32
+ case "tsx":
33
+ return javascript({ jsx: true, typescript: true });
34
+ case "py":
35
+ return python();
36
+ case "html":
37
+ return html();
38
+ case "css":
39
+ case "scss":
40
+ return css();
41
+ case "json":
42
+ return json();
43
+ case "md":
44
+ case "mdx":
45
+ return markdown();
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+
51
+ interface CodeEditorProps {
52
+ metadata?: Record<string, unknown>;
53
+ tabId?: string;
54
+ }
55
+
56
+ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
57
+ const filePath = metadata?.filePath as string | undefined;
58
+ const projectName = metadata?.projectName as string | undefined;
59
+ const [content, setContent] = useState<string | null>(null);
60
+ const [encoding, setEncoding] = useState<string>("utf-8");
61
+ const [loading, setLoading] = useState(true);
62
+ const [error, setError] = useState<string | null>(null);
63
+ const [unsaved, setUnsaved] = useState(false);
64
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+ const latestContentRef = useRef<string>("");
66
+ const { tabs, updateTab } = useTabStore();
67
+
68
+ const ownTab = tabs.find((t) => t.id === tabId);
69
+ const ext = filePath ? getFileExt(filePath) : "";
70
+ const isImage = IMAGE_EXTS.has(ext);
71
+ const isPdf = ext === PDF_EXT;
72
+
73
+ // Load file content
74
+ useEffect(() => {
75
+ if (!filePath || !projectName) return;
76
+ // Skip loading for images and PDFs — they use raw endpoint
77
+ if (isImage || isPdf) {
78
+ setLoading(false);
79
+ return;
80
+ }
81
+
82
+ setLoading(true);
83
+ setError(null);
84
+
85
+ api
86
+ .get<{ content: string; encoding: string }>(
87
+ `${projectUrl(projectName)}/files/read?path=${encodeURIComponent(filePath)}`,
88
+ )
89
+ .then((data) => {
90
+ setContent(data.content);
91
+ setEncoding(data.encoding);
92
+ latestContentRef.current = data.content;
93
+ setLoading(false);
94
+ })
95
+ .catch((err) => {
96
+ setError(err instanceof Error ? err.message : "Failed to load file");
97
+ setLoading(false);
98
+ });
99
+
100
+ return () => {
101
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
102
+ };
103
+ }, [filePath, projectName, isImage, isPdf]);
104
+
105
+ // Update tab title unsaved indicator
106
+ useEffect(() => {
107
+ if (!ownTab) return;
108
+ const baseName = filePath?.split("/").pop() ?? "Untitled";
109
+ const newTitle = unsaved ? `${baseName} \u25CF` : baseName;
110
+ if (ownTab.title !== newTitle) {
111
+ updateTab(ownTab.id, { title: newTitle });
112
+ }
113
+ }, [unsaved]); // eslint-disable-line react-hooks/exhaustive-deps
114
+
115
+ const saveFile = useCallback(
116
+ async (text: string) => {
117
+ if (!filePath || !projectName) return;
118
+ try {
119
+ await api.put(`${projectUrl(projectName)}/files/write`, {
120
+ path: filePath,
121
+ content: text,
122
+ });
123
+ setUnsaved(false);
124
+ } catch {
125
+ // Silent save failure — user sees unsaved indicator persists
126
+ }
127
+ },
128
+ [filePath, projectName],
129
+ );
130
+
131
+ function handleChange(value: string) {
132
+ setContent(value);
133
+ latestContentRef.current = value;
134
+ setUnsaved(true);
135
+
136
+ // Debounced auto-save (1s)
137
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
138
+ saveTimerRef.current = setTimeout(() => {
139
+ saveFile(latestContentRef.current);
140
+ }, 1000);
141
+ }
142
+
143
+ if (!filePath || !projectName) {
144
+ return (
145
+ <div className="flex items-center justify-center h-full text-text-secondary text-sm">
146
+ No file selected.
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (loading) {
152
+ return (
153
+ <div className="flex items-center justify-center h-full gap-2 text-text-secondary">
154
+ <Loader2 className="size-5 animate-spin" />
155
+ <span className="text-sm">Loading file...</span>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ if (error) {
161
+ return (
162
+ <div className="flex items-center justify-center h-full text-error text-sm">
163
+ {error}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ // --- Image preview ---
169
+ if (isImage) {
170
+ return <ImagePreview filePath={filePath} projectName={projectName} />;
171
+ }
172
+
173
+ // --- PDF viewer ---
174
+ if (isPdf) {
175
+ return <PdfPreview filePath={filePath} projectName={projectName} />;
176
+ }
177
+
178
+ // --- Binary file (base64 encoding) — cannot edit ---
179
+ if (encoding === "base64") {
180
+ return (
181
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
182
+ <FileWarning className="size-10 text-text-subtle" />
183
+ <p className="text-sm">This file is a binary format and cannot be displayed.</p>
184
+ <p className="text-xs text-text-subtle">{filePath}</p>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // --- Text editor ---
190
+ const extensions: Extension[] = [];
191
+ const langExt = getLanguageExtension(filePath);
192
+ if (langExt) extensions.push(langExt);
193
+
194
+ return (
195
+ <div className="h-full w-full overflow-hidden">
196
+ <CodeMirror
197
+ value={content ?? ""}
198
+ onChange={handleChange}
199
+ extensions={extensions}
200
+ theme={oneDark}
201
+ height="100%"
202
+ style={{ height: "100%", fontSize: "13px", fontFamily: "var(--font-mono)" }}
203
+ basicSetup={{
204
+ lineNumbers: true,
205
+ foldGutter: true,
206
+ autocompletion: true,
207
+ bracketMatching: true,
208
+ closeBrackets: true,
209
+ highlightActiveLine: true,
210
+ indentOnInput: true,
211
+ }}
212
+ />
213
+ </div>
214
+ );
215
+ }
216
+
217
+ /** Inline image preview with auth */
218
+ function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
219
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
220
+ const [error, setError] = useState(false);
221
+
222
+ useEffect(() => {
223
+ let revoke: string | undefined;
224
+ const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
225
+ const token = getAuthToken();
226
+ fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
227
+ .then((r) => {
228
+ if (!r.ok) throw new Error("Failed");
229
+ return r.blob();
230
+ })
231
+ .then((blob) => {
232
+ const objUrl = URL.createObjectURL(blob);
233
+ revoke = objUrl;
234
+ setBlobUrl(objUrl);
235
+ })
236
+ .catch(() => setError(true));
237
+ return () => { if (revoke) URL.revokeObjectURL(revoke); };
238
+ }, [filePath, projectName]);
239
+
240
+ if (error) {
241
+ return (
242
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
243
+ <FileWarning className="size-10 text-text-subtle" />
244
+ <p className="text-sm">Failed to load image.</p>
245
+ </div>
246
+ );
247
+ }
248
+
249
+ if (!blobUrl) {
250
+ return (
251
+ <div className="flex items-center justify-center h-full">
252
+ <Loader2 className="size-5 animate-spin text-text-subtle" />
253
+ </div>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <div className="flex items-center justify-center h-full p-4 bg-surface overflow-auto">
259
+ <img src={blobUrl} alt={filePath} className="max-w-full max-h-full object-contain" />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ /** PDF preview — fetches with auth, opens blob in iframe or new tab */
265
+ function PdfPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
266
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
267
+ const [error, setError] = useState(false);
268
+
269
+ useEffect(() => {
270
+ let revoke: string | undefined;
271
+ const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
272
+ const token = getAuthToken();
273
+ fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
274
+ .then((r) => {
275
+ if (!r.ok) throw new Error("Failed");
276
+ return r.blob();
277
+ })
278
+ .then((blob) => {
279
+ const objUrl = URL.createObjectURL(new Blob([blob], { type: "application/pdf" }));
280
+ revoke = objUrl;
281
+ setBlobUrl(objUrl);
282
+ })
283
+ .catch(() => setError(true));
284
+ return () => { if (revoke) URL.revokeObjectURL(revoke); };
285
+ }, [filePath, projectName]);
286
+
287
+ const openInNewTab = useCallback(() => {
288
+ if (blobUrl) window.open(blobUrl, "_blank");
289
+ }, [blobUrl]);
290
+
291
+ if (error) {
292
+ return (
293
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
294
+ <FileWarning className="size-10 text-text-subtle" />
295
+ <p className="text-sm">Failed to load PDF.</p>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ if (!blobUrl) {
301
+ return (
302
+ <div className="flex items-center justify-center h-full">
303
+ <Loader2 className="size-5 animate-spin text-text-subtle" />
304
+ </div>
305
+ );
306
+ }
307
+
308
+ return (
309
+ <div className="flex flex-col h-full">
310
+ {/* Toolbar */}
311
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background shrink-0">
312
+ <span className="text-xs text-text-secondary truncate">{filePath}</span>
313
+ <button
314
+ onClick={openInNewTab}
315
+ className="flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary transition-colors"
316
+ >
317
+ <ExternalLink className="size-3" />
318
+ Open in new tab
319
+ </button>
320
+ </div>
321
+ {/* Embedded PDF viewer */}
322
+ <iframe
323
+ src={blobUrl}
324
+ title={filePath}
325
+ className="flex-1 w-full border-none"
326
+ />
327
+ </div>
328
+ );
329
+ }
@@ -0,0 +1,276 @@
1
+ import { useEffect, useState, useMemo, useRef } from "react";
2
+ import { oneDark } from "@codemirror/theme-one-dark";
3
+ import { EditorView, lineNumbers } from "@codemirror/view";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { MergeView } from "@codemirror/merge";
6
+ import { javascript } from "@codemirror/lang-javascript";
7
+ import { python } from "@codemirror/lang-python";
8
+ import { html } from "@codemirror/lang-html";
9
+ import { css } from "@codemirror/lang-css";
10
+ import { json } from "@codemirror/lang-json";
11
+ import { markdown } from "@codemirror/lang-markdown";
12
+ import type { Extension } from "@codemirror/state";
13
+ import { api, projectUrl } from "@/lib/api-client";
14
+ import { Loader2, FileCode } from "lucide-react";
15
+
16
+ function getLanguageExtension(filename: string): Extension | null {
17
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
18
+ switch (ext) {
19
+ case "js":
20
+ case "jsx":
21
+ return javascript({ jsx: true });
22
+ case "ts":
23
+ case "tsx":
24
+ return javascript({ jsx: true, typescript: true });
25
+ case "py":
26
+ return python();
27
+ case "html":
28
+ return html();
29
+ case "css":
30
+ case "scss":
31
+ return css();
32
+ case "json":
33
+ return json();
34
+ case "md":
35
+ case "mdx":
36
+ return markdown();
37
+ default:
38
+ return null;
39
+ }
40
+ }
41
+
42
+ interface DiffViewerProps {
43
+ metadata?: Record<string, unknown>;
44
+ }
45
+
46
+ export function DiffViewer({ metadata }: DiffViewerProps) {
47
+ const filePath = metadata?.filePath as string | undefined;
48
+ const projectName = metadata?.projectName as string | undefined;
49
+ const ref1 = metadata?.ref1 as string | undefined;
50
+ const ref2 = metadata?.ref2 as string | undefined;
51
+ const file1 = metadata?.file1 as string | undefined;
52
+ const file2 = metadata?.file2 as string | undefined;
53
+ const isFileCompare = Boolean(file1 && file2);
54
+
55
+ const [diffText, setDiffText] = useState<string | null>(null);
56
+ const [fileContents, setFileContents] = useState<{ original: string; modified: string } | null>(null);
57
+ const [loading, setLoading] = useState(true);
58
+ const [error, setError] = useState<string | null>(null);
59
+ const containerRef = useRef<HTMLDivElement>(null);
60
+ const mergeViewRef = useRef<MergeView | null>(null);
61
+
62
+ useEffect(() => {
63
+ if (!projectName) return;
64
+ setLoading(true);
65
+ setError(null);
66
+
67
+ if (file1 && file2) {
68
+ const params = new URLSearchParams();
69
+ params.set("file1", file1);
70
+ params.set("file2", file2);
71
+ api
72
+ .get<{ original: string; modified: string }>(
73
+ `${projectUrl(projectName)}/files/compare?${params.toString()}`,
74
+ )
75
+ .then((data) => { setFileContents(data); setLoading(false); })
76
+ .catch((err) => { setError(err instanceof Error ? err.message : "Failed to compare files"); setLoading(false); });
77
+ return;
78
+ }
79
+
80
+ let url: string;
81
+ if (filePath) {
82
+ const params = new URLSearchParams();
83
+ params.set("file", filePath);
84
+ if (ref1) params.set("ref", ref1);
85
+ url = `${projectUrl(projectName)}/git/file-diff?${params.toString()}`;
86
+ } else if (ref1 || ref2) {
87
+ const params = new URLSearchParams();
88
+ if (ref1) params.set("ref1", ref1);
89
+ if (ref2) params.set("ref2", ref2);
90
+ url = `${projectUrl(projectName)}/git/diff?${params.toString()}`;
91
+ } else {
92
+ url = `${projectUrl(projectName)}/git/diff`;
93
+ }
94
+
95
+ api
96
+ .get<{ diff: string }>(url)
97
+ .then((data) => { setDiffText(data.diff); setLoading(false); })
98
+ .catch((err) => { setError(err instanceof Error ? err.message : "Failed to load diff"); setLoading(false); });
99
+ }, [filePath, projectName, ref1, ref2, file1, file2]);
100
+
101
+ const { original, modified } = useMemo(() => {
102
+ if (isFileCompare && fileContents) return fileContents;
103
+ if (!diffText) return { original: "", modified: "" };
104
+ return parseDiff(diffText);
105
+ }, [diffText, isFileCompare, fileContents]);
106
+
107
+ const langExts = useMemo(() => {
108
+ const langFile = filePath ?? file2 ?? file1;
109
+ if (!langFile) return [];
110
+ const ext = getLanguageExtension(langFile);
111
+ return ext ? [ext] : [];
112
+ }, [filePath, file1, file2]);
113
+
114
+ // Create MergeView when content is ready
115
+ useEffect(() => {
116
+ const container = containerRef.current;
117
+ if (!container || loading || error) return;
118
+ if (!original && !modified) return;
119
+
120
+ // Clean up previous
121
+ if (mergeViewRef.current) {
122
+ mergeViewRef.current.destroy();
123
+ mergeViewRef.current = null;
124
+ }
125
+
126
+ const isMobile = window.innerWidth < 768;
127
+ const sharedExts: Extension[] = [
128
+ ...langExts,
129
+ oneDark,
130
+ EditorView.editable.of(false),
131
+ EditorState.readOnly.of(true),
132
+ lineNumbers(),
133
+ EditorView.theme({
134
+ "&": { fontSize: "13px", fontFamily: "var(--font-mono)" },
135
+ // Character-level highlight: bold background, NO underline
136
+ "& .cm-changedText": {
137
+ textDecoration: "none !important",
138
+ borderBottom: "none !important",
139
+ textDecorationLine: "none !important",
140
+ backgroundColor: "rgba(16, 185, 129, 0.4) !important",
141
+ borderRadius: "2px",
142
+ },
143
+ "& .cm-deletedChunk .cm-changedText": {
144
+ backgroundColor: "rgba(239, 68, 68, 0.4) !important",
145
+ },
146
+ }),
147
+ ];
148
+
149
+ const mv = new MergeView({
150
+ parent: container,
151
+ a: { doc: original, extensions: sharedExts },
152
+ b: { doc: modified, extensions: sharedExts },
153
+ orientation: "a-b",
154
+ revertControls: undefined,
155
+ highlightChanges: true, // Highlight changed characters within a line
156
+ gutter: true,
157
+ });
158
+
159
+ mergeViewRef.current = mv;
160
+
161
+ // Sync horizontal scroll between both editors
162
+ const scrollerA = mv.a.dom.querySelector(".cm-scroller") as HTMLElement | null;
163
+ const scrollerB = mv.b.dom.querySelector(".cm-scroller") as HTMLElement | null;
164
+ let syncing = false;
165
+ const syncScroll = (source: HTMLElement, target: HTMLElement) => {
166
+ if (syncing) return;
167
+ syncing = true;
168
+ target.scrollLeft = source.scrollLeft;
169
+ syncing = false;
170
+ };
171
+ const onScrollA = () => scrollerA && scrollerB && syncScroll(scrollerA, scrollerB);
172
+ const onScrollB = () => scrollerA && scrollerB && syncScroll(scrollerB, scrollerA);
173
+ scrollerA?.addEventListener("scroll", onScrollA);
174
+ scrollerB?.addEventListener("scroll", onScrollB);
175
+
176
+ return () => {
177
+ scrollerA?.removeEventListener("scroll", onScrollA);
178
+ scrollerB?.removeEventListener("scroll", onScrollB);
179
+ mv.destroy();
180
+ mergeViewRef.current = null;
181
+ };
182
+ }, [original, modified, langExts, loading, error]);
183
+
184
+ if (!projectName) {
185
+ return (
186
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
187
+ No project selected.
188
+ </div>
189
+ );
190
+ }
191
+
192
+ if (loading) {
193
+ return (
194
+ <div className="flex items-center justify-center h-full gap-2 text-muted-foreground">
195
+ <Loader2 className="size-5 animate-spin" />
196
+ <span className="text-sm">Loading diff...</span>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ if (error) {
202
+ return (
203
+ <div className="flex items-center justify-center h-full text-destructive text-sm">
204
+ {error}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ if (!isFileCompare && (!diffText || diffText.trim() === "")) {
210
+ return (
211
+ <div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
212
+ <FileCode className="size-8" />
213
+ <p className="text-sm">No changes detected</p>
214
+ {filePath && <p className="text-xs font-mono">{filePath}</p>}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <div className="flex flex-col h-full">
221
+ {/* Header */}
222
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b text-xs text-muted-foreground">
223
+ <FileCode className="size-3.5" />
224
+ {isFileCompare ? (
225
+ <span className="font-mono truncate">{file1} vs {file2}</span>
226
+ ) : (
227
+ <>
228
+ <span className="font-mono">{filePath ?? "Working tree changes"}</span>
229
+ {(ref1 || ref2) && (
230
+ <span>({ref1?.slice(0, 7) ?? "HEAD"} vs {ref2?.slice(0, 7) ?? "working tree"})</span>
231
+ )}
232
+ </>
233
+ )}
234
+ </div>
235
+
236
+ {/* MergeView container — side-by-side, pinch-zoom on mobile */}
237
+ <div
238
+ ref={containerRef}
239
+ className="flex-1 overflow-auto touch-pinch-zoom [&_.cm-mergeView]:h-full"
240
+ style={{ WebkitOverflowScrolling: "touch" }}
241
+ />
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function parseDiff(diff: string): { original: string; modified: string } {
247
+ const lines = diff.split("\n");
248
+ const originalLines: string[] = [];
249
+ const modifiedLines: string[] = [];
250
+ let inHunk = false;
251
+
252
+ for (const line of lines) {
253
+ if (
254
+ line.startsWith("diff --git") ||
255
+ line.startsWith("index ") ||
256
+ line.startsWith("---") ||
257
+ line.startsWith("+++") ||
258
+ line.startsWith("Binary files")
259
+ ) continue;
260
+
261
+ if (line.startsWith("@@")) { inHunk = true; continue; }
262
+ if (!inHunk) continue;
263
+
264
+ if (line.startsWith("-")) {
265
+ originalLines.push(line.slice(1));
266
+ } else if (line.startsWith("+")) {
267
+ modifiedLines.push(line.slice(1));
268
+ } else if (line.startsWith(" ") || line === "") {
269
+ const content = line.startsWith(" ") ? line.slice(1) : line;
270
+ originalLines.push(content);
271
+ modifiedLines.push(content);
272
+ }
273
+ }
274
+
275
+ return { original: originalLines.join("\n"), modified: modifiedLines.join("\n") };
276
+ }
@@ -0,0 +1,10 @@
1
+ import { FileCode } from "lucide-react";
2
+
3
+ export function EditorPlaceholder() {
4
+ return (
5
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
6
+ <FileCode className="size-10 text-text-subtle" />
7
+ <p className="text-sm">Code Editor — coming in Phase 4</p>
8
+ </div>
9
+ );
10
+ }