@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.
- package/app/api/favorites/route.ts +26 -0
- package/app/api/git/route.ts +40 -35
- package/app/api/skills/route.ts +0 -2
- package/app/api/tabs/route.ts +25 -0
- package/bin/forge-server.mjs +1 -1
- package/components/DocsViewer.tsx +160 -3
- package/components/ProjectDetail.tsx +1115 -0
- package/components/ProjectManager.tsx +189 -1105
- package/components/TabBar.tsx +46 -0
- package/lib/settings.ts +2 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +12 -0
|
@@ -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
|
+
}
|
package/app/api/git/route.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
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 =
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
104
|
+
gitSync(`add "${f}"`, dir);
|
|
100
105
|
}
|
|
101
106
|
} else {
|
|
102
|
-
|
|
107
|
+
gitSync('add -A', dir);
|
|
103
108
|
}
|
|
104
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
|
125
|
+
for (const f of files) gitSync(`add "${f}"`, dir);
|
|
121
126
|
} else {
|
|
122
|
-
|
|
127
|
+
gitSync('add -A', dir);
|
|
123
128
|
}
|
|
124
129
|
return NextResponse.json({ ok: true });
|
|
125
130
|
}
|
package/app/api/skills/route.ts
CHANGED
|
@@ -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
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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
|
|
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={() => {
|
|
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={
|
|
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
|
|
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
|