@aion0/forge 0.4.7 → 0.4.9
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/RELEASE_NOTES.md +4 -9
- package/app/api/code/route.ts +14 -1
- package/app/api/docs/route.ts +16 -1
- package/components/CodeViewer.tsx +75 -5
- package/components/DocsViewer.tsx +50 -13
- package/components/ProjectDetail.tsx +32 -2
- package/components/TabBar.tsx +1 -1
- package/components/WebTerminal.tsx +13 -2
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.9
|
|
2
2
|
|
|
3
3
|
Released: 2026-03-22
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
6
|
-
|
|
7
|
-
### Bug Fixes
|
|
8
|
-
- fix: serial issue scanning, shell ANSI-C escaping, pipeline navigation
|
|
9
|
-
- fix: prevent concurrent startTunnel calls from killing each other (#16)
|
|
5
|
+
## Changes since v0.4.8
|
|
10
6
|
|
|
11
7
|
### Other
|
|
12
|
-
-
|
|
13
|
-
- fix(#17): normalize SQLite datetime strings to ISO 8601 UTC
|
|
8
|
+
- add browser window
|
|
14
9
|
|
|
15
10
|
|
|
16
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.8...v0.4.9
|
package/app/api/code/route.ts
CHANGED
|
@@ -26,11 +26,13 @@ const CODE_EXTS = new Set([
|
|
|
26
26
|
'.xml', '.csv', '.lock',
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
|
|
30
|
+
|
|
29
31
|
function isCodeFile(name: string): boolean {
|
|
30
32
|
if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
|
|
31
33
|
const ext = extname(name);
|
|
32
34
|
if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
|
|
33
|
-
return CODE_EXTS.has(ext);
|
|
35
|
+
return CODE_EXTS.has(ext) || IMAGE_EXTS.has(ext);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
|
|
@@ -140,6 +142,17 @@ export async function GET(req: Request) {
|
|
|
140
142
|
'class', 'jar', 'war',
|
|
141
143
|
'pyc', 'pyo', 'wasm',
|
|
142
144
|
]);
|
|
145
|
+
// Image files — return base64 for preview
|
|
146
|
+
const IMAGE_PREVIEW = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
147
|
+
if (IMAGE_PREVIEW.has(ext)) {
|
|
148
|
+
if (size > 5_000_000) {
|
|
149
|
+
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: `${sizeMB} MB`, message: 'Image too large to preview (> 5 MB)' });
|
|
150
|
+
}
|
|
151
|
+
const data = readFileSync(fullPath);
|
|
152
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
153
|
+
const base64 = `data:${mime};base64,${data.toString('base64')}`;
|
|
154
|
+
return NextResponse.json({ image: true, dataUrl: base64, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
155
|
+
}
|
|
143
156
|
if (BINARY_EXTS.has(ext)) {
|
|
144
157
|
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
145
158
|
}
|
package/app/api/docs/route.ts
CHANGED
|
@@ -105,9 +105,24 @@ export async function GET(req: Request) {
|
|
|
105
105
|
try {
|
|
106
106
|
const stat = statSync(fullPath);
|
|
107
107
|
const size = stat.size;
|
|
108
|
+
const ext = extname(fullPath).replace('.', '').toLowerCase();
|
|
108
109
|
const sizeKB = Math.round(size / 1024);
|
|
109
110
|
const sizeMB = (size / (1024 * 1024)).toFixed(1);
|
|
110
111
|
|
|
112
|
+
// Binary file types
|
|
113
|
+
const BINARY_EXTS = new Set([
|
|
114
|
+
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'avif',
|
|
115
|
+
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
|
|
116
|
+
'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
|
|
117
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
118
|
+
'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a',
|
|
119
|
+
'woff', 'woff2', 'ttf', 'eot', 'otf',
|
|
120
|
+
'sqlite', 'db', 'sqlite3', 'class', 'jar', 'pyc', 'wasm',
|
|
121
|
+
]);
|
|
122
|
+
if (BINARY_EXTS.has(ext)) {
|
|
123
|
+
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
if (size > 2_000_000) {
|
|
112
127
|
return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
|
|
113
128
|
}
|
|
@@ -115,7 +130,7 @@ export async function GET(req: Request) {
|
|
|
115
130
|
return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB` });
|
|
116
131
|
}
|
|
117
132
|
const content = readFileSync(fullPath, 'utf-8');
|
|
118
|
-
return NextResponse.json({ content });
|
|
133
|
+
return NextResponse.json({ content, language: ext });
|
|
119
134
|
} catch {
|
|
120
135
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
121
136
|
}
|
|
@@ -189,6 +189,12 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
189
189
|
const [editing, setEditing] = useState(false);
|
|
190
190
|
const [editContent, setEditContent] = useState('');
|
|
191
191
|
const [saving, setSaving] = useState(false);
|
|
192
|
+
const [browserOpen, setBrowserOpen] = useState(false);
|
|
193
|
+
const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
|
|
194
|
+
const [browserKey, setBrowserKey] = useState(0);
|
|
195
|
+
const [browserWidth, setBrowserWidth] = useState(640);
|
|
196
|
+
const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
|
197
|
+
const [browserDragging, setBrowserDragging] = useState(false);
|
|
192
198
|
|
|
193
199
|
const handleCodeOpenChange = useCallback((open: boolean) => {
|
|
194
200
|
setCodeOpen(open);
|
|
@@ -420,11 +426,75 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
420
426
|
</div>
|
|
421
427
|
)}
|
|
422
428
|
|
|
423
|
-
{/* Terminal —
|
|
424
|
-
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
425
|
-
<
|
|
426
|
-
<
|
|
427
|
-
|
|
429
|
+
{/* Terminal + Browser — main area */}
|
|
430
|
+
<div className={`flex ${codeOpen ? 'shrink-0' : 'flex-1'}`} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
431
|
+
<div className="flex-1 min-w-0">
|
|
432
|
+
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
433
|
+
<WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} browserOpen={browserOpen} onBrowserToggle={() => { setBrowserOpen(v => !v); if (!browserOpen) setBrowserKey(k => k + 1); }} />
|
|
434
|
+
</Suspense>
|
|
435
|
+
</div>
|
|
436
|
+
{browserOpen && (
|
|
437
|
+
<>
|
|
438
|
+
<div
|
|
439
|
+
onMouseDown={(e) => {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
browserDragRef.current = { startX: e.clientX, startW: browserWidth };
|
|
442
|
+
setBrowserDragging(true);
|
|
443
|
+
const onMove = (ev: MouseEvent) => {
|
|
444
|
+
if (!browserDragRef.current) return;
|
|
445
|
+
setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
|
|
446
|
+
};
|
|
447
|
+
const onUp = () => {
|
|
448
|
+
browserDragRef.current = null;
|
|
449
|
+
setBrowserDragging(false);
|
|
450
|
+
window.removeEventListener('mousemove', onMove);
|
|
451
|
+
window.removeEventListener('mouseup', onUp);
|
|
452
|
+
};
|
|
453
|
+
window.addEventListener('mousemove', onMove);
|
|
454
|
+
window.addEventListener('mouseup', onUp);
|
|
455
|
+
}}
|
|
456
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
|
|
457
|
+
/>
|
|
458
|
+
<div style={{ width: browserWidth }} className="shrink-0 flex flex-col">
|
|
459
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
|
|
460
|
+
<input
|
|
461
|
+
type="text"
|
|
462
|
+
defaultValue={browserUrl}
|
|
463
|
+
placeholder="http://localhost:3000"
|
|
464
|
+
onKeyDown={e => {
|
|
465
|
+
if (e.key === 'Enter') {
|
|
466
|
+
const url = (e.target as HTMLInputElement).value.trim();
|
|
467
|
+
if (url) {
|
|
468
|
+
setBrowserUrl(url);
|
|
469
|
+
localStorage.setItem('forge-browser-url', url);
|
|
470
|
+
setBrowserKey(k => k + 1);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}}
|
|
474
|
+
className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
|
|
475
|
+
/>
|
|
476
|
+
<button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
|
|
477
|
+
<button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
|
|
478
|
+
<button onClick={() => setBrowserOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
|
|
479
|
+
</div>
|
|
480
|
+
<div className="flex-1 relative">
|
|
481
|
+
{browserUrl ? (
|
|
482
|
+
<iframe
|
|
483
|
+
key={browserKey}
|
|
484
|
+
src={browserUrl}
|
|
485
|
+
className="absolute inset-0 w-full h-full border-0 bg-white"
|
|
486
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
487
|
+
/>
|
|
488
|
+
) : (
|
|
489
|
+
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
|
490
|
+
Enter a URL and press Enter
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
{browserDragging && <div className="absolute inset-0 z-10" />}
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
</>
|
|
497
|
+
)}
|
|
428
498
|
</div>
|
|
429
499
|
|
|
430
500
|
{/* Resize handle */}
|
|
@@ -55,20 +55,18 @@ function TreeNode({ node, depth, selected, onSelect }: {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const isSelected = selected === node.path;
|
|
58
|
-
const canOpen = node.fileType === 'md' || node.fileType === 'image';
|
|
59
58
|
|
|
60
59
|
return (
|
|
61
60
|
<button
|
|
62
|
-
onClick={() =>
|
|
61
|
+
onClick={() => onSelect(node.path)}
|
|
63
62
|
className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
|
|
64
|
-
|
|
65
|
-
: isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
63
|
+
isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
66
64
|
: 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
67
65
|
}`}
|
|
68
66
|
style={{ paddingLeft: depth * 12 + 16 }}
|
|
69
67
|
title={node.path}
|
|
70
68
|
>
|
|
71
|
-
{node.fileType === 'image' ? '🖼 ' : ''}{node.name
|
|
69
|
+
{node.fileType === 'image' ? '🖼 ' : ''}{node.name}
|
|
72
70
|
</button>
|
|
73
71
|
);
|
|
74
72
|
}
|
|
@@ -84,6 +82,20 @@ function flattenTree(nodes: FileNode[]): FileNode[] {
|
|
|
84
82
|
return result;
|
|
85
83
|
}
|
|
86
84
|
|
|
85
|
+
const BINARY_EXTS = /\.(png|jpg|jpeg|gif|bmp|ico|webp|avif|mp3|mp4|wav|ogg|webm|mov|avi|zip|gz|tar|bz2|xz|7z|rar|pdf|doc|docx|xls|xlsx|ppt|pptx|exe|dll|so|dylib|bin|woff|woff2|ttf|eot|otf|sqlite|db|class|jar|pyc|wasm|o|a)$/i;
|
|
86
|
+
|
|
87
|
+
function filterTree(nodes: FileNode[]): FileNode[] {
|
|
88
|
+
return nodes.reduce<FileNode[]>((acc, node) => {
|
|
89
|
+
if (node.type === 'dir') {
|
|
90
|
+
const children = filterTree(node.children || []);
|
|
91
|
+
if (children.length > 0) acc.push({ ...node, children });
|
|
92
|
+
} else if (!BINARY_EXTS.test(node.name)) {
|
|
93
|
+
acc.push(node);
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}, []);
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
// ─── Main Component ──────────────────────────────────────
|
|
88
100
|
|
|
89
101
|
export default function DocsViewer() {
|
|
@@ -176,7 +188,9 @@ export default function DocsViewer() {
|
|
|
176
188
|
setLoading(true);
|
|
177
189
|
const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
|
|
178
190
|
const data = await res.json();
|
|
179
|
-
if (data.
|
|
191
|
+
if (data.binary) {
|
|
192
|
+
setFileWarning(`${data.fileType?.toUpperCase() || 'Binary'} file — ${data.sizeLabel} — cannot be displayed`);
|
|
193
|
+
} else if (data.tooLarge) {
|
|
180
194
|
setFileWarning(`File too large (${data.sizeLabel})`);
|
|
181
195
|
} else {
|
|
182
196
|
fileContent = data.content || null;
|
|
@@ -185,11 +199,15 @@ export default function DocsViewer() {
|
|
|
185
199
|
}
|
|
186
200
|
setContent(fileContent);
|
|
187
201
|
|
|
202
|
+
const MAX_TABS = 8;
|
|
188
203
|
const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
|
|
189
204
|
setDocTabs(prev => {
|
|
190
|
-
// Double-check no duplicate
|
|
191
205
|
if (prev.find(t => t.filePath === path)) return prev;
|
|
192
|
-
|
|
206
|
+
let updated = [...prev, newTab];
|
|
207
|
+
// Auto-close oldest tabs if over limit
|
|
208
|
+
while (updated.length > MAX_TABS) {
|
|
209
|
+
updated = updated.slice(1);
|
|
210
|
+
}
|
|
193
211
|
setActiveDocTabId(newTab.id);
|
|
194
212
|
persistDocTabs(updated, newTab.id);
|
|
195
213
|
return updated;
|
|
@@ -259,6 +277,7 @@ export default function DocsViewer() {
|
|
|
259
277
|
}, [activeRoot, fetchTree]);
|
|
260
278
|
|
|
261
279
|
const [fileWarning, setFileWarning] = useState<string | null>(null);
|
|
280
|
+
const [hideUnsupported, setHideUnsupported] = useState(true);
|
|
262
281
|
|
|
263
282
|
// Fetch file content
|
|
264
283
|
const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
|
|
@@ -321,9 +340,9 @@ export default function DocsViewer() {
|
|
|
321
340
|
}
|
|
322
341
|
|
|
323
342
|
return (
|
|
324
|
-
<div className="flex-1 flex flex-col min-h-0">
|
|
343
|
+
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
|
325
344
|
{/* Doc content area */}
|
|
326
|
-
<div className="flex-1 flex min-h-0">
|
|
345
|
+
<div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
|
|
327
346
|
{/* Collapsible sidebar — file tree */}
|
|
328
347
|
{sidebarOpen && (
|
|
329
348
|
<aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
|
|
@@ -343,9 +362,16 @@ export default function DocsViewer() {
|
|
|
343
362
|
{/* Header with refresh */}
|
|
344
363
|
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
|
|
345
364
|
<span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
|
|
365
|
+
<button
|
|
366
|
+
onClick={() => setHideUnsupported(v => !v)}
|
|
367
|
+
className={`text-[9px] ml-auto shrink-0 px-1 rounded ${hideUnsupported ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)]'} hover:text-[var(--text-primary)]`}
|
|
368
|
+
title={hideUnsupported ? 'Show all files' : 'Hide binary files'}
|
|
369
|
+
>
|
|
370
|
+
{hideUnsupported ? 'Docs' : 'All'}
|
|
371
|
+
</button>
|
|
346
372
|
<button
|
|
347
373
|
onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
|
|
348
|
-
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
|
374
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
349
375
|
title="Refresh files"
|
|
350
376
|
>
|
|
351
377
|
↻
|
|
@@ -384,7 +410,7 @@ export default function DocsViewer() {
|
|
|
384
410
|
))
|
|
385
411
|
)
|
|
386
412
|
) : (
|
|
387
|
-
tree.map(node => (
|
|
413
|
+
(hideUnsupported ? filterTree(tree) : tree).map(node => (
|
|
388
414
|
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
|
|
389
415
|
))
|
|
390
416
|
)}
|
|
@@ -493,7 +519,7 @@ export default function DocsViewer() {
|
|
|
493
519
|
spellCheck={false}
|
|
494
520
|
/>
|
|
495
521
|
</div>
|
|
496
|
-
) : (
|
|
522
|
+
) : selectedFile.endsWith('.md') ? (
|
|
497
523
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
498
524
|
{loading ? (
|
|
499
525
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -503,6 +529,17 @@ export default function DocsViewer() {
|
|
|
503
529
|
</div>
|
|
504
530
|
)}
|
|
505
531
|
</div>
|
|
532
|
+
) : (
|
|
533
|
+
<div className="flex-1 overflow-y-auto">
|
|
534
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
535
|
+
{content.split('\n').map((line, i) => (
|
|
536
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
537
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
538
|
+
<span className="flex-1">{line || ' '}</span>
|
|
539
|
+
</div>
|
|
540
|
+
))}
|
|
541
|
+
</pre>
|
|
542
|
+
</div>
|
|
506
543
|
)
|
|
507
544
|
) : (
|
|
508
545
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
@@ -59,6 +59,8 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
59
59
|
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
60
60
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
61
61
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
62
|
+
const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
|
|
63
|
+
const [fileBinaryInfo, setFileBinaryInfo] = useState<{ fileType: string; sizeLabel: string; message?: string } | null>(null);
|
|
62
64
|
const [fileLanguage, setFileLanguage] = useState('');
|
|
63
65
|
const [fileLoading, setFileLoading] = useState(false);
|
|
64
66
|
const [showLog, setShowLog] = useState(false);
|
|
@@ -117,12 +119,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
117
119
|
setSelectedFile(path);
|
|
118
120
|
setDiffContent(null);
|
|
119
121
|
setDiffFile(null);
|
|
122
|
+
setFileContent(null);
|
|
123
|
+
setFileImageUrl(null);
|
|
124
|
+
setFileBinaryInfo(null);
|
|
120
125
|
setFileLoading(true);
|
|
121
126
|
try {
|
|
122
127
|
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
|
|
123
128
|
const data = await res.json();
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
if (data.image) {
|
|
130
|
+
setFileImageUrl(data.dataUrl);
|
|
131
|
+
} else if (data.binary) {
|
|
132
|
+
setFileBinaryInfo({ fileType: data.fileType, sizeLabel: data.sizeLabel, message: data.message });
|
|
133
|
+
} else if (data.tooLarge) {
|
|
134
|
+
setFileBinaryInfo({ fileType: '', sizeLabel: data.sizeLabel, message: data.message });
|
|
135
|
+
} else {
|
|
136
|
+
setFileContent(data.content || null);
|
|
137
|
+
setFileLanguage(data.language || '');
|
|
138
|
+
}
|
|
126
139
|
} catch { setFileContent(null); }
|
|
127
140
|
setFileLoading(false);
|
|
128
141
|
}, [projectPath]);
|
|
@@ -529,6 +542,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
529
542
|
</>
|
|
530
543
|
) : fileLoading ? (
|
|
531
544
|
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
545
|
+
) : selectedFile && fileImageUrl ? (
|
|
546
|
+
<>
|
|
547
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
548
|
+
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
|
549
|
+
<img src={fileImageUrl} alt={selectedFile} className="max-w-full max-h-full object-contain rounded" />
|
|
550
|
+
</div>
|
|
551
|
+
</>
|
|
552
|
+
) : selectedFile && fileBinaryInfo ? (
|
|
553
|
+
<>
|
|
554
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
555
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
556
|
+
<span className="text-2xl">📄</span>
|
|
557
|
+
<span className="text-xs">{fileBinaryInfo.fileType ? fileBinaryInfo.fileType.toUpperCase() + ' file' : 'File'} — {fileBinaryInfo.sizeLabel}</span>
|
|
558
|
+
{fileBinaryInfo.message && <span className="text-[10px]">{fileBinaryInfo.message}</span>}
|
|
559
|
+
<span className="text-[10px]">Binary file cannot be displayed</span>
|
|
560
|
+
</div>
|
|
561
|
+
</>
|
|
532
562
|
) : selectedFile && fileContent !== null ? (
|
|
533
563
|
<>
|
|
534
564
|
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
package/components/TabBar.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose }: TabBarPr
|
|
|
18
18
|
if (tabs.length === 0) return null;
|
|
19
19
|
|
|
20
20
|
return (
|
|
21
|
-
<div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0">
|
|
21
|
+
<div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0 min-w-0 max-w-full">
|
|
22
22
|
{tabs.map(tab => (
|
|
23
23
|
<div
|
|
24
24
|
key={tab.id}
|
|
@@ -15,6 +15,8 @@ export interface WebTerminalHandle {
|
|
|
15
15
|
export interface WebTerminalProps {
|
|
16
16
|
onActiveSession?: (sessionName: string | null) => void;
|
|
17
17
|
onCodeOpenChange?: (open: boolean) => void;
|
|
18
|
+
browserOpen?: boolean;
|
|
19
|
+
onBrowserToggle?: () => void;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
// ─── Types ───────────────────────────────────────────────────
|
|
@@ -164,7 +166,7 @@ let globalDragging = false;
|
|
|
164
166
|
|
|
165
167
|
// ─── Main component ─────────────────────────────────────────
|
|
166
168
|
|
|
167
|
-
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
|
|
169
|
+
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange, browserOpen, onBrowserToggle }, ref) {
|
|
168
170
|
const [tabs, setTabs] = useState<TabState[]>(() => {
|
|
169
171
|
const tree = makeTerminal();
|
|
170
172
|
return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
|
|
@@ -634,8 +636,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
634
636
|
Code
|
|
635
637
|
</button>
|
|
636
638
|
)}
|
|
639
|
+
{onBrowserToggle && (
|
|
640
|
+
<button
|
|
641
|
+
onClick={onBrowserToggle}
|
|
642
|
+
className={`text-[11px] px-3 py-1 rounded font-bold ${browserOpen ? 'text-white bg-blue-500 hover:bg-blue-400' : 'text-blue-400 border border-blue-500 hover:bg-blue-500 hover:text-white'}`}
|
|
643
|
+
title={browserOpen ? 'Close browser' : 'Open browser'}
|
|
644
|
+
>
|
|
645
|
+
Browser
|
|
646
|
+
</button>
|
|
647
|
+
)}
|
|
637
648
|
{activeTab && countTerminals(activeTab.tree) > 1 && (
|
|
638
|
-
<button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-
|
|
649
|
+
<button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:text-red-400 hover:bg-[var(--term-border)] rounded font-medium">
|
|
639
650
|
Close Pane
|
|
640
651
|
</button>
|
|
641
652
|
)}
|