@aion0/forge 0.3.4 → 0.3.5

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.
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/src/core/db/database';
3
+ import { getDbPath } from '@/src/config';
4
+
5
+ function db() { return getDb(getDbPath()); }
6
+
7
+ // GET /api/favorites — list all favorites
8
+ export async function GET() {
9
+ const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
10
+ return NextResponse.json(rows.map(r => r.project_path));
11
+ }
12
+
13
+ // POST /api/favorites — add or remove
14
+ export async function POST(req: Request) {
15
+ const { action, projectPath } = await req.json();
16
+ if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
17
+
18
+ if (action === 'add') {
19
+ db().prepare('INSERT OR IGNORE INTO project_favorites (project_path) VALUES (?)').run(projectPath);
20
+ } else if (action === 'remove') {
21
+ db().prepare('DELETE FROM project_favorites WHERE project_path = ?').run(projectPath);
22
+ }
23
+
24
+ const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
25
+ return NextResponse.json(rows.map(r => r.project_path));
26
+ }
@@ -1,8 +1,9 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { execSync } from 'node:child_process';
2
+ import { execSync, exec } from 'node:child_process';
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
+ import { promisify } from 'node:util';
6
7
  import { loadSettings } from '@/lib/settings';
7
8
 
8
9
  function isUnderProjectRoot(dir: string): boolean {
@@ -11,8 +12,17 @@ function isUnderProjectRoot(dir: string): boolean {
11
12
  return roots.some(root => dir.startsWith(root) || dir === root);
12
13
  }
13
14
 
14
- function git(cmd: string, cwd: string): string {
15
- return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
15
+ const execAsync = promisify(exec);
16
+
17
+ function gitSync(cmd: string, cwd: string): string {
18
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
19
+ }
20
+
21
+ async function gitAsync(cmd: string, cwd: string): Promise<string> {
22
+ try {
23
+ const { stdout } = await execAsync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 });
24
+ return stdout.trim();
25
+ } catch { return ''; }
16
26
  }
17
27
 
18
28
  // GET /api/git?dir=<path> — git status for a project
@@ -23,38 +33,33 @@ export async function GET(req: NextRequest) {
23
33
  }
24
34
 
25
35
  try {
26
- const branch = git('rev-parse --abbrev-ref HEAD', dir);
27
- const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
28
- const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
36
+ // Run all git commands in parallel
37
+ const [branchOut, statusOut, remoteOut, lastCommitOut, logOut] = await Promise.all([
38
+ gitAsync('rev-parse --abbrev-ref HEAD', dir),
39
+ gitAsync('status --porcelain -u', dir),
40
+ gitAsync('remote get-url origin', dir),
41
+ gitAsync('log -1 --format="%h %s"', dir),
42
+ gitAsync('log --format="%h||%s||%an||%ar" -10', dir),
43
+ ]);
44
+
45
+ const branch = branchOut;
46
+ const changes = statusOut ? statusOut.split('\n').filter(Boolean).map(line => ({
29
47
  status: line.substring(0, 2).trim() || 'M',
30
48
  path: line.substring(3).replace(/\/$/, ''),
31
- }));
32
-
33
- let remote = '';
34
- try { remote = git('remote get-url origin', dir); } catch {}
49
+ })) : [];
35
50
 
