@hienlh/ppm 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +18 -1
  3. package/bun.lock +57 -59
  4. package/dist/ppm +0 -0
  5. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  6. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  7. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  8. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  9. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  10. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  11. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  12. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  13. package/dist/web/assets/{terminal-tab-sGlqTp7k.js → terminal-tab-BEFAYT4S.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  15. package/dist/web/index.html +35 -8
  16. package/dist/web/sw.js +1 -1
  17. package/docs/codebase-summary.md +13 -8
  18. package/docs/project-roadmap.md +22 -4
  19. package/docs/system-architecture.md +59 -0
  20. package/package.json +6 -14
  21. package/src/providers/claude-agent-sdk.ts +2 -2
  22. package/src/providers/registry.ts +12 -11
  23. package/src/server/routes/projects.ts +43 -0
  24. package/src/server/routes/settings.ts +42 -8
  25. package/src/server/ws/chat.ts +2 -2
  26. package/src/services/config.service.ts +5 -1
  27. package/src/services/project.service.ts +1 -0
  28. package/src/types/config.ts +37 -0
  29. package/src/types/project.ts +1 -0
  30. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  35. package/src/web/app.tsx +43 -5
  36. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  37. package/src/web/components/chat/chat-tab.tsx +27 -19
  38. package/src/web/components/editor/code-editor.tsx +78 -197
  39. package/src/web/components/editor/diff-viewer.tsx +59 -176
  40. package/src/web/components/layout/add-project-form.tsx +151 -0
  41. package/src/web/components/layout/command-palette.tsx +3 -1
  42. package/src/web/components/layout/editor-panel.tsx +6 -4
  43. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  44. package/src/web/components/layout/mobile-nav.tsx +89 -6
  45. package/src/web/components/layout/panel-layout.tsx +16 -10
  46. package/src/web/components/layout/project-bar.tsx +329 -0
  47. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  48. package/src/web/components/layout/sidebar.tsx +56 -142
  49. package/src/web/components/layout/tab-bar.tsx +1 -6
  50. package/src/web/components/layout/tab-content.tsx +0 -10
  51. package/src/web/components/ui/dialog.tsx +1 -1
  52. package/src/web/lib/project-avatar.ts +45 -0
  53. package/src/web/lib/project-palette.ts +18 -0
  54. package/src/web/lib/use-monaco-theme.ts +29 -0
  55. package/src/web/stores/panel-store.ts +96 -9
  56. package/src/web/stores/project-store.ts +87 -3
  57. package/src/web/stores/settings-store.ts +31 -4
  58. package/src/web/stores/tab-store.ts +0 -2
  59. package/vite.config.ts +6 -2
  60. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  61. package/dist/web/assets/button-CQ5h5gxS.js +0 -41
  62. package/dist/web/assets/chat-tab-Cfw__7vJ.js +0 -6
  63. package/dist/web/assets/code-editor-D8Pz69sx.js +0 -2
  64. package/dist/web/assets/dialog-BL9i7XEo.js +0 -5
  65. package/dist/web/assets/diff-viewer-CWS5n7ur.js +0 -4
  66. package/dist/web/assets/dist-0XHv8Vwc.js +0 -1
  67. package/dist/web/assets/dist-Ca3N8Xbh.js +0 -46
  68. package/dist/web/assets/git-graph-DwA62J8-.js +0 -1
  69. package/dist/web/assets/git-status-panel-DaB-zzSF.js +0 -1
  70. package/dist/web/assets/index-BYIXPY6U.css +0 -2
  71. package/dist/web/assets/index-DbTCLiox.js +0 -17
  72. package/dist/web/assets/project-list-Z4lhtp6P.js +0 -1
  73. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  74. package/dist/web/assets/settings-tab-BW6MGcir.js +0 -1
  75. package/dist/web/assets/trash-2-CGlFXde_.js +0 -1
  76. package/dist/web/assets/x-C0Rw5Giw.js +0 -1
  77. /package/dist/web/assets/{api-client-DzH9zCD7.js → api-client-BCjah751.js} +0 -0
  78. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  79. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  80. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  81. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  82. /package/dist/web/assets/{utils-D6me7KDg.js → utils-B-_GCz7E.js} +0 -0
@@ -1,54 +1,32 @@
1
1
  import { useEffect, useState, useCallback, useRef, useMemo } 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";
2
+ import Editor, { type OnMount } from "@monaco-editor/react";
3
+ import type * as MonacoType from "monaco-editor";
10
4
  import { marked } from "marked";
11
- import type { Extension } from "@codemirror/state";
12
- import { EditorView } from "@codemirror/view";
13
5
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
14
6
  import { useTabStore } from "@/stores/tab-store";
15
7
  import { useSettingsStore } from "@/stores/settings-store";
8
+ import { useMonacoTheme } from "@/lib/use-monaco-theme";
16
9
  import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
17
10
 
18
11
  /** Image extensions renderable inline */
19
12
  const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
20
13
 
21
- /** PDF extension */
22
- const PDF_EXT = "pdf";
23
-
24
14
  function getFileExt(filename: string): string {
25
15
  return filename.split(".").pop()?.toLowerCase() ?? "";
26
16
  }
27
17
 
28
- function getLanguageExtension(filename: string): Extension | null {
18
+ function getMonacoLanguage(filename: string): string {
29
19
  const ext = getFileExt(filename);
30
- switch (ext) {
31
- case "js":
32
- case "jsx":
33
- return javascript({ jsx: true });
34
- case "ts":
35
- case "tsx":
36
- return javascript({ jsx: true, typescript: true });
37
- case "py":
38
- return python();
39
- case "html":
40
- return html();
41
- case "css":
42
- case "scss":
43
- return css();
44
- case "json":
45
- return json();
46
- case "md":
47
- case "mdx":
48
- return markdown();
49
- default:
50
- return null;
51
- }
20
+ const map: Record<string, string> = {
21
+ js: "javascript", jsx: "javascript",
22
+ ts: "typescript", tsx: "typescript",
23
+ py: "python", html: "html",
24
+ css: "css", scss: "scss",
25
+ json: "json", md: "markdown", mdx: "markdown",
26
+ yaml: "yaml", yml: "yaml",
27
+ sh: "shell", bash: "shell",
28
+ };
29
+ return map[ext] ?? "plaintext";
52
30
  }
53
31
 
54
32
  interface CodeEditorProps {
@@ -66,45 +44,22 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
66
44
  const [unsaved, setUnsaved] = useState(false);
67
45
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
46
  const latestContentRef = useRef<string>("");
47
+ const editorRef = useRef<MonacoType.editor.IStandaloneCodeEditor | null>(null);
69
48
  const { tabs, updateTab } = useTabStore();
70
-
71
49
  const { wordWrap, toggleWordWrap } = useSettingsStore();
72
- // Ref so the memoized domEventHandlers always calls the latest toggleWordWrap
73
- const toggleWordWrapRef = useRef(toggleWordWrap);
74
- useEffect(() => { toggleWordWrapRef.current = toggleWordWrap; }, [toggleWordWrap]);
75
-
76
- // Alt+Z handler — intercepts at DOM level to prevent macOS "Ω" insertion
77
- const wrapKeyHandler = useMemo(() => EditorView.domEventHandlers({
78
- keydown(e) {
79
- if (e.altKey && (e.key === "z" || e.key === "Z")) {
80
- e.preventDefault();
81
- toggleWordWrapRef.current();
82
- return true;
83
- }
84
- return false;
85
- },
86
- beforeinput(e) {
87
- if (e.data === "Ω") { e.preventDefault(); return true; }
88
- return false;
89
- },
90
- }), []);
50
+ const monacoTheme = useMonacoTheme();
91
51
 
92
52
  const ownTab = tabs.find((t) => t.id === tabId);
93
53
  const ext = filePath ? getFileExt(filePath) : "";
94
54
  const isImage = IMAGE_EXTS.has(ext);
95
- const isPdf = ext === PDF_EXT;
55
+ const isPdf = ext === "pdf";
96
56
  const isMarkdown = ext === "md" || ext === "mdx";
97
- /** "edit" | "preview" for markdown files — default to preview */
98
57
  const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
99
58
 
100
59
  // Load file content
101
60
  useEffect(() => {
102
61
  if (!filePath || !projectName) return;
103
- // Skip loading for images and PDFs — they use raw endpoint
104
- if (isImage || isPdf) {
105
- setLoading(false);
106
- return;
107
- }
62
+ if (isImage || isPdf) { setLoading(false); return; }
108
63
 
109
64
  setLoading(true);
110
65
  setError(null);
@@ -124,9 +79,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
124
79
  setLoading(false);
125
80
  });
126
81
 
127
- return () => {
128
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
129
- };
82
+ return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
130
83
  }, [filePath, projectName, isImage, isPdf]);
131
84
 
132
85
  // Update tab title unsaved indicator
@@ -134,39 +87,38 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
134
87
  if (!ownTab) return;
135
88
  const baseName = filePath?.split("/").pop() ?? "Untitled";
136
89
  const newTitle = unsaved ? `${baseName} \u25CF` : baseName;
137
- if (ownTab.title !== newTitle) {
138
- updateTab(ownTab.id, { title: newTitle });
139
- }
90
+ if (ownTab.title !== newTitle) updateTab(ownTab.id, { title: newTitle });
140
91
  }, [unsaved]); // eslint-disable-line react-hooks/exhaustive-deps
