@hienlh/ppm 0.2.19 → 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 (85) 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/api-client-BCjah751.js +1 -0
  6. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  7. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  8. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  9. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  10. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  11. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  12. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  13. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  14. package/dist/web/assets/{terminal-tab-DlRo-KzS.js → terminal-tab-BEFAYT4S.js} +1 -1
  15. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  16. package/dist/web/index.html +35 -9
  17. package/dist/web/sw.js +1 -1
  18. package/docs/codebase-summary.md +13 -8
  19. package/docs/project-roadmap.md +22 -4
  20. package/docs/system-architecture.md +59 -0
  21. package/package.json +6 -14
  22. package/src/providers/claude-agent-sdk.ts +2 -2
  23. package/src/providers/registry.ts +12 -11
  24. package/src/server/routes/projects.ts +43 -0
  25. package/src/server/routes/settings.ts +42 -8
  26. package/src/server/ws/chat.ts +2 -2
  27. package/src/services/config.service.ts +5 -1
  28. package/src/services/project.service.ts +1 -0
  29. package/src/types/config.ts +37 -0
  30. package/src/types/project.ts +1 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  35. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  36. package/src/web/app.tsx +43 -5
  37. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  38. package/src/web/components/chat/chat-tab.tsx +27 -19
  39. package/src/web/components/editor/code-editor.tsx +101 -173
  40. package/src/web/components/editor/diff-viewer.tsx +67 -172
  41. package/src/web/components/git/git-status-panel.tsx +4 -11
  42. package/src/web/components/layout/add-project-form.tsx +151 -0
  43. package/src/web/components/layout/command-palette.tsx +3 -1
  44. package/src/web/components/layout/editor-panel.tsx +6 -4
  45. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  46. package/src/web/components/layout/mobile-nav.tsx +89 -6
  47. package/src/web/components/layout/panel-layout.tsx +16 -10
  48. package/src/web/components/layout/project-bar.tsx +329 -0
  49. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  50. package/src/web/components/layout/sidebar.tsx +56 -142
  51. package/src/web/components/layout/tab-bar.tsx +1 -6
  52. package/src/web/components/layout/tab-content.tsx +0 -10
  53. package/src/web/components/ui/dialog.tsx +1 -1
  54. package/src/web/lib/project-avatar.ts +45 -0
  55. package/src/web/lib/project-palette.ts +18 -0
  56. package/src/web/lib/use-monaco-theme.ts +29 -0
  57. package/src/web/stores/panel-store.ts +96 -9
  58. package/src/web/stores/project-store.ts +87 -3
  59. package/src/web/stores/settings-store.ts +52 -5
  60. package/src/web/stores/tab-store.ts +0 -2
  61. package/vite.config.ts +6 -2
  62. package/dist/web/assets/api-client-B_eCZViO.js +0 -1
  63. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  64. package/dist/web/assets/button-CvHWF07y.js +0 -41
  65. package/dist/web/assets/chat-tab-DJvME48K.js +0 -6
  66. package/dist/web/assets/code-editor-81Tzd5aV.js +0 -2
  67. package/dist/web/assets/dialog-Cn5zGuid.js +0 -5
  68. package/dist/web/assets/diff-viewer-pieRctzs.js +0 -4
  69. package/dist/web/assets/dist-B6sG2GPc.js +0 -1
  70. package/dist/web/assets/dist-CBiGQxfr.js +0 -46
  71. package/dist/web/assets/git-graph-CWI6hxtE.js +0 -1
  72. package/dist/web/assets/git-status-panel-CAjReViM.js +0 -1
  73. package/dist/web/assets/index-BdUoflYx.css +0 -2
  74. package/dist/web/assets/index-CqpLusQd.js +0 -17
  75. package/dist/web/assets/project-list-MAvAY2K3.js +0 -1
  76. package/dist/web/assets/react-C32bf_ch.js +0 -1
  77. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  78. package/dist/web/assets/settings-tab-zeZrAFld.js +0 -1
  79. package/dist/web/assets/trash-2-Dc17nbCE.js +0 -1
  80. package/dist/web/assets/x-Bpqyw40Y.js +0 -1
  81. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  82. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  83. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  84. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  85. /package/dist/web/assets/{utils-61GRB9Cb.js → utils-B-_GCz7E.js} +0 -0
@@ -1,52 +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
5
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
13
6
  import { useTabStore } from "@/stores/tab-store";
14
- import { Loader2, FileWarning, ExternalLink, Code, Eye } from "lucide-react";
7
+ import { useSettingsStore } from "@/stores/settings-store";
8
+ import { useMonacoTheme } from "@/lib/use-monaco-theme";
9
+ import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
15
10
 