36
- let ahead = 0;
37
- let behind = 0;
51
+ let ahead = 0, behind = 0;
38
52
  try {
39
- const counts = git(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
40
- const [a, b] = counts.split('\t');
41
- ahead = parseInt(a) || 0;
42
- behind = parseInt(b) || 0;
53
+ const counts = await gitAsync(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
54
+ if (counts) { const [a, b] = counts.split('\t'); ahead = parseInt(a) || 0; behind = parseInt(b) || 0; }
43
55
  } catch {}
44
56
 
45
- const lastCommit = git('log -1 --format="%h %s" 2>/dev/null || echo ""', dir);
46
-
47
- // Git log recent commits
48
- let log: { hash: string; message: string; author: string; date: string }[] = [];
49
- try {
50
- const logOut = git('log --format="%h||%s||%an||%ar" -20', dir);
51
- log = logOut.split('\n').filter(Boolean).map(line => {
52
- const [hash, message, author, date] = line.split('||');
53
- return { hash, message, author, date };
54
- });
55
- } catch {}
57
+ const log = logOut ? logOut.split('\n').filter(Boolean).map(line => {
58
+ const [hash, message, author, date] = line.split('||');
59
+ return { hash, message, author, date };
60
+ }) : [];
56
61
 
57
- return NextResponse.json({ branch, changes, remote, ahead, behind, lastCommit, log });
62
+ return NextResponse.json({ branch, changes, remote: remoteOut, ahead, behind, lastCommit: lastCommitOut, log });
58
63
  } catch (e: any) {
59
64
  return NextResponse.json({ error: e.message }, { status: 500 });
60
65
  }
@@ -96,30 +101,30 @@ export async function POST(req: NextRequest) {
96
101
  if (!message) return NextResponse.json({ error: 'message required' }, { status: 400 });
97
102
  if (files && files.length > 0) {
98
103
  for (const f of files) {
99
- git(`add "${f}"`, dir);
104
+ gitSync(`add "${f}"`, dir);
100
105
  }
101
106
  } else {
102
- git('add -A', dir);
107
+ gitSync('add -A', dir);
103
108
  }
104
- git(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
109
+ gitSync(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
105
110
  return NextResponse.json({ ok: true });
106
111
  }
107
112
 
108
113
  if (action === 'push') {
109
- const output = git('push', dir);
114
+ const output = gitSync('push', dir);
110
115
  return NextResponse.json({ ok: true, output });
111
116
  }
112
117
 
113
118
  if (action === 'pull') {
114
- const output = git('pull', dir);
119
+ const output = gitSync('pull', dir);
115
120
  return NextResponse.json({ ok: true, output });
116
121
  }
117
122
 
118
123
  if (action === 'stage') {
119
124
  if (files && files.length > 0) {
120
- for (const f of files) git(`add "${f}"`, dir);
125
+ for (const f of files) gitSync(`add "${f}"`, dir);
121
126
  } else {
122
- git('add -A', dir);
127
+ gitSync('add -A', dir);
123
128
  }
124
129
  return NextResponse.json({ ok: true });
125
130
  }
@@ -99,8 +99,6 @@ export async function GET(req: Request) {
99
99
  return NextResponse.json({ content: '(Failed to load)' });
100
100
  }
101
101
  }
102
- // Refresh install state from filesystem
103
- refreshInstallState(getProjectPaths());
104
102
  const skills = listSkills();
105
103
  const projects = getProjectPaths().map(p => ({ path: p, name: p.split('/').pop() || p }));
106
104
  return NextResponse.json({ skills, projects });
@@ -0,0 +1,25 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getDb } from '@/src/core/db/database';
3
+ import { getDbPath } from '@/src/config';
4
+
5
+ function db() { return getDb(getDbPath()); }
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const type = req.nextUrl.searchParams.get('type') || 'projects';
9
+ try {
10
+ const row = db().prepare('SELECT data FROM tab_state WHERE type = ?').get(type) as any;
11
+ if (row?.data) return NextResponse.json(JSON.parse(row.data));
12
+ } catch {}
13
+ return NextResponse.json({ tabs: [], activeTabId: 0 });
14
+ }
15
+
16
+ export async function POST(req: NextRequest) {
17
+ const type = req.nextUrl.searchParams.get('type') || 'projects';
18
+ try {
19
+ const body = await req.json();
20
+ db().prepare('INSERT OR REPLACE INTO tab_state (type, data) VALUES (?, ?)').run(type, JSON.stringify(body));
21
+ return NextResponse.json({ ok: true });
22
+ } catch (e: any) {
23
+ return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
24
+ }
25
+ }
@@ -189,7 +189,7 @@ if (!isStop) {
189
189
  writeFileSync(settingsFile, YAML.stringify(settings), 'utf-8');
190
190
  console.log('[forge] Admin password saved');
191
191
 
192
- if (resetPassword && !isDev && !isBackground && !isRestart) {
192
+ if (resetPassword) {
193
193
  process.exit(0);
194
194
  }
195
195
  }
@@ -2,9 +2,21 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
+ import TabBar from './TabBar';
5
6
 
6
7
  const DocTerminal = lazy(() => import('./DocTerminal'));
7
8
 
9
+ interface DocTab {
10
+ id: number;
11
+ filePath: string;
12
+ fileName: string;
13
+ rootIdx: number;
14
+ content: string | null;
15
+ isImage: boolean;
16
+ }
17
+
18
+ function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
19
+
8
20
  interface FileNode {
9
21
  name: string;
10
22
  path: string;
@@ -89,6 +101,141 @@ export default function DocsViewer() {
89
101
  const [saving, setSaving] = useState(false);
90
102
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
91
103
 
104
+ // Doc tabs
105
+ const [docTabs, setDocTabs] = useState<DocTab[]>([]);
106
+ const [activeDocTabId, setActiveDocTabId] = useState(0);
107
+ const saveTimerRef = useRef<any>(null);
108
+
109
+ // Load tabs from DB on mount
110
+ useEffect(() => {
111
+ fetch('/api/tabs?type=docs').then(r => r.json())
112
+ .then(data => {
113
+ if (Array.isArray(data.tabs) && data.tabs.length > 0) {
114
+ setDocTabs(data.tabs);
115
+ setActiveDocTabId(data.activeTabId || data.tabs[0].id);
116
+ // Set selectedFile to active tab's file
117
+ const activeId = data.activeTabId || data.tabs[0].id;
118
+ const active = data.tabs.find((t: any) => t.id === activeId);
119
+ if (active) {
120
+ setSelectedFile(active.filePath);
121
+ // Content not stored in DB, fetch it
122
+ if (!active.isImage) {
123
+ fetch(`/api/docs?root=${active.rootIdx}&file=${encodeURIComponent(active.filePath)}`)
124
+ .then(r => r.json())
125
+ .then(d => { setContent(d.content || null); })
126
+ .catch(() => {});
127
+ }
128
+ }
129
+ }
130
+ }).catch(() => {});
131
+ }, []);
132
+
133
+ // Persist tabs (debounced)
134
+ const persistDocTabs = useCallback((tabs: DocTab[], activeId: number) => {
135
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
136
+ saveTimerRef.current = setTimeout(() => {
137
+ fetch('/api/tabs?type=docs', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ tabs: tabs.map(t => ({ id: t.id, filePath: t.filePath, fileName: t.fileName, rootIdx: t.rootIdx, isImage: t.isImage })),
142
+ activeTabId: activeId,
143
+ }),
144
+ }).catch(() => {});
145
+ }, 500);
146
+ }, []);
147
+
148
+ // Open file in tab
149
+ const openFileInTab = useCallback(async (path: string) => {
150
+ setSelectedFile(path);
151
+ setEditing(false);
152
+ setFileWarning(null);
153
+
154
+ const isImg = isImageFile(path);
155
+ const fileName = path.split('/').pop() || path;
156
+
157
+ // Check if tab already exists (use functional update to get latest state)
158
+ let found = false;
159
+ setDocTabs(prev => {
160
+ const existing = prev.find(t => t.filePath === path);
161
+ if (existing) {
162
+ found = true;
163
+ setActiveDocTabId(existing.id);
164
+ setContent(existing.content);
165
+ persistDocTabs(prev, existing.id);
166
+ }
167
+ return prev;
168
+ });
169
+ if (found) return;
170
+
171
+ // Fetch content
172
+ let fileContent: string | null = null;
173
+ if (!isImg) {
174
+ setLoading(true);
175
+ const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
176
+ const data = await res.json();
177
+ if (data.tooLarge) {
178
+ setFileWarning(`File too large (${data.sizeLabel})`);
179
+ } else {
180
+ fileContent = data.content || null;
181
+ }
182
+ setLoading(false);
183
+ }
184
+ setContent(fileContent);
185
+
186
+ const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
187
+ setDocTabs(prev => {
188
+ // Double-check no duplicate
189
+ if (prev.find(t => t.filePath === path)) return prev;
190
+ const updated = [...prev, newTab];
191
+ setActiveDocTabId(newTab.id);
192
+ persistDocTabs(updated, newTab.id);
193
+ return updated;
194
+ });
195
+ }, [activeRoot, persistDocTabs]);
196
+
197
+ const closeDocTab = useCallback((tabId: number) => {
198
+ setDocTabs(prev => {
199
+ const updated = prev.filter(t => t.id !== tabId);
200
+ let newActiveId = activeDocTabId;
201
+ if (tabId === activeDocTabId) {
202
+ const idx = prev.findIndex(t => t.id === tabId);
203
+ const next = updated[Math.min(idx, updated.length - 1)];
204
+ newActiveId = next?.id || 0;
205
+ if (next) { setSelectedFile(next.filePath); setContent(next.content); }
206
+ else { setSelectedFile(null); setContent(null); }
207
+ }
208
+ setActiveDocTabId(newActiveId);
209
+ persistDocTabs(updated, newActiveId);
210
+ return updated;
211
+ });
212
+ }, [activeDocTabId, persistDocTabs]);
213
+
214
+ const activateDocTab = useCallback(async (tabId: number) => {
215
+ const tab = docTabs.find(t => t.id === tabId);
216
+ if (tab) {
217
+ setActiveDocTabId(tabId);
218
+ setSelectedFile(tab.filePath);
219
+ setEditing(false);
220
+ if (tab.rootIdx !== activeRoot) setActiveRoot(tab.rootIdx);
221
+ persistDocTabs(docTabs, tabId);
222
+
223
+ // Use cached content or re-fetch
224
+ if (tab.content) {
225
+ setContent(tab.content);
226
+ } else if (!tab.isImage) {
227
+ setLoading(true);
228
+ const res = await fetch(`/api/docs?root=${tab.rootIdx}&file=${encodeURIComponent(tab.filePath)}`);
229
+ const data = await res.json();
230
+ const fetched = data.content || null;
231
+ setContent(fetched);
232
+ // Cache in tab
233
+ setDocTabs(prev => prev.map(t => t.id === tabId ? { ...t, content: fetched } : t));
234
+ setLoading(false);
235
+ }
236
+ }
237
+ }, [docTabs, activeRoot, persistDocTabs]);
238
+
92
239
  // Fetch tree
