@aion0/forge 0.4.7 → 0.4.8
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 +5 -10
- package/app/api/code/route.ts +14 -1
- package/app/api/docs/route.ts +16 -1
- package/components/DocsViewer.tsx +50 -13
- package/components/ProjectDetail.tsx +32 -2
- package/components/TabBar.tsx +1 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.8
|
|
2
2
|
|
|
3
3
|
Released: 2026-03-22
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.7
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
-
|
|
9
|
-
- fix: prevent concurrent startTunnel calls from killing each other (#16)
|
|
7
|
+
### Features
|
|
8
|
+
- feat: image preview, all file types support, docs tab limit
|
|
10
9
|
|
|
11
|
-
### Other
|
|
12
|
-
- improve pipeline
|
|
13
|
-
- fix(#17): normalize SQLite datetime strings to ISO 8601 UTC
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.6...v0.4.7
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.7...v0.4.8
|
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
|
}
|
|
@@ -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}
|