@aion0/forge 0.2.0 → 0.2.1

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.
@@ -138,23 +138,57 @@ export async function GET(req: Request) {
138
138
  const tree = scanDir(resolvedDir, resolvedDir);
139
139
  const dirName = resolvedDir.split('/').pop() || resolvedDir;
140
140
 
141
- // Git status: changed files
142
- let gitChanges: { path: string; status: string }[] = [];
143
- let gitBranch = '';
141
+ // Git status: scan for git repos (could be root dir or subdirectories)
142
+ interface GitRepo {
143
+ name: string; // repo dir name (or '.' for root)
144
+ branch: string;
145
+ remote: string; // remote URL
146
+ changes: { path: string; status: string }[];
147
+ }
148
+ const gitRepos: GitRepo[] = [];
149
+
150
+ function scanGitStatus(dir: string, repoName: string, pathPrefix: string) {
151
+ try {
152
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf-8', timeout: 3000 }).trim();
153
+ const statusOut = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 5000 });
154
+ const changes = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
155
+ .map(line => {
156
+ if (line.length < 4) return null;
157
+ return {
158
+ status: line.substring(0, 2).trim() || 'M',
159
+ path: pathPrefix ? `${pathPrefix}/${line.substring(3).replace(/\/$/, '')}` : line.substring(3).replace(/\/$/, ''),
160
+ };
161
+ })
162
+ .filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
163
+ let remote = '';
164
+ try { remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8', timeout: 2000 }).trim(); } catch {}
165
+ if (branch || changes.length > 0) {
166
+ gitRepos.push({ name: repoName, branch, remote, changes });
167
+ }
168
+ } catch {}
169
+ }
170
+
171
+ // Check if root is a git repo
144
172
  try {
145
- gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: resolvedDir, encoding: 'utf-8', timeout: 3000 }).trim();
146
- const statusOut = execSync('git status --porcelain -u', { cwd: resolvedDir, encoding: 'utf-8', timeout: 5000 });
147
- gitChanges = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
148
- .map(line => {
149
- // Format: XY<space>path — first 2 chars are status, char 3 is space, rest is path
150
- if (line.length < 4) return null;
151
- return {
152
- status: line.substring(0, 2).trim() || 'M',
153
- path: line.substring(3).replace(/\/$/, ''),
154
- };
155
- })
156
- .filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
157
- } catch {}
158
-
159
- return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges });
173
+ execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000 });
174
+ scanGitStatus(resolvedDir, '.', '');
175
+ } catch {
176
+ // Root is not a git repo — scan subdirectories
177
+ try {
178
+ for (const entry of readdirSync(resolvedDir, { withFileTypes: true })) {
179
+ if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
180
+ const subDir = join(resolvedDir, entry.name);
181
+ try {
182
+ execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000 });
183
+ scanGitStatus(subDir, entry.name, entry.name);
184
+ } catch {}
185
+ }
186
+ } catch {}
187
+ }
188
+
189
+ // Flatten for backward compat
190
+ const gitChanges = gitRepos.flatMap(r => r.changes);
191
+ const gitBranch = gitRepos.length === 1 ? gitRepos[0].branch : '';
192
+
193
+ return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges, gitRepos });
160
194
  }
