@aion0/forge 0.2.0 → 0.2.2
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/code/route.ts +83 -22
- package/app/api/docs/route.ts +48 -3
- package/app/api/git/route.ts +131 -0
- package/app/api/online/route.ts +40 -0
- package/app/api/preview/[...path]/route.ts +64 -0
- package/app/api/preview/route.ts +135 -0
- package/app/api/tasks/[id]/route.ts +8 -2
- package/components/CodeViewer.tsx +274 -37
- package/components/Dashboard.tsx +68 -3
- package/components/DocsViewer.tsx +54 -5
- package/components/NewTaskModal.tsx +7 -7
- package/components/PreviewPanel.tsx +154 -0
- package/components/ProjectManager.tsx +410 -0
- package/components/SettingsModal.tsx +4 -1
- package/components/TaskDetail.tsx +1 -1
- package/components/WebTerminal.tsx +131 -21
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +1 -1
|
@@ -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,27 +166,35 @@ function highlightLine(line: string, lang: string): React.ReactNode {
|
|
|
159
166
|
|
|
160
167
|
// ─── Main Component ──────────────────────────────────────
|
|
161
168
|
|
|
162
|
-
export default function CodeViewer({ terminalRef
|
|
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);
|
|
171
179
|
const [language, setLanguage] = useState('');
|
|
172
180
|
const [loading, setLoading] = useState(false);
|
|
181
|
+
const [fileWarning, setFileWarning] = useState<{ type: 'binary' | 'large' | 'tooLarge'; label: string; fileType?: string } | null>(null);
|
|
173
182
|
const [search, setSearch] = useState('');
|
|
174
183
|
const [diffContent, setDiffContent] = useState<string | null>(null);
|
|
175
184
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
176
185
|
const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
|
|
177
186
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
178
187
|
const [codeOpen, setCodeOpen] = useState(false);
|
|
188
|
+
|
|
189
|
+
const handleCodeOpenChange = useCallback((open: boolean) => {
|
|
190
|
+
setCodeOpen(open);
|
|
191
|
+
}, []);
|
|
179
192
|
const [terminalHeight, setTerminalHeight] = useState(300);
|
|
180
193
|
const [activeSession, setActiveSession] = useState<string | null>(null);
|
|
194
|
+
const [taskNotification, setTaskNotification] = useState<{ id: string; status: string; prompt: string; sessionId?: string } | null>(null);
|
|
181
195
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
|
182
196
|
const lastDirRef = useRef<string | null>(null);
|
|
197
|
+
const lastTaskCheckRef = useRef<string>('');
|
|
183
198
|
|
|
184
199
|
// When active terminal session changes, query its cwd
|
|
185
200
|
useEffect(() => {
|
|
@@ -216,24 +231,72 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
216
231
|
setDirName(data.dirName || currentDir.split('/').pop() || '');
|
|
217
232
|
setGitBranch(data.gitBranch || '');
|
|
218
233
|
setGitChanges(data.gitChanges || []);
|
|
234
|
+
setGitRepos(data.gitRepos || []);
|
|
219
235
|
})
|
|
220
236
|
.catch(() => setTree([]));
|
|
221
237
|
};
|
|
222
238
|
fetchDir();
|
|
223
239
|
}, [currentDir]);
|
|
224
240
|
|
|
241
|
+
// Poll for task completions in the current project
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!currentDir) return;
|
|
244
|
+
const dirName = currentDir.split('/').pop() || '';
|
|
245
|
+
const check = async () => {
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch('/api/tasks?status=done');
|
|
248
|
+
const tasks = await res.json();
|
|
249
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return;
|
|
250
|
+
const latest = tasks.find((t: any) => t.projectPath === currentDir || t.projectName === dirName);
|
|
251
|
+
if (latest && latest.id !== lastTaskCheckRef.current && latest.completedAt) {
|
|
252
|
+
// Only notify if completed in the last 30s
|
|
253
|
+
const age = Date.now() - new Date(latest.completedAt).getTime();
|
|
254
|
+
if (age < 30_000) {
|
|
255
|
+
lastTaskCheckRef.current = latest.id;
|
|
256
|
+
setTaskNotification({
|
|
257
|
+
id: latest.id,
|
|
258
|
+
status: latest.status,
|
|
259
|
+
prompt: latest.prompt,
|
|
260
|
+
sessionId: latest.conversationId,
|
|
261
|
+
});
|
|
262
|
+
setTimeout(() => setTaskNotification(null), 15_000);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
};
|
|
267
|
+
const timer = setInterval(check, 5000);
|
|
268
|
+
return () => clearInterval(timer);
|
|
269
|
+
}, [currentDir]);
|
|
270
|
+
|
|
225
271
|
// Build git status map for tree coloring
|
|
226
272
|
const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
|
|
273
|
+
const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
|
|
227
274
|
|
|
228
|
-
const openFile = useCallback(async (path: string) => {
|
|
275
|
+
const openFile = useCallback(async (path: string, forceLoad?: boolean) => {
|
|
229
276
|
if (!currentDir) return;
|
|
230
277
|
setSelectedFile(path);
|
|
231
278
|
setViewMode('file');
|
|
279
|
+
setFileWarning(null);
|
|
232
280
|
setLoading(true);
|
|
233
|
-
|
|
281
|
+
|
|
282
|
+
const url = `/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}${forceLoad ? '&force=1' : ''}`;
|
|
283
|
+
const res = await fetch(url);
|
|
234
284
|
const data = await res.json();
|
|
235
|
-
|
|
236
|
-
|
|
285
|
+
|
|
286
|
+
if (data.binary) {
|
|
287
|
+
setContent(null);
|
|
288
|
+
setFileWarning({ type: 'binary', label: data.sizeLabel, fileType: data.fileType });
|
|
289
|
+
} else if (data.tooLarge) {
|
|
290
|
+
setContent(null);
|
|
291
|
+
setFileWarning({ type: 'tooLarge', label: data.sizeLabel });
|
|
292
|
+
} else if (data.large && !forceLoad) {
|
|
293
|
+
setContent(null);
|
|
294
|
+
setFileWarning({ type: 'large', label: data.sizeLabel });
|
|
295
|
+
setLanguage(data.language || '');
|
|
296
|
+
} else {
|
|
297
|
+
setContent(data.content || null);
|
|
298
|
+
setLanguage(data.language || '');
|
|
299
|
+
}
|
|
237
300
|
setLoading(false);
|
|
238
301
|
}, [currentDir]);
|
|
239
302
|
|
|
@@ -276,16 +339,86 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
276
339
|
window.addEventListener('mouseup', onUp);
|
|
277
340
|
};
|
|
278
341
|
|
|
342
|
+
// Git operations
|
|
343
|
+
const [commitMsg, setCommitMsg] = useState('');
|
|
344
|
+
const [gitLoading, setGitLoading] = useState(false);
|
|
345
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
346
|
+
|
|
347
|
+
const gitAction = useCallback(async (action: string, extra?: any) => {
|
|
348
|
+
if (!currentDir) return;
|
|
349
|
+
setGitLoading(true);
|
|
350
|
+
setGitResult(null);
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetch('/api/git', {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ action, dir: currentDir, ...extra }),
|
|
356
|
+
});
|
|
357
|
+
const data = await res.json();
|
|
358
|
+
setGitResult(data);
|
|
359
|
+
// Refresh git status
|
|
360
|
+
if (data.ok) {
|
|
361
|
+
const r = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`);
|
|
362
|
+
const d = await r.json();
|
|
363
|
+
setGitChanges(d.gitChanges || []);
|
|
364
|
+
setGitRepos(d.gitRepos || []);
|
|
365
|
+
setGitBranch(d.gitBranch || '');
|
|
366
|
+
if (action === 'commit') setCommitMsg('');
|
|
367
|
+
}
|
|
368
|
+
} catch (e: any) {
|
|
369
|
+
setGitResult({ error: e.message });
|
|
370
|
+
}
|
|
371
|
+
setGitLoading(false);
|
|
372
|
+
setTimeout(() => setGitResult(null), 5000);
|
|
373
|
+
}, [currentDir]);
|
|
374
|
+
|
|
375
|
+
const refreshAll = useCallback(() => {
|
|
376
|
+
if (!currentDir) return;
|
|
377
|
+
// Refresh tree + git
|
|
378
|
+
fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`)
|
|
379
|
+
.then(r => r.json())
|
|
380
|
+
.then(data => {
|
|
381
|
+
setTree(data.tree || []);
|
|
382
|
+
setDirName(data.dirName || currentDir.split('/').pop() || '');
|
|
383
|
+
setGitBranch(data.gitBranch || '');
|
|
384
|
+
setGitChanges(data.gitChanges || []);
|
|
385
|
+
setGitRepos(data.gitRepos || []);
|
|
386
|
+
})
|
|
387
|
+
.catch(() => {});
|
|
388
|
+
// Refresh open file
|
|
389
|
+
if (selectedFile) openFile(selectedFile);
|
|
390
|
+
}, [currentDir, selectedFile, openFile]);
|
|
391
|
+
|
|
279
392
|
const handleActiveSession = useCallback((session: string | null) => {
|
|
280
393
|
setActiveSession(session);
|
|
281
394
|
}, []);
|
|
282
395
|
|
|
283
396
|
return (
|
|
284
397
|
<div className="flex-1 flex flex-col min-h-0">
|
|
398
|
+
{/* Task completion notification */}
|
|
399
|
+
{taskNotification && (
|
|
400
|
+
<div className="shrink-0 px-3 py-1.5 bg-green-900/30 border-b border-green-800/50 flex items-center gap-2 text-xs">
|
|
401
|
+
<span className="text-green-400">{taskNotification.status === 'done' ? '✅' : '❌'}</span>
|
|
402
|
+
<span className="text-green-300 truncate">Task {taskNotification.id}: {taskNotification.prompt.slice(0, 60)}</span>
|
|
403
|
+
{taskNotification.sessionId && (
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => {
|
|
406
|
+
// Send claude --resume to the active terminal
|
|
407
|
+
// The tmux display-message from backend already showed the notification
|
|
408
|
+
setTaskNotification(null);
|
|
409
|
+
}}
|
|
410
|
+
className="ml-auto text-[10px] text-green-400 hover:text-white shrink-0"
|
|
411
|
+
>
|
|
412
|
+
Dismiss
|
|
413
|
+
</button>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
|
|
285
418
|
{/* Terminal — top */}
|
|
286
419
|
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
287
420
|
<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}
|
|
421
|
+
<WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
|
|
289
422
|
</Suspense>
|
|
290
423
|
</div>
|
|
291
424
|
|
|
@@ -313,7 +446,19 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
313
446
|
{gitBranch}
|
|
314
447
|
</span>
|
|
315
448
|
)}
|
|
449
|
+
<button
|
|
450
|
+
onClick={refreshAll}
|
|
451
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
|
|
452
|
+
title="Refresh files & git status"
|
|
453
|
+
>
|
|
454
|
+
↻
|
|
455
|
+
</button>
|
|
316
456
|
</div>
|
|
457
|
+
{gitRepos.find(r => r.name === '.')?.remote && (
|
|
458
|
+
<div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={gitRepos.find(r => r.name === '.')!.remote}>
|
|
459
|
+
{gitRepos.find(r => r.name === '.')!.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
317
462
|
{gitChanges.length > 0 && (
|
|
318
463
|
<button
|
|
319
464
|
onClick={() => setShowGit(v => !v)}
|
|
@@ -324,38 +469,55 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
324
469
|
)}
|
|
325
470
|
</div>
|
|
326
471
|
|
|
327
|
-
{/* Git changes */}
|
|
472
|
+
{/* Git changes — grouped by repo */}
|
|
328
473
|
{showGit && gitChanges.length > 0 && (
|
|
329
474
|
<div className="border-b border-[var(--border)] max-h-48 overflow-y-auto">
|
|
330
|
-
{
|
|
331
|
-
<div
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
475
|
+
{gitRepos.map(repo => (
|
|
476
|
+
<div key={repo.name}>
|
|
477
|
+
{/* Repo header — only show if multiple repos */}
|
|
478
|
+
{gitRepos.length > 1 && (
|
|
479
|
+
<div className="px-2 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0" title={repo.remote}>
|
|
480
|
+
<div className="flex items-center gap-1.5">
|
|
481
|
+
<span className="font-semibold text-[var(--text-primary)]">{repo.name}</span>
|
|
482
|
+
<span className="text-[var(--accent)]">{repo.branch}</span>
|
|
483
|
+
<span className="ml-auto">{repo.changes.length}</span>
|
|
484
|
+
</div>
|
|
485
|
+
{repo.remote && (
|
|
486
|
+
<div className="text-[8px] truncate mt-0.5">{repo.remote.replace(/^https?:\/\//, '').replace(/\.git$/, '')}</div>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
{repo.changes.map(g => (
|
|
491
|
+
<div
|
|
492
|
+
key={g.path}
|
|
493
|
+
className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
|
|
494
|
+
diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
|
|
495
|
+
}`}
|
|
496
|
+
>
|
|
497
|
+
<span className={`text-[10px] font-mono w-4 shrink-0 ${
|
|
498
|
+
g.status.includes('M') ? 'text-yellow-500' :
|
|
499
|
+
g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
|
|
500
|
+
g.status.includes('D') ? 'text-red-500' :
|
|
501
|
+
'text-[var(--text-secondary)]'
|
|
502
|
+
}`}>
|
|
503
|
+
{g.status.includes('?') ? '+' : g.status[0]}
|
|
504
|
+
</span>
|
|
505
|
+
<button
|
|
506
|
+
onClick={() => openDiff(g.path)}
|
|
507
|
+
className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1 group relative"
|
|
508
|
+
title={`${g.path}${gitRepos.length > 1 ? ` (${repo.name} · ${repo.branch})` : ''}`}
|
|
509
|
+
>
|
|
510
|
+
{gitRepos.length > 1 ? g.path.replace(repo.name + '/', '') : g.path}
|
|
511
|
+
</button>
|
|
512
|
+
<button
|
|
513
|
+
onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
|
|
514
|
+
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"
|
|
515
|
+
title="Locate in file tree"
|
|
356
516
|
>
|
|
357
517
|
file
|
|
358
518
|
</button>
|
|
519
|
+
</div>
|
|
520
|
+
))}
|
|
359
521
|
</div>
|
|
360
522
|
))}
|
|
361
523
|
</div>
|
|
@@ -396,10 +558,53 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
396
558
|
)
|
|
397
559
|
) : (
|
|
398
560
|
tree.map(node => (
|
|
399
|
-
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} />
|
|
561
|
+
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
|
|
400
562
|
))
|
|
401
563
|
)}
|
|
402
564
|
</div>
|
|
565
|
+
|
|
566
|
+
{/* Git actions — bottom of sidebar */}
|
|
567
|
+
{currentDir && (gitChanges.length > 0 || gitRepos.length > 0) && (
|
|
568
|
+
<div className="border-t border-[var(--border)] shrink-0 p-2 space-y-1.5">
|
|
569
|
+
<div className="flex gap-1.5">
|
|
570
|
+
<input
|
|
571
|
+
value={commitMsg}
|
|
572
|
+
onChange={e => setCommitMsg(e.target.value)}
|
|
573
|
+
onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
574
|
+
placeholder="Commit message..."
|
|
575
|
+
className="flex-1 text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
576
|
+
/>
|
|
577
|
+
<button
|
|
578
|
+
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
579
|
+
disabled={gitLoading || !commitMsg.trim() || gitChanges.length === 0}
|
|
580
|
+
className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
581
|
+
>
|
|
582
|
+
Commit
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
<div className="flex gap-1.5">
|
|
586
|
+
<button
|
|
587
|
+
onClick={() => gitAction('push')}
|
|
588
|
+
disabled={gitLoading}
|
|
589
|
+
className="flex-1 text-[9px] py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
590
|
+
>
|
|
591
|
+
Push
|
|
592
|
+
</button>
|
|
593
|
+
<button
|
|
594
|
+
onClick={() => gitAction('pull')}
|
|
595
|
+
disabled={gitLoading}
|
|
596
|
+
className="flex-1 text-[9px] py-1 text-[var(--text-secondary)] border border-[var(--border)] rounded hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] disabled:opacity-50"
|
|
597
|
+
>
|
|
598
|
+
Pull
|
|
599
|
+
</button>
|
|
600
|
+
</div>
|
|
601
|
+
{gitResult && (
|
|
602
|
+
<div className={`text-[9px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
603
|
+
{gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
403
608
|
</aside>
|
|
404
609
|
)}
|
|
405
610
|
|
|
@@ -433,6 +638,38 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
|
|
|
433
638
|
<div className="flex-1 flex items-center justify-center">
|
|
434
639
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
435
640
|
</div>
|
|
641
|
+
) : fileWarning ? (
|
|
642
|
+
<div className="flex-1 flex items-center justify-center">
|
|
643
|
+
<div className="text-center space-y-3 p-6">
|
|
644
|
+
{fileWarning.type === 'binary' && (
|
|
645
|
+
<>
|
|
646
|
+
<div className="text-3xl">🚫</div>
|
|
647
|
+
<p className="text-sm text-[var(--text-primary)]">Binary file cannot be displayed</p>
|
|
648
|
+
<p className="text-xs text-[var(--text-secondary)]">{fileWarning.fileType?.toUpperCase()} · {fileWarning.label}</p>
|
|
649
|
+
</>
|
|
650
|
+
)}
|
|
651
|
+
{fileWarning.type === 'tooLarge' && (
|
|
652
|
+
<>
|
|
653
|
+
<div className="text-3xl">⚠️</div>
|
|
654
|
+
<p className="text-sm text-[var(--text-primary)]">File too large to display</p>
|
|
655
|
+
<p className="text-xs text-[var(--text-secondary)]">{fileWarning.label} — exceeds 2 MB limit</p>
|
|
656
|
+
</>
|
|
657
|
+
)}
|
|
658
|
+
{fileWarning.type === 'large' && (
|
|
659
|
+
<>
|
|
660
|
+
<div className="text-3xl">📄</div>
|
|
661
|
+
<p className="text-sm text-[var(--text-primary)]">Large file: {fileWarning.label}</p>
|
|
662
|
+
<p className="text-xs text-[var(--text-secondary)]">This file may slow down the browser</p>
|
|
663
|
+
<button
|
|
664
|
+
onClick={() => selectedFile && openFile(selectedFile, true)}
|
|
665
|
+
className="text-xs px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 mt-2"
|
|
666
|
+
>
|
|
667
|
+
Open anyway
|
|
668
|
+
</button>
|
|
669
|
+
</>
|
|
670
|
+
)}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
436
673
|
) : viewMode === 'diff' && diffContent ? (
|
|
437
674
|
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
438
675
|
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
package/components/Dashboard.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
import { signOut } from 'next-auth/react';
|
|
4
5
|
import TaskBoard from './TaskBoard';
|
|
5
6
|
import TaskDetail from './TaskDetail';
|
|
6
7
|
import SessionView from './SessionView';
|
|
@@ -13,6 +14,8 @@ import type { WebTerminalHandle } from './WebTerminal';
|
|
|
13
14
|
const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
14
15
|
const DocsViewer = lazy(() => import('./DocsViewer'));
|
|
15
16
|
const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
17
|
+
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
18
|
+
const PreviewPanel = lazy(() => import('./PreviewPanel'));
|
|
16
19
|
|
|
17
20
|
interface UsageSummary {
|
|
18
21
|
provider: string;
|
|
@@ -35,17 +38,30 @@ interface ProjectInfo {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export default function Dashboard({ user }: { user: any }) {
|
|
38
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs'>('terminal');
|
|
41
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview'>('terminal');
|
|
39
42
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
40
43
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
41
44
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
42
45
|
const [showSettings, setShowSettings] = useState(false);
|
|
43
|
-
const [showCode, setShowCode] = useState(true);
|
|
44
46
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
45
47
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
46
48
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
49
|
+
const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
|
|
47
50
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
48
51
|
|
|
52
|
+
// Heartbeat for online user tracking
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const ping = () => {
|
|
55
|
+
fetch('/api/online', { method: 'POST' })
|
|
56
|
+
.then(r => r.json())
|
|
57
|
+
.then(setOnlineCount)
|
|
58
|
+
.catch(() => {});
|
|
59
|
+
};
|
|
60
|
+
ping();
|
|
61
|
+
const id = setInterval(ping, 15_000); // every 15s
|
|
62
|
+
return () => clearInterval(id);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
49
65
|
const fetchData = useCallback(async () => {
|
|
50
66
|
const [tasksRes, statusRes, projectsRes] = await Promise.all([
|
|
51
67
|
fetch('/api/tasks'),
|
|
@@ -100,6 +116,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
100
116
|
>
|
|
101
117
|
Docs
|
|
102
118
|
</button>
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => setViewMode('projects')}
|
|
121
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
122
|
+
viewMode === 'projects'
|
|
123
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
124
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
Projects
|
|
128
|
+
</button>
|
|
103
129
|
<button
|
|
104
130
|
onClick={() => setViewMode('tasks')}
|
|
105
131
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -120,6 +146,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
120
146
|
>
|
|
121
147
|
Sessions
|
|
122
148
|
</button>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => setViewMode('preview')}
|
|
151
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
152
|
+
viewMode === 'preview'
|
|
153
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
154
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
155
|
+
}`}
|
|
156
|
+
>
|
|
157
|
+
Demo Preview
|
|
158
|
+
</button>
|
|
123
159
|
</div>
|
|
124
160
|
|
|
125
161
|
{viewMode === 'tasks' && (
|
|
@@ -138,6 +174,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
138
174
|
</button>
|
|
139
175
|
)}
|
|
140
176
|
<TunnelToggle />
|
|
177
|
+
{onlineCount.total > 0 && (
|
|
178
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
179
|
+
<span className="text-green-500">●</span>
|
|
180
|
+
{onlineCount.total}
|
|
181
|
+
{onlineCount.remote > 0 && (
|
|
182
|
+
<span className="text-[var(--accent)]">({onlineCount.remote} remote)</span>
|
|
183
|
+
)}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
141
186
|
<button
|
|
142
187
|
onClick={() => setShowSettings(true)}
|
|
143
188
|
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
@@ -145,6 +190,12 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
145
190
|
Settings
|
|
146
191
|
</button>
|
|
147
192
|
<span className="text-xs text-[var(--text-secondary)]">{user?.name || 'local'}</span>
|
|
193
|
+
<button
|
|
194
|
+
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
195
|
+
className="text-xs text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
196
|
+
>
|
|
197
|
+
Logout
|
|
198
|
+
</button>
|
|
148
199
|
</div>
|
|
149
200
|
</header>
|
|
150
201
|
|
|
@@ -252,6 +303,20 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
252
303
|
/>
|
|
253
304
|
) : null}
|
|
254
305
|
|
|
306
|
+
{/* Projects */}
|
|
307
|
+
{viewMode === 'projects' && (
|
|
308
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
309
|
+
<ProjectManager />
|
|
310
|
+
</Suspense>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{/* Preview */}
|
|
314
|
+
{viewMode === 'preview' && (
|
|
315
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
316
|
+
<PreviewPanel />
|
|
317
|
+
</Suspense>
|
|
318
|
+
)}
|
|
319
|
+
|
|
255
320
|
{/* Docs — always mounted to keep terminal session alive */}
|
|
256
321
|
<div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
|
|
257
322
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -262,7 +327,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
262
327
|
{/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
|
|
263
328
|
<div className={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
|
|
264
329
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
265
|
-
<CodeViewer terminalRef={terminalRef}
|
|
330
|
+
<CodeViewer terminalRef={terminalRef} />
|
|
266
331
|
</Suspense>
|
|
267
332
|
</div>
|
|
268
333
|
</div>
|