141
92
 
142
93
  const saveFile = useCallback(
143
94
  async (text: string) => {
144
95
  if (!filePath || !projectName) return;
145
96
  try {
146
- await api.put(`${projectUrl(projectName)}/files/write`, {
147
- path: filePath,
148
- content: text,
149
- });
97
+ await api.put(`${projectUrl(projectName)}/files/write`, { path: filePath, content: text });
150
98
  setUnsaved(false);
151
- } catch {
152
- // Silent save failure — user sees unsaved indicator persists
153
- }
99
+ } catch { /* Silent — unsaved indicator persists */ }
154
100
  },
155
101
  [filePath, projectName],
156
102
  );
157
103
 
158
- function handleChange(value: string) {
159
- setContent(value);
160
- latestContentRef.current = value;
104
+ function handleChange(value: string | undefined) {
105
+ const val = value ?? "";
106
+ setContent(val);
107
+ latestContentRef.current = val;
161
108
  setUnsaved(true);
162
-
163
- // Debounced auto-save (1s)
164
109
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
165
- saveTimerRef.current = setTimeout(() => {
166
- saveFile(latestContentRef.current);
167
- }, 1000);
110
+ saveTimerRef.current = setTimeout(() => saveFile(latestContentRef.current), 1000);
168
111
  }
169
112
 
113
+ const handleEditorMount: OnMount = useCallback((editor, monaco) => {
114
+ editorRef.current = editor;
115
+ // Alt+Z → toggle word wrap
116
+ editor.addCommand(
117
+ monaco.KeyMod.Alt | monaco.KeyCode.KeyZ,
118
+ () => useSettingsStore.getState().toggleWordWrap(),
119
+ );
120
+ }, []);
121
+
170
122
  if (!filePath || !projectName) {
171
123
  return (
172
124
  <div className="flex items-center justify-center h-full text-text-secondary text-sm">
@@ -186,23 +138,13 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
186
138
 
187
139
  if (error) {
188
140
  return (
189
- <div className="flex items-center justify-center h-full text-error text-sm">
190
- {error}
191
- </div>
141
+ <div className="flex items-center justify-center h-full text-error text-sm">{error}</div>
192
142
  );
193
143
  }
194
144
 
195
- // --- Image preview ---
196
- if (isImage) {
197
- return <ImagePreview filePath={filePath} projectName={projectName} />;
198
- }
199
-
200
- // --- PDF viewer ---
201
- if (isPdf) {
202
- return <PdfPreview filePath={filePath} projectName={projectName} />;
203
- }
145
+ if (isImage) return <ImagePreview filePath={filePath} projectName={projectName} />;
146
+ if (isPdf) return <PdfPreview filePath={filePath} projectName={projectName} />;
204
147
 
205
- // --- Binary file (base64 encoding) — cannot edit ---
206
148
  if (encoding === "base64") {
207
149
  return (
208
150
  <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
@@ -213,46 +155,24 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
213
155
  );
214
156
  }
215
157
 
216
- // --- Text editor ---
217
- const extensions: Extension[] = [];
218
- const langExt = getLanguageExtension(filePath);
219
- if (langExt) extensions.push(langExt);
220
- if (wordWrap) extensions.push(EditorView.lineWrapping);
221
- extensions.push(wrapKeyHandler);
222
-
223
158
  const mdModeButtons = isMarkdown ? (
224
159
  <>
225
- <button
226
- type="button"
227
- onClick={() => setMdMode("edit")}
228
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
229
- mdMode === "edit" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
230
- }`}
160
+ <button type="button" onClick={() => setMdMode("edit")}
161
+ className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "edit" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
231
162
  >
232
- <Code className="size-3" />
233
- Edit
163
+ <Code className="size-3" /> Edit
234
164
  </button>
235
- <button
236
- type="button"
237
- onClick={() => setMdMode("preview")}
238
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
239
- mdMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
240
- }`}
165
+ <button type="button" onClick={() => setMdMode("preview")}
166
+ className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
241
167
  >
242
- <Eye className="size-3" />
243
- Preview
168
+ <Eye className="size-3" /> Preview
244
169
  </button>
245
170
  </>
246
171
  ) : null;
247
172
 
248
173
  const wrapBtn = (
249
- <button
250
- type="button"
251
- onClick={toggleWordWrap}
252
- title="Toggle word wrap (Alt+Z)"
253
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
254
- wordWrap ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
255
- }`}
174
+ <button type="button" onClick={toggleWordWrap} title="Toggle word wrap (Alt+Z)"
175
+ className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${wordWrap ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
256
176
  >
257
177
  <WrapText className="size-3" />
258
178
  <span className="hidden sm:inline">Wrap</span>
@@ -261,39 +181,41 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
261
181
 
262
182
  return (
263
183
  <div className="flex flex-col h-full w-full overflow-hidden">
264
- {/* Desktop toolbar at top */}
184
+ {/* Desktop toolbar */}
265
185
  <div className="hidden md:flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-background">
266
186
  {mdModeButtons}
267
187
  <div className="flex-1" />
268
188
  {wrapBtn}
269
189
  </div>
270
190
 
271
- {/* Markdown preview mode */}
272
191
  {isMarkdown && mdMode === "preview" ? (
273
192
  <MarkdownPreview content={content ?? ""} />
274
193
  ) : (
275
194
  <div className="flex-1 overflow-hidden">
276
- <CodeMirror
195
+ <Editor
196
+ height="100%"
197
+ language={getMonacoLanguage(filePath)}
277
198
  value={content ?? ""}
278
199
  onChange={handleChange}
279
- extensions={extensions}
280
- theme={oneDark}
281
- height="100%"
282
- style={{ height: "100%", fontSize: "13px", fontFamily: "var(--font-mono)" }}
283
- basicSetup={{
284
- lineNumbers: true,
285
- foldGutter: true,
286
- autocompletion: true,
287
- bracketMatching: true,
288
- closeBrackets: true,
289
- highlightActiveLine: true,
290
- indentOnInput: true,
200
+ onMount={handleEditorMount}
201
+ theme={monacoTheme}
202
+ options={{
203
+ fontSize: 13,
204
+ fontFamily: "Menlo, Monaco, Consolas, monospace",
205
+ wordWrap: wordWrap ? "on" : "off",
206
+ minimap: { enabled: false },
207
+ scrollBeyondLastLine: false,
208
+ automaticLayout: true,
209
+ lineNumbers: "on",
210
+ folding: true,
211
+ bracketPairColorization: { enabled: true },
291
212
  }}
213
+ loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
292
214
  />
293
215
  </div>
294
216
  )}
295
217
 
296
- {/* Mobile toolbar at bottom */}
218
+ {/* Mobile toolbar */}
297
219
  <div className="md:hidden flex items-center gap-1 px-2 py-1 border-t shrink-0 bg-background">
298
220
  {mdModeButtons}
299
221
  <div className="flex-1" />
@@ -303,14 +225,10 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
303
225
  );
304
226
  }
305
227
 
306
- /** Rendered markdown preview using marked */
307
228
  function MarkdownPreview({ content }: { content: string }) {
308
229
  const html = useMemo(() => {
309
- try {
310
- return marked.parse(content, { gfm: true, breaks: true }) as string;
311
- } catch {
312
- return content;
313
- }
230
+ try { return marked.parse(content, { gfm: true, breaks: true }) as string; }
231
+ catch { return content; }
314
232
  }, [content]);
315
233
 
316
234
  return (
@@ -321,7 +239,6 @@ function MarkdownPreview({ content }: { content: string }) {
321
239
  );
322
240
  }
323
241
 
324
- /** Inline image preview with auth */
325
242
  function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
326
243
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
327
244
  const [error, setError] = useState(false);
@@ -331,15 +248,8 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
331
248
  const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
332
249
  const token = getAuthToken();
333
250
  fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
334
- .then((r) => {
335
- if (!r.ok) throw new Error("Failed");
336
- return r.blob();
337
- })
338
- .then((blob) => {
339
- const objUrl = URL.createObjectURL(blob);
340
- revoke = objUrl;
341
- setBlobUrl(objUrl);
342
- })
251
+ .then((r) => { if (!r.ok) throw new Error("Failed"); return r.blob(); })
252
+ .then((blob) => { const u = URL.createObjectURL(blob); revoke = u; setBlobUrl(u); })
343
253
  .catch(() => setError(true));
344
254
  return () => { if (revoke) URL.revokeObjectURL(revoke); };
345
255
  }, [filePath, projectName]);
@@ -352,15 +262,9 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
352
262
  </div>
353
263
  );
354
264
  }
355
-
356
265
  if (!blobUrl) {
357
- return (
358
- <div className="flex items-center justify-center h-full">
359
- <Loader2 className="size-5 animate-spin text-text-subtle" />
360
- </div>
361
- );
266
+ return <div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>;
362
267
  }
363
-
364
268
  return (
365
269
  <div className="flex items-center justify-center h-full p-4 bg-surface overflow-auto">
366
270
  <img src={blobUrl} alt={filePath} className="max-w-full max-h-full object-contain" />
@@ -368,7 +272,6 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
368
272
  );
369
273
  }
370
274
 
371
- /** PDF preview — fetches with auth, opens blob in iframe or new tab */
372
275
  function PdfPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
373
276
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
374
277
  const [error, setError] = useState(false);
@@ -378,22 +281,16 @@ function PdfPreview({ filePath, projectName }: { filePath: string; projectName:
378
281
  const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
379
282
  const token = getAuthToken();
380
283
  fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
381
- .then((r) => {
382
- if (!r.ok) throw new Error("Failed");
383
- return r.blob();
384
- })
284
+ .then((r) => { if (!r.ok) throw new Error("Failed"); return r.blob(); })
385
285
  .then((blob) => {
386
- const objUrl = URL.createObjectURL(new Blob([blob], { type: "application/pdf" }));
387
- revoke = objUrl;
388
- setBlobUrl(objUrl);
286
+ const u = URL.createObjectURL(new Blob([blob], { type: "application/pdf" }));
287
+ revoke = u; setBlobUrl(u);
389
288
  })
390
289
  .catch(() => setError(true));
391
290
  return () => { if (revoke) URL.revokeObjectURL(revoke); };
392
291
  }, [filePath, projectName]);
393
292
 
394
- const openInNewTab = useCallback(() => {
395
- if (blobUrl) window.open(blobUrl, "_blank");
396
- }, [blobUrl]);
293
+ const openInNewTab = useCallback(() => { if (blobUrl) window.open(blobUrl, "_blank"); }, [blobUrl]);
397
294
 
398
295
  if (error) {
399
296
  return (
@@ -403,34 +300,18 @@ function PdfPreview({ filePath, projectName }: { filePath: string; projectName:
403
300
  </div>
404
301
  );
405
302
  }
406
-
407
303
  if (!blobUrl) {
408
- return (
409
- <div className="flex items-center justify-center h-full">
410
- <Loader2 className="size-5 animate-spin text-text-subtle" />
411
- </div>
412
- );
304
+ return <div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>;
413
305
  }
414
-
415
306
  return (
416
307
  <div className="flex flex-col h-full">
417
- {/* Toolbar */}
418
308
  <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background shrink-0">
419
309
  <span className="text-xs text-text-secondary truncate">{filePath}</span>
420
- <button
421
- onClick={openInNewTab}
422
- className="flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary transition-colors"
423
- >
424
- <ExternalLink className="size-3" />
425
- Open in new tab
310
+ <button onClick={openInNewTab} className="flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary transition-colors">
311
+ <ExternalLink className="size-3" /> Open in new tab
426
312
  </button>
427
313
  </div>
428
- {/* Embedded PDF viewer */}
429
- <iframe
430
- src={blobUrl}
431
- title={filePath}
432
- className="flex-1 w-full border-none"
433
- />
314
+ <iframe src={blobUrl} title={filePath} className="flex-1 w-full border-none" />
434
315
  </div>
435
316
  );
436
317
  }