93
240
  const fetchTree = useCallback(async (rootIdx: number) => {
94
241
  const res = await fetch(`/api/docs?root=${rootIdx}`);
@@ -223,7 +370,7 @@ export default function DocsViewer() {
223
370
  filtered.map(f => (
224
371
  <button
225
372
  key={f.path}
226
- onClick={() => { openFile(f.path); setSearch(''); }}
373
+ onClick={() => { openFileInTab(f.path); setSearch(''); }}
227
374
  className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
228
375
  selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
229
376
  }`}
@@ -236,15 +383,25 @@ export default function DocsViewer() {
236
383
  )
237
384
  ) : (
238
385
  tree.map(node => (
239
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
386
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
240
387
  ))
241
388
  )}
242
389
  </div>
243
390
  </aside>
244
391
  )}
245
392
 
246
- {/* Main content — full width markdown */}
393
+ {/* Main content */}
247
394
  <main className="flex-1 flex flex-col min-w-0">
395
+ {/* Doc tab bar */}
396
+ {docTabs.length > 0 && (
397
+ <TabBar
398
+ tabs={docTabs.map(t => ({ id: t.id, label: t.fileName.replace(/\.md$/, '') }))}
399
+ activeId={activeDocTabId}
400
+ onActivate={activateDocTab}
401
+ onClose={closeDocTab}
402
+ />
403
+ )}
404
+
248
405
  {/* Top bar */}
249
406
  <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
250
407
  <button