@@ -15,13 +15,15 @@ interface FileNode {
15
15
  // ─── File Tree ───────────────────────────────────────────
16
16
 
17
17
  type GitStatusMap = Map<string, string>; // path → status
18
+ type GitRepoMap = Map<string, { branch: string; remote: string }>; // dir name → repo info
18
19
 
19
- function TreeNode({ node, depth, selected, onSelect, gitMap }: {
20
+ function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
20
21
  node: FileNode;
21
22
  depth: number;
22
23
  selected: string | null;
23
24
  onSelect: (path: string) => void;
24
25
  gitMap: GitStatusMap;
26
+ repoMap: GitRepoMap;
25
27
  }) {
26
28
  // Auto-expand if selected file is under this directory
27
29
  const containsSelected = selected ? selected.startsWith(node.path + '/') : false;
@@ -30,18 +32,23 @@ function TreeNode({ node, depth, selected, onSelect, gitMap }: {
30
32
 
31
33
  if (node.type === 'dir') {
32
34
  const dirHasChanges = node.children?.some(c => hasGitChanges(c, gitMap));
35
+ const repo = repoMap.get(node.name);
33
36
  return (
34
37
  <div>
35
38
  <button
36
39
  onClick={() => setManualExpanded(v => v === null ? !expanded : !v)}
37
- className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
40
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs group"
38
41
  style={{ paddingLeft: depth * 12 + 4 }}
42
+ title={repo ? `${repo.branch} · ${repo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}` : undefined}
39
43
  >
40
44
  <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
41
45
  <span className={dirHasChanges ? 'text-yellow-400' : 'text-[var(--text-primary)]'}>{node.name}</span>
46
+ {repo && (
47
+ <span className="text-[8px] text-[var(--accent)] opacity-60 group-hover:opacity-100 ml-auto shrink-0">{repo.branch}</span>
48
+ )}
42
49
  </button>
43
50
  {expanded && node.children?.map(child => (
44
- <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} />
51
+ <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} repoMap={repoMap} />
45
52
  ))}
46
53
  </div>
47
54
  );
@@ -159,12 +166,13 @@ function highlightLine(line: string, lang: string): React.ReactNode {
159
166
 
160
167
  // ─── Main Component ──────────────────────────────────────
161
168
 
162
- export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef: React.RefObject<WebTerminalHandle | null>; onToggleCode?: () => void }) {
169
+ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObject<WebTerminalHandle | null> }) {
163
170
  const [currentDir, setCurrentDir] = useState<string | null>(null);
164
171
  const [dirName, setDirName] = useState('');
165
172
  const [tree, setTree] = useState<FileNode[]>([]);
166
173
  const [gitBranch, setGitBranch] = useState('');
167
174
  const [gitChanges, setGitChanges] = useState<{ path: string; status: string }[]>([]);
175
+ const [gitRepos, setGitRepos] = useState<{ name: string; branch: string; remote: string; changes: { path: string; status: string }[] }[]>([]);
168
176
  const [showGit, setShowGit] = useState(false);
169
177
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
170
178
  const [content, setContent] = useState<string | null>(null);
@@ -176,6 +184,10 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
176
184
  const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
177
185
  const [sidebarOpen, setSidebarOpen] = useState(true);
178
186
  const [codeOpen, setCodeOpen] = useState(false);
187
+
188
+ const handleCodeOpenChange = useCallback((open: boolean) => {
189
+ setCodeOpen(open);
190
+ }, []);
179
191
  const [terminalHeight, setTerminalHeight] = useState(300);
180
192
  const [activeSession, setActiveSession] = useState<string | null>(null);
181
193
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
@@ -216,6 +228,7 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
216
228
  setDirName(data.dirName || currentDir.split('/').pop() || '');
217
229
  setGitBranch(data.gitBranch || '');
218
230
  setGitChanges(data.gitChanges || []);
231
+ setGitRepos(data.gitRepos || []);
219
232
  })
220
233
  .catch(() => setTree([]));
221
234
  };
@@ -224,6 +237,7 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
224
237
 
225
238
  // Build git status map for tree coloring
226
239
  const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
240
+ const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
227
241
 
228
242
  const openFile = useCallback(async (path: string) => {
229
243
  if (!currentDir) return;
@@ -285,7 +299,7 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
285
299
  {/* Terminal — top */}
286
300
  <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
287
301
  <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
288
- <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} codeOpen={codeOpen} onToggleCode={() => setCodeOpen(v => !v)} />
302
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
289
303
  </Suspense>
290
304
  </div>
291
305
 
@@ -314,6 +328,11 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
314
328
  </span>
315
329
  )}