16
11
  /** Image extensions renderable inline */
17
12
  const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
18
13
 
19
- /** PDF extension */
20
- const PDF_EXT = "pdf";
21
-
22
14
  function getFileExt(filename: string): string {
23
15
  return filename.split(".").pop()?.toLowerCase() ?? "";
24
16
  }
25
17
 
26
- function getLanguageExtension(filename: string): Extension | null {
18
+ function getMonacoLanguage(filename: string): string {
27
19
  const ext = getFileExt(filename);
28
- switch (ext) {
29
- case "js":
30
- case "jsx":
31
- return javascript({ jsx: true });
32
- case "ts":
33
- case "tsx":
34
- return javascript({ jsx: true, typescript: true });
35
- case "py":
36
- return python();
37
- case "html":
38
- return html();
39
- case "css":
40
- case "scss":
41
- return css();
42
- case "json":
43
- return json();
44
- case "md":
45
- case "mdx":
46
- return markdown();
47
- default:
48
- return null;
49
- }
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";
50
30
  }
51
31
 
52
32
  interface CodeEditorProps {
@@ -64,24 +44,22 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
64
44
  const [unsaved, setUnsaved] = useState(false);
65
45
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
66
46
  const latestContentRef = useRef<string>("");
47
+ const editorRef = useRef<MonacoType.editor.IStandaloneCodeEditor | null>(null);
67
48
  const { tabs, updateTab } = useTabStore();
49
+ const { wordWrap, toggleWordWrap } = useSettingsStore();
50
+ const monacoTheme = useMonacoTheme();
68
51
 
69
52
  const ownTab = tabs.find((t) => t.id === tabId);
70
53
  const ext = filePath ? getFileExt(filePath) : "";
71
54
  const isImage = IMAGE_EXTS.has(ext);
72
- const isPdf = ext === PDF_EXT;
55
+ const isPdf = ext === "pdf";
73
56
  const isMarkdown = ext === "md" || ext === "mdx";
74
- /** "edit" | "preview" for markdown files — default to preview */
75
57
  const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
76
58
 
77
59
  // Load file content
78
60
  useEffect(() => {
79
61
  if (!filePath || !projectName) return;
80
- // Skip loading for images and PDFs — they use raw endpoint
81
- if (isImage || isPdf) {
82
- setLoading(false);
83
- return;
84
- }
62
+ if (isImage || isPdf) { setLoading(false); return; }
85
63
 
86
64
  setLoading(true);
87
65
  setError(null);
@@ -101,9 +79,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
101
79
  setLoading(false);
102
80
  });
103
81
 
104
- return () => {
105
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
106
- };
82
+ return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
107
83
  }, [filePath, projectName, isImage, isPdf]);
108
84
 
109
85
  // Update tab title unsaved indicator
@@ -111,39 +87,38 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
111
87
  if (!ownTab) return;
112
88
  const baseName = filePath?.split("/").pop() ?? "Untitled";
113
89
  const newTitle = unsaved ? `${baseName} \u25CF` : baseName;
114
- if (ownTab.title !== newTitle) {
115
- updateTab(ownTab.id, { title: newTitle });
116
- }
90
+ if (ownTab.title !== newTitle) updateTab(ownTab.id, { title: newTitle });
117
91
  }, [unsaved]); // eslint-disable-line react-hooks/exhaustive-deps
118
92
 
119
93
  const saveFile = useCallback(
120
94
  async (text: string) => {
121
95
  if (!filePath || !projectName) return;
122
96
  try {
123
- await api.put(`${projectUrl(projectName)}/files/write`, {
124
- path: filePath,
125
- content: text,
126
- });
97
+ await api.put(`${projectUrl(projectName)}/files/write`, { path: filePath, content: text });
127
98
  setUnsaved(false);
128
- } catch {
129
- // Silent save failure — user sees unsaved indicator persists
130
- }
99
+ } catch { /* Silent — unsaved indicator persists */ }
131
100
  },
132
101
  [filePath, projectName],
133
102
  );
134
103
 
