@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.
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +18 -1
- package/bun.lock +57 -59
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BCjah751.js +1 -0
- package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
- package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
- package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
- package/dist/web/assets/index-3zt5mBwZ.css +2 -0
- package/dist/web/assets/index-CaUQy3Zs.js +21 -0
- package/dist/web/assets/input-CTnwfHVN.js +41 -0
- package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
- package/dist/web/assets/{terminal-tab-DlRo-KzS.js → terminal-tab-BEFAYT4S.js} +1 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
- package/dist/web/index.html +35 -9
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +13 -8
- package/docs/project-roadmap.md +22 -4
- package/docs/system-architecture.md +59 -0
- package/package.json +6 -14
- package/src/providers/claude-agent-sdk.ts +2 -2
- package/src/providers/registry.ts +12 -11
- package/src/server/routes/projects.ts +43 -0
- package/src/server/routes/settings.ts +42 -8
- package/src/server/ws/chat.ts +2 -2
- package/src/services/config.service.ts +5 -1
- package/src/services/project.service.ts +1 -0
- package/src/types/config.ts +37 -0
- package/src/types/project.ts +1 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
- package/src/web/app.tsx +43 -5
- package/src/web/components/chat/chat-history-panel.tsx +106 -0
- package/src/web/components/chat/chat-tab.tsx +27 -19
- package/src/web/components/editor/code-editor.tsx +101 -173
- package/src/web/components/editor/diff-viewer.tsx +67 -172
- package/src/web/components/git/git-status-panel.tsx +4 -11
- package/src/web/components/layout/add-project-form.tsx +151 -0
- package/src/web/components/layout/command-palette.tsx +3 -1
- package/src/web/components/layout/editor-panel.tsx +6 -4
- package/src/web/components/layout/mobile-drawer.tsx +48 -180
- package/src/web/components/layout/mobile-nav.tsx +89 -6
- package/src/web/components/layout/panel-layout.tsx +16 -10
- package/src/web/components/layout/project-bar.tsx +329 -0
- package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
- package/src/web/components/layout/sidebar.tsx +56 -142
- package/src/web/components/layout/tab-bar.tsx +1 -6
- package/src/web/components/layout/tab-content.tsx +0 -10
- package/src/web/components/ui/dialog.tsx +1 -1
- package/src/web/lib/project-avatar.ts +45 -0
- package/src/web/lib/project-palette.ts +18 -0
- package/src/web/lib/use-monaco-theme.ts +29 -0
- package/src/web/stores/panel-store.ts +96 -9
- package/src/web/stores/project-store.ts +87 -3
- package/src/web/stores/settings-store.ts +52 -5
- package/src/web/stores/tab-store.ts +0 -2
- package/vite.config.ts +6 -2
- package/dist/web/assets/api-client-B_eCZViO.js +0 -1
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
- package/dist/web/assets/button-CvHWF07y.js +0 -41
- package/dist/web/assets/chat-tab-DJvME48K.js +0 -6
- package/dist/web/assets/code-editor-81Tzd5aV.js +0 -2
- package/dist/web/assets/dialog-Cn5zGuid.js +0 -5
- package/dist/web/assets/diff-viewer-pieRctzs.js +0 -4
- package/dist/web/assets/dist-B6sG2GPc.js +0 -1
- package/dist/web/assets/dist-CBiGQxfr.js +0 -46
- package/dist/web/assets/git-graph-CWI6hxtE.js +0 -1
- package/dist/web/assets/git-status-panel-CAjReViM.js +0 -1
- package/dist/web/assets/index-BdUoflYx.css +0 -2
- package/dist/web/assets/index-CqpLusQd.js +0 -17
- package/dist/web/assets/project-list-MAvAY2K3.js +0 -1
- package/dist/web/assets/react-C32bf_ch.js +0 -1
- package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
- package/dist/web/assets/settings-tab-zeZrAFld.js +0 -1
- package/dist/web/assets/trash-2-Dc17nbCE.js +0 -1
- package/dist/web/assets/x-Bpqyw40Y.js +0 -1
- /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
- /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
- /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
- /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
- /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
|
|
3
|
-
import
|
|
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 {
|
|
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
|
|
18
|
+
function getMonacoLanguage(filename: string): string {
|
|
27
19
|
const ext = getFileExt(filename);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
173
|
-
if (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
<div className="hidden md:
|
|
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
|
-
<
|
|
195
|
+
<Editor
|
|
196
|
+
height="100%"
|
|
197
|
+
language={getMonacoLanguage(filePath)}
|
|
234
198
|
value={content ?? ""}
|
|
235
199
|
onChange={handleChange}
|
|
236
|
-
|
|
237
|
-
theme={
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
254
|
-
<div className="md:hidden border-t">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
340
|
-
revoke =
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
}
|