316
330
  </div>
331
+ {gitRepos.find(r => r.name === '.')?.remote && (
332
+ <div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={gitRepos.find(r => r.name === '.')!.remote}>
333
+ {gitRepos.find(r => r.name === '.')!.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}
334
+ </div>
335
+ )}
317
336
  {gitChanges.length > 0 && (
318
337
  <button
319
338
  onClick={() => setShowGit(v => !v)}
@@ -324,38 +343,55 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
324
343
  )}
325
344
  </div>
326
345
 
327
- {/* Git changes */}
346
+ {/* Git changes — grouped by repo */}
328
347
  {showGit && gitChanges.length > 0 && (
329
348
  <div className="border-b border-[var(--border)] max-h-48 overflow-y-auto">
330
- {gitChanges.map(g => (
331
- <div
332
- key={g.path}
333
- className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
334
- diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
335
- }`}
336
- >
337
- <span className={`text-[10px] font-mono w-4 shrink-0 ${
338
- g.status.includes('M') ? 'text-yellow-500' :
339
- g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
340
- g.status.includes('D') ? 'text-red-500' :
341
- 'text-[var(--text-secondary)]'
342
- }`}>
343
- {g.status.includes('?') ? '+' : g.status[0]}
344
- </span>
345
- <button
346
- onClick={() => openDiff(g.path)}
347
- className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1"
348
- title="View diff"
349
- >
350
- {g.path}
351
- </button>
352
- <button
353
- onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
354
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0"
355
- title="Locate in file tree"
349
+ {gitRepos.map(repo => (
350
+ <div key={repo.name}>
351
+ {/* Repo header — only show if multiple repos */}
352
+ {gitRepos.length > 1 && (
353
+ <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0" title={repo.remote}>
354
+ <div className="flex items-center gap-1.5">
355
+ <span className="font-semibold text-[var(--text-primary)]">{repo.name}</span>
356
+ <span className="text-[var(--accent)]">{repo.branch}</span>
357
+ <span className="ml-auto">{repo.changes.length}</span>
358
+ </div>
359
+ {repo.remote && (
360
+ <div className="text-[8px] truncate mt-0.5">{repo.remote.replace(/^https?:\/\//, '').replace(/\.git$/, '')}</div>
361
+ )}
362
+ </div>
363
+ )}
364
+ {repo.changes.map(g => (
365
+ <div
366
+ key={g.path}
367
+ className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
368
+ diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
369
+ }`}
370
+ >
371
+ <span className={`text-[10px] font-mono w-4 shrink-0 ${
372
+ g.status.includes('M') ? 'text-yellow-500' :
373
+ g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
374
+ g.status.includes('D') ? 'text-red-500' :
375
+ 'text-[var(--text-secondary)]'
376
+ }`}>
377
+ {g.status.includes('?') ? '+' : g.status[0]}
378
+ </span>
379
+ <button
380
+ onClick={() => openDiff(g.path)}
381
+ className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1 group relative"
382
+ title={`${g.path}${gitRepos.length > 1 ? ` (${repo.name} · ${repo.branch})` : ''}`}
383
+ >
384
+ {gitRepos.length > 1 ? g.path.replace(repo.name + '/', '') : g.path}
385
+ </button>
386
+ <button
387
+ onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
388
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0"
389
+ title="Locate in file tree"
356
390
  >
357
391
  file
358
392
  </button>
393
+ </div>
394
+ ))}
359
395
  </div>
360
396
  ))}
361
397
  </div>
@@ -396,7 +432,7 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
396
432
  )
397
433
  ) : (
398
434
  tree.map(node => (
399
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} />
435
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
400
436
  ))
401
437
  )}
402
438
  </div>