135
- function handleChange(value: string) {
136
- setContent(value);
137
- latestContentRef.current = value;
104
+ function handleChange(value: string | undefined) {
105
+ const val = value ?? "";
106
+ setContent(val);
107
+ latestContentRef.current = val;
138
108
  setUnsaved(true);
139
-
140
- // Debounced auto-save (1s)
141
109
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
142
- saveTimerRef.current = setTimeout(() => {
143
- saveFile(latestContentRef.current);
144
- }, 1000);
110
+ saveTimerRef.current = setTimeout(() => saveFile(latestContentRef.current), 1000);
145
111
  }
146
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
+
147
122
  if (!filePath || !projectName) {
148
123
  return (
149
124
  <div className="flex items-center justify-center h-full text-text-secondary text-sm">
@@ -163,23 +138,13 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
163
138
 
164
139
  if (error) {
165
140
  return (
166
- <div className="flex items-center justify-center h-full text-error text-sm">
167
- {error}
168
- </div>
141
+ <div className="flex items-center justify-center h-full text-error text-sm">{error}</div>
169
142
  );
170
143
  }
171
144
 
172
- // --- Image preview ---
173
- if (isImage) {
174
- return <ImagePreview filePath={filePath} projectName={projectName} />;
175
- }
145
+ if (isImage) return <ImagePreview filePath={filePath} projectName={projectName} />;
146
+ if (isPdf) return <PdfPreview filePath={filePath} projectName={projectName} />;
176
147
 
177
- // --- PDF viewer ---
178
- if (isPdf) {
179
- return <PdfPreview filePath={filePath} projectName={projectName} />;
180
- }
181
-
182
- // --- Binary file (base64 encoding) — cannot edit ---
183
148
  if (encoding === "base64") {
184
149
  return (
185
150
  <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
@@ -190,80 +155,80 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
190
155
  );
191
156
  }
192
157
 
193
- // --- Text editor ---
194
- const extensions: Extension[] = [];
195
- const langExt = getLanguageExtension(filePath);
196
- if (langExt) extensions.push(langExt);
197
-
198
- const mdToggleBar = isMarkdown ? (
199
- <div className="flex items-center gap-1 px-2 py-1 border-border shrink-0 bg-background">
200
- <button
201
- type="button"
202
- onClick={() => setMdMode("edit")}
203
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
204
- mdMode === "edit" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
205
- }`}
158
+ const mdModeButtons = isMarkdown ? (
159
+ <>
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"}`}
206
162
  >
207
- <Code className="size-3" />
208
- Edit
163
+ <Code className="size-3" /> Edit
209
164
  </button>
210
- <button
211
- type="button"
212
- onClick={() => setMdMode("preview")}
213
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
214
- mdMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
215
- }`}
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"}`}
216
167
  >
217
- <Eye className="size-3" />
218
- Preview
168
+ <Eye className="size-3" /> Preview
219
169
  </button>
220
- </div>
170
+ </>
221
171
  ) : null;
222
172
 
173
+ const wrapBtn = (
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"}`}
176
+ >
177
+ <WrapText className="size-3" />
178
+ <span className="hidden sm:inline">Wrap</span>
179
+ </button>
180
+ );
181
+
223
182
  return (
224
183
  <div className="flex flex-col h-full w-full overflow-hidden">
225
- {/* Desktop: toggle at top */}
226
- <div className="hidden md:block border-b">{mdToggleBar}</div>
184
+ {/* Desktop toolbar */}
185
+ <div className="hidden md:flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-background">
186
+ {mdModeButtons}
187
+ <div className="flex-1" />
188
+ {wrapBtn}
189
+ </div>
227
190
 
228
- {/* Markdown preview mode */}
229
191
  {isMarkdown && mdMode === "preview" ? (
230
192
  <MarkdownPreview content={content ?? ""} />
231
193
  ) : (
232
194
  <div className="flex-1 overflow-hidden">
233
- <CodeMirror
195
+ <Editor
196
+ height="100%"
197
+ language={getMonacoLanguage(filePath)}
234
198
  value={content ?? ""}
235
199
  onChange={handleChange}
236
- extensions={extensions}
237
- theme={oneDark}
238
- height="100%"
239
- style={{ height: "100%", fontSize: "13px", fontFamily: "var(--font-mono)" }}
240
- basicSetup={{
241
- lineNumbers: true,
242
- foldGutter: true,
243
- autocompletion: true,
244
- bracketMatching: true,
245
- closeBrackets: true,
246
- highlightActiveLine: true,
247
- 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 },
248
212
  }}
213
+ loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
249
214
  />
250
215
  </div>
251
216
  )}
252
217
 
253
- {/* Mobile: toggle at bottom */}
254
- <div className="md:hidden border-t">{mdToggleBar}</div>
218
+ {/* Mobile toolbar */}
219
+ <div className="md:hidden flex items-center gap-1 px-2 py-1 border-t shrink-0 bg-background">
220
+ {mdModeButtons}
221
+ <div className="flex-1" />
222
+ {wrapBtn}
223
+ </div>
255
224
  </div>
256
225
  );
257
226
  }
258
227
 
259
- /** Rendered markdown preview using marked */
260
228
  function MarkdownPreview({ content }: { content: string }) {
261
229
  const html = useMemo(() => {
262
- try {
263
- return marked.parse(content, { gfm: true, breaks: true }) as string;
264
- } catch {
265
- return content;
266
- }
230
+ try { return marked.parse(content, { gfm: true, breaks: true }) as string; }
231
+ catch { return content; }
267
232
  }, [content]);
268
233
 
269
234
  return (
@@ -274,7 +239,6 @@ function MarkdownPreview({ content }: { content: string }) {
274
239
  );
275
240
  }
276
241
 
277
- /** Inline image preview with auth */
278
242
  function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
279
243
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
280
244
  const [error, setError] = useState(false);
@@ -284,15 +248,8 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
284
248
  const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
285
249
  const token = getAuthToken();
286
250
  fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
287
- .then((r) => {
288
- if (!r.ok) throw new Error("Failed");
289
- return r.blob();
290
- })
291
- .then((blob) => {
292
- const objUrl = URL.createObjectURL(blob);
293
- revoke = objUrl;
294
- setBlobUrl(objUrl);
295
- })
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); })
296
253
  .catch(() => setError(true));
297
254
  return () => { if (revoke) URL.revokeObjectURL(revoke); };
298
255
  }, [filePath, projectName]);
@@ -305,15 +262,9 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
305
262
  </div>
306
263
  );
307
264
  }
308
-
309
265
  if (!blobUrl) {
310
- return (
311
- <div className="flex items-center justify-center h-full">
312
- <Loader2 className="size-5 animate-spin text-text-subtle" />
313
- </div>
314
- );
266
+ return <div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>;
315
267
  }
316
-
317
268
  return (
318
269
  <div className="flex items-center justify-center h-full p-4 bg-surface overflow-auto">
319
270
  <img src={blobUrl} alt={filePath} className="max-w-full max-h-full object-contain" />
@@ -321,7 +272,6 @@ function ImagePreview({ filePath, projectName }: { filePath: string; projectName
321
272
  );
322
273
  }
323
274
 
324
- /** PDF preview — fetches with auth, opens blob in iframe or new tab */
325
275
  function PdfPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
326
276
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
327
277
  const [error, setError] = useState(false);
@@ -331,22 +281,16 @@ function PdfPreview({ filePath, projectName }: { filePath: string; projectName:
331
281
  const url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
332
282
  const token = getAuthToken();
333
283
  fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
334
- .then((r) => {
335
- if (!r.ok) throw new Error("Failed");
336
- return r.blob();
337
- })
284
+ .then((r) => { if (!r.ok) throw new Error("Failed"); return r.blob(); })
338
285
  .then((blob) => {
339
- const objUrl = URL.createObjectURL(new Blob([blob], { type: "application/pdf" }));
340
- revoke = objUrl;
341
- setBlobUrl(objUrl);
286
+ const u = URL.createObjectURL(new Blob([blob], { type: "application/pdf" }));
287
+ revoke = u; setBlobUrl(u);
342
288
  })
343
289
  .catch(() => setError(true));
344
290
  return () => { if (revoke) URL.revokeObjectURL(revoke); };
345
291
  }, [filePath, projectName]);
346
292
 
347
- const openInNewTab = useCallback(() => {
348
- if (blobUrl) window.open(blobUrl, "_blank");
349
- }, [blobUrl]);
293
+ const openInNewTab = useCallback(() => { if (blobUrl) window.open(blobUrl, "_blank"); }, [blobUrl]);
350
294
 
351
295
  if (error) {
352
296
  return (
@@ -356,34 +300,18 @@ function PdfPreview({ filePath, projectName }: { filePath: string; projectName:
356
300
  </div>
357
301
  );
358
302
  }
359
-
360
303
  if (!blobUrl) {
361
- return (
362
- <div className="flex items-center justify-center h-full">
363
- <Loader2 className="size-5 animate-spin text-text-subtle" />
364
- </div>
365
- );
304
+ return <div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>;
366
305
  }
367
-
368
306
  return (
369
307
  <div className="flex flex-col h-full">
370
- {/* Toolbar */}
371
308
  <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background shrink-0">
372
309
  <span className="text-xs text-text-secondary truncate">{filePath}</span>
373
- <button
374
- onClick={openInNewTab}
375
- className="flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary transition-colors"
376
- >
377
- <ExternalLink className="size-3" />
378
- 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
379
312
  </button>
380
313
  </div>
381
- {/* Embedded PDF viewer */}
382
- <iframe
383
- src={blobUrl}
384
- title={filePath}
385
- className="flex-1 w-full border-none"
386
- />
314
+ <iframe src={blobUrl} title={filePath} className="flex-1 w-full border-none" />
387
315
  </div>
388
316
  );
389
317
  }