@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.4.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.
- package/README.md +415 -24
- package/package.json +46 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +444 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
- package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- package/src/ui/public/style.css +0 -341
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TaskFilePane component for displaying a single task file with preview
|
|
5
|
+
* @param {Object} props - Component props
|
|
6
|
+
* @param {boolean} props.isOpen - Whether the pane is open
|
|
7
|
+
* @param {string} props.jobId - Job ID
|
|
8
|
+
* @param {string} props.taskId - Task ID
|
|
9
|
+
* @param {string} props.type - File type (artifacts|logs|tmp)
|
|
10
|
+
* @param {string} props.filename - File name to display
|
|
11
|
+
* @param {Function} props.onClose - Close handler
|
|
12
|
+
*/
|
|
13
|
+
export function TaskFilePane({
|
|
14
|
+
isOpen,
|
|
15
|
+
jobId,
|
|
16
|
+
taskId,
|
|
17
|
+
type,
|
|
18
|
+
filename,
|
|
19
|
+
onClose,
|
|
20
|
+
}) {
|
|
21
|
+
const [copyNotice, setCopyNotice] = useState(null);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState(null);
|
|
24
|
+
const [content, setContent] = useState(null);
|
|
25
|
+
const [mime, setMime] = useState(null);
|
|
26
|
+
const [encoding, setEncoding] = useState(null);
|
|
27
|
+
const [size, setSize] = useState(null);
|
|
28
|
+
const [mtime, setMtime] = useState(null);
|
|
29
|
+
|
|
30
|
+
const invokerRef = useRef(null);
|
|
31
|
+
const closeButtonRef = useRef(null);
|
|
32
|
+
const abortControllerRef = useRef(null);
|
|
33
|
+
const copyNoticeTimerRef = useRef(null);
|
|
34
|
+
|
|
35
|
+
// Retry counter for refetching
|
|
36
|
+
const [retryCounter, setRetryCounter] = useState(0);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Infer MIME type and encoding from file extension
|
|
40
|
+
* @param {string} filename - File name
|
|
41
|
+
* @returns {Object} { mime, encoding }
|
|
42
|
+
*/
|
|
43
|
+
function inferMimeType(filename) {
|
|
44
|
+
const ext = filename.toLowerCase().split(".").pop();
|
|
45
|
+
|
|
46
|
+
const MIME_MAP = {
|
|
47
|
+
// Text types
|
|
48
|
+
txt: { mime: "text/plain", encoding: "utf8" },
|
|
49
|
+
log: { mime: "text/plain", encoding: "utf8" },
|
|
50
|
+
md: { mime: "text/markdown", encoding: "utf8" },
|
|
51
|
+
csv: { mime: "text/csv", encoding: "utf8" },
|
|
52
|
+
json: { mime: "application/json", encoding: "utf8" },
|
|
53
|
+
xml: { mime: "application/xml", encoding: "utf8" },
|
|
54
|
+
yaml: { mime: "application/x-yaml", encoding: "utf8" },
|
|
55
|
+
yml: { mime: "application/x-yaml", encoding: "utf8" },
|
|
56
|
+
toml: { mime: "application/toml", encoding: "utf8" },
|
|
57
|
+
ini: { mime: "text/plain", encoding: "utf8" },
|
|
58
|
+
conf: { mime: "text/plain", encoding: "utf8" },
|
|
59
|
+
config: { mime: "text/plain", encoding: "utf8" },
|
|
60
|
+
env: { mime: "text/plain", encoding: "utf8" },
|
|
61
|
+
gitignore: { mime: "text/plain", encoding: "utf8" },
|
|
62
|
+
dockerfile: { mime: "text/plain", encoding: "utf8" },
|
|
63
|
+
sh: { mime: "application/x-sh", encoding: "utf8" },
|
|
64
|
+
bash: { mime: "application/x-sh", encoding: "utf8" },
|
|
65
|
+
zsh: { mime: "application/x-sh", encoding: "utf8" },
|
|
66
|
+
fish: { mime: "application/x-fish", encoding: "utf8" },
|
|
67
|
+
ps1: { mime: "application/x-powershell", encoding: "utf8" },
|
|
68
|
+
bat: { mime: "application/x-bat", encoding: "utf8" },
|
|
69
|
+
cmd: { mime: "application/x-cmd", encoding: "utf8" },
|
|
70
|
+
|
|
71
|
+
// Code types
|
|
72
|
+
js: { mime: "application/javascript", encoding: "utf8" },
|
|
73
|
+
mjs: { mime: "application/javascript", encoding: "utf8" },
|
|
74
|
+
cjs: { mime: "application/javascript", encoding: "utf8" },
|
|
75
|
+
ts: { mime: "application/typescript", encoding: "utf8" },
|
|
76
|
+
mts: { mime: "application/typescript", encoding: "utf8" },
|
|
77
|
+
cts: { mime: "application/typescript", encoding: "utf8" },
|
|
78
|
+
jsx: { mime: "application/javascript", encoding: "utf8" },
|
|
79
|
+
tsx: { mime: "application/typescript", encoding: "utf8" },
|
|
80
|
+
py: { mime: "text/x-python", encoding: "utf8" },
|
|
81
|
+
rb: { mime: "text/x-ruby", encoding: "utf8" },
|
|
82
|
+
php: { mime: "application/x-php", encoding: "utf8" },
|
|
83
|
+
java: { mime: "text/x-java-source", encoding: "utf8" },
|
|
84
|
+
c: { mime: "text/x-c", encoding: "utf8" },
|
|
85
|
+
cpp: { mime: "text/x-c++", encoding: "utf8" },
|
|
86
|
+
cc: { mime: "text/x-c++", encoding: "utf8" },
|
|
87
|
+
cxx: { mime: "text/x-c++", encoding: "utf8" },
|
|
88
|
+
h: { mime: "text/x-c", encoding: "utf8" },
|
|
89
|
+
hpp: { mime: "text/x-c++", encoding: "utf8" },
|
|
90
|
+
cs: { mime: "text/x-csharp", encoding: "utf8" },
|
|
91
|
+
go: { mime: "text/x-go", encoding: "utf8" },
|
|
92
|
+
rs: { mime: "text/x-rust", encoding: "utf8" },
|
|
93
|
+
swift: { mime: "text/x-swift", encoding: "utf8" },
|
|
94
|
+
kt: { mime: "text/x-kotlin", encoding: "utf8" },
|
|
95
|
+
scala: { mime: "text/x-scala", encoding: "utf8" },
|
|
96
|
+
r: { mime: "text/x-r", encoding: "utf8" },
|
|
97
|
+
sql: { mime: "application/sql", encoding: "utf8" },
|
|
98
|
+
pl: { mime: "text/x-perl", encoding: "utf8" },
|
|
99
|
+
lua: { mime: "text/x-lua", encoding: "utf8" },
|
|
100
|
+
vim: { mime: "text/x-vim", encoding: "utf8" },
|
|
101
|
+
el: { mime: "text/x-elisp", encoding: "utf8" },
|
|
102
|
+
lisp: { mime: "text/x-lisp", encoding: "utf8" },
|
|
103
|
+
hs: { mime: "text/x-haskell", encoding: "utf8" },
|
|
104
|
+
ml: { mime: "text/x-ocaml", encoding: "utf8" },
|
|
105
|
+
ex: { mime: "text/x-elixir", encoding: "utf8" },
|
|
106
|
+
exs: { mime: "text/x-elixir", encoding: "utf8" },
|
|
107
|
+
erl: { mime: "text/x-erlang", encoding: "utf8" },
|
|
108
|
+
beam: { mime: "application/x-erlang-beam", encoding: "base64" },
|
|
109
|
+
|
|
110
|
+
// Web types
|
|
111
|
+
html: { mime: "text/html", encoding: "utf8" },
|
|
112
|
+
htm: { mime: "text/html", encoding: "utf8" },
|
|
113
|
+
xhtml: { mime: "application/xhtml+xml", encoding: "utf8" },
|
|
114
|
+
css: { mime: "text/css", encoding: "utf8" },
|
|
115
|
+
scss: { mime: "text/x-scss", encoding: "utf8" },
|
|
116
|
+
sass: { mime: "text/x-sass", encoding: "utf8" },
|
|
117
|
+
less: { mime: "text/x-less", encoding: "utf8" },
|
|
118
|
+
styl: { mime: "text/x-stylus", encoding: "utf8" },
|
|
119
|
+
vue: { mime: "text/x-vue", encoding: "utf8" },
|
|
120
|
+
svelte: { mime: "text/x-svelte", encoding: "utf8" },
|
|
121
|
+
|
|
122
|
+
// Images
|
|
123
|
+
png: { mime: "image/png", encoding: "base64" },
|
|
124
|
+
jpg: { mime: "image/jpeg", encoding: "base64" },
|
|
125
|
+
jpeg: { mime: "image/jpeg", encoding: "base64" },
|
|
126
|
+
gif: { mime: "image/gif", encoding: "base64" },
|
|
127
|
+
bmp: { mime: "image/bmp", encoding: "base64" },
|
|
128
|
+
webp: { mime: "image/webp", encoding: "base64" },
|
|
129
|
+
svg: { mime: "image/svg+xml", encoding: "utf8" },
|
|
130
|
+
ico: { mime: "image/x-icon", encoding: "base64" },
|
|
131
|
+
tiff: { mime: "image/tiff", encoding: "base64" },
|
|
132
|
+
tif: { mime: "image/tiff", encoding: "base64" },
|
|
133
|
+
psd: { mime: "image/vnd.adobe.photoshop", encoding: "base64" },
|
|
134
|
+
ai: { mime: "application/pdf", encoding: "base64" },
|
|
135
|
+
eps: { mime: "application/postscript", encoding: "base64" },
|
|
136
|
+
|
|
137
|
+
// Default to binary
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
MIME_MAP[ext] || { mime: "application/octet-stream", encoding: "base64" }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fetch file content when dependencies change
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!isOpen || !jobId || !taskId || !type || !filename) {
|
|
148
|
+
// Reset state when closed or missing props
|
|
149
|
+
setLoading(false);
|
|
150
|
+
setError(null);
|
|
151
|
+
setContent(null);
|
|
152
|
+
setMime(null);
|
|
153
|
+
setEncoding(null);
|
|
154
|
+
setSize(null);
|
|
155
|
+
setMtime(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate type
|
|
160
|
+
const allowedTypes = ["artifacts", "logs", "tmp"];
|
|
161
|
+
if (!allowedTypes.includes(type)) {
|
|
162
|
+
setError({
|
|
163
|
+
error: {
|
|
164
|
+
message: `Invalid type: ${type}. Must be one of: ${allowedTypes.join(", ")}`,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
setLoading(false);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setLoading(true);
|
|
172
|
+
setError(null);
|
|
173
|
+
|
|
174
|
+
// Cancel previous request
|
|
175
|
+
if (abortControllerRef.current) {
|
|
176
|
+
abortControllerRef.current.abort();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
abortControllerRef.current = new AbortController();
|
|
180
|
+
const { signal } = abortControllerRef.current;
|
|
181
|
+
|
|
182
|
+
const doFetch = async () => {
|
|
183
|
+
try {
|
|
184
|
+
const url = `/api/jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(taskId)}/file?type=${encodeURIComponent(type)}&filename=${encodeURIComponent(filename)}`;
|
|
185
|
+
console.debug("[TaskFilePane] Fetching file:", {
|
|
186
|
+
url,
|
|
187
|
+
jobId,
|
|
188
|
+
taskId,
|
|
189
|
+
type,
|
|
190
|
+
filename,
|
|
191
|
+
});
|
|
192
|
+
const response = await fetch(url, { signal });
|
|
193
|
+
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
const errorData = await response.json().catch(() => ({}));
|
|
196
|
+
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const result = await response.json();
|
|
200
|
+
if (!result.ok) {
|
|
201
|
+
throw new Error(result.message || "Failed to fetch file content");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Use server-provided mime/encoding, fallback to inference
|
|
205
|
+
const serverMime = result.mime;
|
|
206
|
+
const serverEncoding = result.encoding;
|
|
207
|
+
const inferred = inferMimeType(filename);
|
|
208
|
+
|
|
209
|
+
setMime(serverMime || inferred.mime);
|
|
210
|
+
setEncoding(serverEncoding || inferred.encoding);
|
|
211
|
+
setContent(result.content || null);
|
|
212
|
+
setSize(result.size || null);
|
|
213
|
+
setMtime(result.mtime || null);
|
|
214
|
+
setLoading(false);
|
|
215
|
+
setError(null);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err.name !== "AbortError") {
|
|
218
|
+
setError({ error: { message: err.message } });
|
|
219
|
+
setLoading(false);
|
|
220
|
+
setContent(null);
|
|
221
|
+
setMime(null);
|
|
222
|
+
setEncoding(null);
|
|
223
|
+
setSize(null);
|
|
224
|
+
setMtime(null);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
doFetch();
|
|
230
|
+
}, [isOpen, jobId, taskId, type, filename, retryCounter]);
|
|
231
|
+
|
|
232
|
+
// Store invoker ref for focus return and focus close button on open
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (isOpen) {
|
|
235
|
+
// Try to find the element that opened this pane
|
|
236
|
+
if (!invokerRef.current) {
|
|
237
|
+
const activeElement = document.activeElement;
|
|
238
|
+
if (
|
|
239
|
+
activeElement &&
|
|
240
|
+
activeElement.getAttribute("role") === "listitem"
|
|
241
|
+
) {
|
|
242
|
+
invokerRef.current = activeElement;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Focus close button when pane opens (with a small delay to avoid race conditions)
|
|
247
|
+
const focusTimer = setTimeout(() => {
|
|
248
|
+
if (closeButtonRef.current) {
|
|
249
|
+
closeButtonRef.current.focus();
|
|
250
|
+
}
|
|
251
|
+
}, 0);
|
|
252
|
+
|
|
253
|
+
return () => clearTimeout(focusTimer);
|
|
254
|
+
}
|
|
255
|
+
}, [isOpen]);
|
|
256
|
+
|
|
257
|
+
// Handle escape key
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
const handleEscape = (event) => {
|
|
260
|
+
if (event.key === "Escape" && isOpen) {
|
|
261
|
+
onClose();
|
|
262
|
+
// Return focus to invoker
|
|
263
|
+
if (invokerRef.current) {
|
|
264
|
+
invokerRef.current.focus();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (isOpen) {
|
|
270
|
+
document.addEventListener("keydown", handleEscape);
|
|
271
|
+
return () => {
|
|
272
|
+
document.removeEventListener("keydown", handleEscape);
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}, [isOpen, onClose]);
|
|
276
|
+
|
|
277
|
+
// Cleanup on unmount
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
return () => {
|
|
280
|
+
if (abortControllerRef.current) {
|
|
281
|
+
abortControllerRef.current.abort();
|
|
282
|
+
}
|
|
283
|
+
if (copyNoticeTimerRef.current) {
|
|
284
|
+
clearTimeout(copyNoticeTimerRef.current);
|
|
285
|
+
copyNoticeTimerRef.current = null;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
// Copy to clipboard with feedback
|
|
291
|
+
const handleCopy = async () => {
|
|
292
|
+
if (!content) return;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
await navigator.clipboard.writeText(content);
|
|
296
|
+
setCopyNotice({ type: "success", message: "Copied to clipboard" });
|
|
297
|
+
|
|
298
|
+
// Clear existing timer
|
|
299
|
+
if (copyNoticeTimerRef.current) {
|
|
300
|
+
clearTimeout(copyNoticeTimerRef.current);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Set new timer
|
|
304
|
+
copyNoticeTimerRef.current = setTimeout(() => {
|
|
305
|
+
setCopyNotice(null);
|
|
306
|
+
copyNoticeTimerRef.current = null;
|
|
307
|
+
}, 2000);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
setCopyNotice({ type: "error", message: "Failed to copy" });
|
|
310
|
+
|
|
311
|
+
// Clear existing timer
|
|
312
|
+
if (copyNoticeTimerRef.current) {
|
|
313
|
+
clearTimeout(copyNoticeTimerRef.current);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Set new timer
|
|
317
|
+
copyNoticeTimerRef.current = setTimeout(() => {
|
|
318
|
+
setCopyNotice(null);
|
|
319
|
+
copyNoticeTimerRef.current = null;
|
|
320
|
+
}, 2000);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Retry fetch
|
|
325
|
+
const handleRetry = () => {
|
|
326
|
+
// Trigger refetch by incrementing retry counter
|
|
327
|
+
// This will cause the useEffect to run again
|
|
328
|
+
setRetryCounter((prev) => prev + 1);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Render file content based on MIME type
|
|
332
|
+
const renderContent = () => {
|
|
333
|
+
if (loading) {
|
|
334
|
+
return (
|
|
335
|
+
<div className="flex items-center justify-center h-64 text-gray-500">
|
|
336
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-3"></div>
|
|
337
|
+
Loading...
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (error) {
|
|
343
|
+
return (
|
|
344
|
+
<div className="p-4">
|
|
345
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
346
|
+
<div className="flex">
|
|
347
|
+
<div className="flex-shrink-0">
|
|
348
|
+
<svg
|
|
349
|
+
className="h-5 w-5 text-red-400"
|
|
350
|
+
viewBox="0 0 20 20"
|
|
351
|
+
fill="currentColor"
|
|
352
|
+
>
|
|
353
|
+
<path
|
|
354
|
+
fillRule="evenodd"
|
|
355
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
356
|
+
clipRule="evenodd"
|
|
357
|
+
/>
|
|
358
|
+
</svg>
|
|
359
|
+
</div>
|
|
360
|
+
<div className="ml-3">
|
|
361
|
+
<h3 className="text-sm font-medium text-red-800">
|
|
362
|
+
Error loading file
|
|
363
|
+
</h3>
|
|
364
|
+
<p className="mt-1 text-sm text-red-700">
|
|
365
|
+
{error.error?.message || "Unknown error"}
|
|
366
|
+
</p>
|
|
367
|
+
<button
|
|
368
|
+
onClick={handleRetry}
|
|
369
|
+
className="mt-2 text-sm text-red-600 hover:text-red-800 underline"
|
|
370
|
+
>
|
|
371
|
+
Retry
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!content) {
|
|
381
|
+
return (
|
|
382
|
+
<div className="flex items-center justify-center h-64 text-gray-500">
|
|
383
|
+
No file content
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Handle different content types
|
|
389
|
+
if (mime === "application/json") {
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(content);
|
|
392
|
+
return (
|
|
393
|
+
<pre className="bg-gray-50 p-4 rounded-lg overflow-auto text-sm">
|
|
394
|
+
<code>{JSON.stringify(parsed, null, 2)}</code>
|
|
395
|
+
</pre>
|
|
396
|
+
);
|
|
397
|
+
} catch {
|
|
398
|
+
// Fallback to plain text if invalid JSON
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (mime === "text/markdown") {
|
|
403
|
+
// Simple markdown rendering (basic)
|
|
404
|
+
const rendered = content.split("\n").map((line, i) => {
|
|
405
|
+
if (line.startsWith("# ")) {
|
|
406
|
+
return (
|
|
407
|
+
<h1 key={i} className="text-2xl font-bold mb-2">
|
|
408
|
+
{line.substring(2)}
|
|
409
|
+
</h1>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (line.startsWith("## ")) {
|
|
413
|
+
return (
|
|
414
|
+
<h2 key={i} className="text-xl font-semibold mb-2">
|
|
415
|
+
{line.substring(3)}
|
|
416
|
+
</h2>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
if (line.startsWith("### ")) {
|
|
420
|
+
return (
|
|
421
|
+
<h3 key={i} className="text-lg font-medium mb-2">
|
|
422
|
+
{line.substring(4)}
|
|
423
|
+
</h3>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (line.startsWith("- ")) {
|
|
427
|
+
return (
|
|
428
|
+
<li key={i} className="ml-4">
|
|
429
|
+
• {line.substring(2)}
|
|
430
|
+
</li>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (line.trim() === "") {
|
|
434
|
+
return <br key={i} />;
|
|
435
|
+
}
|
|
436
|
+
return (
|
|
437
|
+
<p key={i} className="mb-2">
|
|
438
|
+
{line}
|
|
439
|
+
</p>
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
return <div className="prose max-w-none p-4">{rendered}</div>;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// For text files, show as plain text
|
|
446
|
+
if (mime.startsWith("text/") || encoding === "utf8") {
|
|
447
|
+
return (
|
|
448
|
+
<pre className="bg-gray-50 p-4 rounded-lg overflow-auto text-sm whitespace-pre-wrap">
|
|
449
|
+
<code>{content}</code>
|
|
450
|
+
</pre>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// For binary files, show not previewable message
|
|
455
|
+
return (
|
|
456
|
+
<div className="flex items-center justify-center h-64 text-gray-500">
|
|
457
|
+
<div className="text-center">
|
|
458
|
+
<svg
|
|
459
|
+
className="mx-auto h-12 w-12 text-gray-400"
|
|
460
|
+
fill="none"
|
|
461
|
+
viewBox="0 0 24 24"
|
|
462
|
+
stroke="currentColor"
|
|
463
|
+
>
|
|
464
|
+
<path
|
|
465
|
+
strokeLinecap="round"
|
|
466
|
+
strokeLinejoin="round"
|
|
467
|
+
strokeWidth={2}
|
|
468
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
469
|
+
/>
|
|
470
|
+
</svg>
|
|
471
|
+
<p className="mt-2 text-sm">Binary file cannot be previewed</p>
|
|
472
|
+
<p className="text-xs text-gray-400 mt-1">Type: {mime}</p>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// Format file size
|
|
479
|
+
const formatSize = (bytes) => {
|
|
480
|
+
if (bytes === 0) return "0 B";
|
|
481
|
+
const k = 1024;
|
|
482
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
483
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
484
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// Format date
|
|
488
|
+
const formatDate = (dateString) => {
|
|
489
|
+
return new Date(dateString).toLocaleString();
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (!isOpen) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl max-h-[90vh] flex flex-col">
|
|
498
|
+
{/* Header */}
|
|
499
|
+
<div className="flex items-center justify-between p-4 border-b">
|
|
500
|
+
<div>
|
|
501
|
+
<h2 className="text-lg font-semibold">File Preview</h2>
|
|
502
|
+
</div>
|
|
503
|
+
<button
|
|
504
|
+
ref={closeButtonRef}
|
|
505
|
+
onClick={onClose}
|
|
506
|
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
507
|
+
aria-label="Close file pane"
|
|
508
|
+
>
|
|
509
|
+
<svg
|
|
510
|
+
className="h-6 w-6"
|
|
511
|
+
fill="none"
|
|
512
|
+
viewBox="0 0 24 24"
|
|
513
|
+
stroke="currentColor"
|
|
514
|
+
>
|
|
515
|
+
<path
|
|
516
|
+
strokeLinecap="round"
|
|
517
|
+
strokeLinejoin="round"
|
|
518
|
+
strokeWidth={2}
|
|
519
|
+
d="M6 18L18 6M6 6l12 12"
|
|
520
|
+
/>
|
|
521
|
+
</svg>
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
{/* Preview */}
|
|
526
|
+
<div className="flex-1 flex flex-col bg-gray-50">
|
|
527
|
+
{/* Preview Header */}
|
|
528
|
+
<div className="bg-white border-b p-4">
|
|
529
|
+
<div className="flex items-center justify-between">
|
|
530
|
+
<div>
|
|
531
|
+
<h3 className="font-medium">{filename}</h3>
|
|
532
|
+
<div className="flex items-center text-sm text-gray-500 mt-1">
|
|
533
|
+
{size && <span>{formatSize(size)}</span>}
|
|
534
|
+
{size && mtime && <span className="mx-1">•</span>}
|
|
535
|
+
{mtime && <span>{formatDate(mtime)}</span>}
|
|
536
|
+
{mime && (size || mtime) && <span className="mx-1">•</span>}
|
|
537
|
+
{mime && <span>{mime}</span>}
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div className="flex items-center space-x-2">
|
|
541
|
+
{copyNotice && (
|
|
542
|
+
<div
|
|
543
|
+
className={`text-sm ${
|
|
544
|
+
copyNotice.type === "success"
|
|
545
|
+
? "text-green-600"
|
|
546
|
+
: "text-red-600"
|
|
547
|
+
}`}
|
|
548
|
+
>
|
|
549
|
+
{copyNotice.message}
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
{content && encoding === "utf8" && (
|
|
553
|
+
<button
|
|
554
|
+
onClick={handleCopy}
|
|
555
|
+
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
556
|
+
aria-label="Copy content to clipboard"
|
|
557
|
+
>
|
|
558
|
+
Copy
|
|
559
|
+
</button>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
{/* Preview Content */}
|
|
566
|
+
<div className="flex-1 overflow-auto">{renderContent()}</div>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
}
|