@@ -40,7 +40,6 @@ export default function Dashboard({ user }: { user: any }) {
40
40
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
41
41
  const [showNewTask, setShowNewTask] = useState(false);
42
42
  const [showSettings, setShowSettings] = useState(false);
43
- const [showCode, setShowCode] = useState(true);
44
43
  const [usage, setUsage] = useState<UsageSummary[]>([]);
45
44
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
46
45
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -262,7 +261,7 @@ export default function Dashboard({ user }: { user: any }) {
262
261
  {/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
263
262
  <div className={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
264
263
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
265
- <CodeViewer terminalRef={terminalRef} onToggleCode={() => setShowCode(v => !v)} />
264
+ <CodeViewer terminalRef={terminalRef} />
266
265
  </Suspense>
267
266
  </div>
268
267
  </div>
@@ -13,8 +13,7 @@ export interface WebTerminalHandle {
13
13
 
14
14
  export interface WebTerminalProps {
15
15
  onActiveSession?: (sessionName: string | null) => void;
16
- codeOpen?: boolean;
17
- onToggleCode?: () => void;
16
+ onCodeOpenChange?: (open: boolean) => void;
18
17
  }
19
18
 
20
19
  // ─── Types ───────────────────────────────────────────────────
@@ -162,7 +161,7 @@ let globalDragging = false;
162
161
 
163
162
  // ─── Main component ─────────────────────────────────────────
164
163
 
165
- const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, codeOpen, onToggleCode }, ref) {
164
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
166
165
  const [tabs, setTabs] = useState<TabState[]>(() => {
167
166
  const tree = makeTerminal();
168
167
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -178,6 +177,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
178
177
  const sessionLabelsRef = useRef<Record<string, string>>({});
179
178
  const dragTabRef = useRef<number | null>(null);
180
179
  const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
180
+ const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
181
181
 
182
182
  // Restore shared state from server after mount
183
183
  useEffect(() => {
@@ -214,12 +214,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
214
214
 
215
215
  const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
216
216
 
217
- // Notify parent when active terminal session changes
217
+ // Notify parent when active terminal session or code state changes
218
218
  useEffect(() => {
219
- if (!onActiveSession || !activeTab) return;
220
- const sessions = collectSessionNames(activeTab.tree);
221
- onActiveSession(sessions[0] || null);
222
- }, [activeTabId, activeTab, onActiveSession]);
219
+ if (!activeTab) return;
220
+ if (onActiveSession) {
221
+ const sessions = collectSessionNames(activeTab.tree);
222
+ onActiveSession(sessions[0] || null);
223
+ }
224
+ if (onCodeOpenChange) {
225
+ onCodeOpenChange(tabCodeOpen[activeTab.id] ?? false);
226
+ }
227
+ }, [activeTabId, activeTab, onActiveSession, onCodeOpenChange, tabCodeOpen]);
223
228
 
224
229
  // ─── Imperative handle for parent ─────────────────────
225
230
 
@@ -519,11 +524,16 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
519
524
  >
520
525
  Refresh
521
526
  </button>
522
- {onToggleCode && (
527
+ {onCodeOpenChange && activeTab && (
523
528
  <button
524
- onClick={onToggleCode}
525
- className={`text-[11px] px-3 py-1 rounded font-bold ${codeOpen ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
526
- title={codeOpen ? 'Hide code panel' : 'Show code panel'}
529
+ onClick={() => {
530
+ const current = tabCodeOpen[activeTab.id] ?? false;
531
+ const next = !current;
532
+ setTabCodeOpen(prev => ({ ...prev, [activeTab.id]: next }));
533
+ onCodeOpenChange(next);
534
+ }}
535
+ className={`text-[11px] px-3 py-1 rounded font-bold ${(tabCodeOpen[activeTab.id] ?? false) ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
536
+ title={(tabCodeOpen[activeTab.id] ?? false) ? 'Hide code panel' : 'Show code panel'}
527
537
  >
528
538
  Code
529
539
  </button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {