@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 CHANGED
@@ -1,16 +1,11 @@
1
- # Forge v0.4.7
1
+ # Forge v0.4.8
2
2
 
3
3
  Released: 2026-03-22
4
4
 
5
- ## Changes since v0.4.6
5
+ ## Changes since v0.4.7
6
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)
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
@@ -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
  }
@@ -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={() => canOpen && onSelect(node.path)}
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
- !canOpen ? 'text-[var(--text-secondary)]/40 cursor-default'
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.replace(/\.md$/, '')}
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.tooLarge) {
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
- const updated = [...prev, newTab];
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)] ml-auto shrink-0"
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
- setFileContent(data.content || null);
125
- setFileLanguage(data.language || '');
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>
@@ -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}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {