@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.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +46 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +444 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
  52. package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. 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
+ }