@aion0/forge 0.1.10 → 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.
- package/CLAUDE.md +1 -0
- package/app/api/code/route.ts +194 -0
- package/app/api/docs/route.ts +85 -0
- package/app/api/docs/sessions/route.ts +54 -0
- package/app/api/terminal-cwd/route.ts +19 -0
- package/components/CodeViewer.tsx +510 -0
- package/components/Dashboard.tsx +33 -14
- package/components/DocTerminal.tsx +168 -0
- package/components/DocsViewer.tsx +254 -0
- package/components/MarkdownContent.tsx +24 -8
- package/components/SettingsModal.tsx +55 -0
- package/components/WebTerminal.tsx +40 -6
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +403 -4
- package/lib/terminal-standalone.ts +35 -3
- package/next-env.d.ts +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
import type { WebTerminalHandle, WebTerminalProps } from './WebTerminal';
|
|
5
|
+
|
|
6
|
+
const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
7
|
+
|
|
8
|
+
interface FileNode {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
type: 'file' | 'dir';
|
|
12
|
+
children?: FileNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── File Tree ───────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type GitStatusMap = Map<string, string>; // path → status
|
|
18
|
+
type GitRepoMap = Map<string, { branch: string; remote: string }>; // dir name → repo info
|
|
19
|
+
|
|
20
|
+
function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
|
|
21
|
+
node: FileNode;
|
|
22
|
+
depth: number;
|
|
23
|
+
selected: string | null;
|
|
24
|
+
onSelect: (path: string) => void;
|
|
25
|
+
gitMap: GitStatusMap;
|
|
26
|
+
repoMap: GitRepoMap;
|
|
27
|
+
}) {
|
|
28
|
+
// Auto-expand if selected file is under this directory
|
|
29
|
+
const containsSelected = selected ? selected.startsWith(node.path + '/') : false;
|
|
30
|
+
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
|
31
|
+
const expanded = manualExpanded ?? (depth < 1 || containsSelected);
|
|
32
|
+
|
|
33
|
+
if (node.type === 'dir') {
|
|
34
|
+
const dirHasChanges = node.children?.some(c => hasGitChanges(c, gitMap));
|
|
35
|
+
const repo = repoMap.get(node.name);
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<button
|
|
39
|
+
onClick={() => setManualExpanded(v => v === null ? !expanded : !v)}
|
|
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"
|
|
41
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
42
|
+
title={repo ? `${repo.branch} · ${repo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}` : undefined}
|
|
43
|
+
>
|
|
44
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
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
|
+
)}
|
|
49
|
+
</button>
|
|
50
|
+
{expanded && node.children?.map(child => (
|
|
51
|
+
<TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} repoMap={repoMap} />
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isSelected = selected === node.path;
|
|
58
|
+
const gitStatus = gitMap.get(node.path);
|
|
59
|
+
const gitColor = gitStatus
|
|
60
|
+
? gitStatus.includes('M') ? 'text-yellow-400'
|
|
61
|
+
: gitStatus.includes('D') ? 'text-red-400'
|
|
62
|
+
: 'text-green-400' // A, ?, new
|
|
63
|
+
: '';
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => onSelect(node.path)}
|
|
68
|
+
className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
|
|
69
|
+
isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
70
|
+
: gitColor ? `hover:bg-[var(--bg-tertiary)] ${gitColor}`
|
|
71
|
+
: 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
72
|
+
}`}
|
|
73
|
+
style={{ paddingLeft: depth * 12 + 16 }}
|
|
74
|
+
title={node.path}
|
|
75
|
+
>
|
|
76
|
+
{node.name}
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasGitChanges(node: FileNode, gitMap: GitStatusMap): boolean {
|
|
82
|
+
if (node.type === 'file') return gitMap.has(node.path);
|
|
83
|
+
return node.children?.some(c => hasGitChanges(c, gitMap)) || false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function flattenTree(nodes: FileNode[]): FileNode[] {
|
|
87
|
+
const result: FileNode[] = [];
|
|
88
|
+
for (const node of nodes) {
|
|
89
|
+
if (node.type === 'file') result.push(node);
|
|
90
|
+
if (node.children) result.push(...flattenTree(node.children));
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const LANG_MAP: Record<string, string> = {
|
|
96
|
+
ts: 'TypeScript', tsx: 'TypeScript (JSX)', js: 'JavaScript', jsx: 'JavaScript (JSX)',
|
|
97
|
+
py: 'Python', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin',
|
|
98
|
+
css: 'CSS', scss: 'SCSS', html: 'HTML', json: 'JSON', yaml: 'YAML', yml: 'YAML',
|
|
99
|
+
md: 'Markdown', sh: 'Shell', sql: 'SQL', toml: 'TOML', xml: 'XML',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ─── Simple syntax highlighting ──────────────────────────
|
|
103
|
+
|
|
104
|
+
const KEYWORDS = new Set([
|
|
105
|
+
'import', 'export', 'from', 'const', 'let', 'var', 'function', 'return',
|
|
106
|
+
'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue',
|
|
107
|
+
'class', 'extends', 'new', 'this', 'super', 'typeof', 'instanceof',
|
|
108
|
+
'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
|
|
109
|
+
'default', 'interface', 'type', 'enum', 'implements', 'readonly',
|
|
110
|
+
'public', 'private', 'protected', 'static', 'abstract',
|
|
111
|
+
'true', 'false', 'null', 'undefined', 'void',
|
|
112
|
+
'def', 'self', 'None', 'True', 'False', 'class', 'lambda', 'with', 'as', 'in', 'not', 'and', 'or',
|
|
113
|
+
'func', 'package', 'struct', 'go', 'defer', 'select', 'chan', 'map', 'range',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
function highlightLine(line: string, lang: string): React.ReactNode {
|
|
117
|
+
if (!line) return ' ';
|
|
118
|
+
|
|
119
|
+
// Comments
|
|
120
|
+
const commentIdx = lang === 'py' ? line.indexOf('#') :
|
|
121
|
+
line.indexOf('//');
|
|
122
|
+
if (commentIdx === 0 || (commentIdx > 0 && /^\s*$/.test(line.slice(0, commentIdx)))) {
|
|
123
|
+
return <span className="text-gray-500 italic">{line}</span>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tokenize with regex
|
|
127
|
+
const parts: React.ReactNode[] = [];
|
|
128
|
+
let lastIdx = 0;
|
|
129
|
+
|
|
130
|
+
const regex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+\.?\d*\b)|(\/\/.*$|#.*$)|(\b[A-Z_][A-Z_0-9]+\b)|(\b\w+\b)/g;
|
|
131
|
+
let match;
|
|
132
|
+
|
|
133
|
+
while ((match = regex.exec(line)) !== null) {
|
|
134
|
+
// Text before match
|
|
135
|
+
if (match.index > lastIdx) {
|
|
136
|
+
parts.push(line.slice(lastIdx, match.index));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (match[1]) {
|
|
140
|
+
// String
|
|
141
|
+
parts.push(<span key={match.index} className="text-green-400">{match[0]}</span>);
|
|
142
|
+
} else if (match[2]) {
|
|
143
|
+
// Number
|
|
144
|
+
parts.push(<span key={match.index} className="text-orange-300">{match[0]}</span>);
|
|
145
|
+
} else if (match[3]) {
|
|
146
|
+
// Comment
|
|
147
|
+
parts.push(<span key={match.index} className="text-gray-500 italic">{match[0]}</span>);
|
|
148
|
+
} else if (match[4]) {
|
|
149
|
+
// CONSTANT
|
|
150
|
+
parts.push(<span key={match.index} className="text-cyan-300">{match[0]}</span>);
|
|
151
|
+
} else if (match[5] && KEYWORDS.has(match[5])) {
|
|
152
|
+
// Keyword
|
|
153
|
+
parts.push(<span key={match.index} className="text-purple-400">{match[0]}</span>);
|
|
154
|
+
} else {
|
|
155
|
+
parts.push(match[0]);
|
|
156
|
+
}
|
|
157
|
+
lastIdx = match.index + match[0].length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (lastIdx < line.length) {
|
|
161
|
+
parts.push(line.slice(lastIdx));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return parts.length > 0 ? <>{parts}</> : line;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Main Component ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObject<WebTerminalHandle | null> }) {
|
|
170
|
+
const [currentDir, setCurrentDir] = useState<string | null>(null);
|
|
171
|
+
const [dirName, setDirName] = useState('');
|
|
172
|
+
const [tree, setTree] = useState<FileNode[]>([]);
|
|
173
|
+
const [gitBranch, setGitBranch] = useState('');
|
|
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 }[] }[]>([]);
|
|
176
|
+
const [showGit, setShowGit] = useState(false);
|
|
177
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
178
|
+
const [content, setContent] = useState<string | null>(null);
|
|
179
|
+
const [language, setLanguage] = useState('');
|
|
180
|
+
const [loading, setLoading] = useState(false);
|
|
181
|
+
const [search, setSearch] = useState('');
|
|
182
|
+
const [diffContent, setDiffContent] = useState<string | null>(null);
|
|
183
|
+
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
184
|
+
const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
|
|
185
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
186
|
+
const [codeOpen, setCodeOpen] = useState(false);
|
|
187
|
+
|
|
188
|
+
const handleCodeOpenChange = useCallback((open: boolean) => {
|
|
189
|
+
setCodeOpen(open);
|
|
190
|
+
}, []);
|
|
191
|
+
const [terminalHeight, setTerminalHeight] = useState(300);
|
|
192
|
+
const [activeSession, setActiveSession] = useState<string | null>(null);
|
|
193
|
+
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
|
194
|
+
const lastDirRef = useRef<string | null>(null);
|
|
195
|
+
|
|
196
|
+
// When active terminal session changes, query its cwd
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!activeSession) return;
|
|
199
|
+
let cancelled = false;
|
|
200
|
+
|
|
201
|
+
const fetchCwd = async () => {
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(`/api/terminal-cwd?session=${encodeURIComponent(activeSession)}`);
|
|
204
|
+
const data = await res.json();
|
|
205
|
+
if (!cancelled && data.path && data.path !== lastDirRef.current) {
|
|
206
|
+
lastDirRef.current = data.path;
|
|
207
|
+
setCurrentDir(data.path);
|
|
208
|
+
setSelectedFile(null);
|
|
209
|
+
setContent(null);
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
fetchCwd();
|
|
215
|
+
// Poll cwd every 5s (user might cd to a different directory)
|
|
216
|
+
const timer = setInterval(fetchCwd, 5000);
|
|
217
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
218
|
+
}, [activeSession]);
|
|
219
|
+
|
|
220
|
+
// Fetch file tree when directory changes
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!currentDir) return;
|
|
223
|
+
const fetchDir = () => {
|
|
224
|
+
fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`)
|
|
225
|
+
.then(r => r.json())
|
|
226
|
+
.then(data => {
|
|
227
|
+
setTree(data.tree || []);
|
|
228
|
+
setDirName(data.dirName || currentDir.split('/').pop() || '');
|
|
229
|
+
setGitBranch(data.gitBranch || '');
|
|
230
|
+
setGitChanges(data.gitChanges || []);
|
|
231
|
+
setGitRepos(data.gitRepos || []);
|
|
232
|
+
})
|
|
233
|
+
.catch(() => setTree([]));
|
|
234
|
+
};
|
|
235
|
+
fetchDir();
|
|
236
|
+
}, [currentDir]);
|
|
237
|
+
|
|
238
|
+
// Build git status map for tree coloring
|
|
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 }]));
|
|
241
|
+
|
|
242
|
+
const openFile = useCallback(async (path: string) => {
|
|
243
|
+
if (!currentDir) return;
|
|
244
|
+
setSelectedFile(path);
|
|
245
|
+
setViewMode('file');
|
|
246
|
+
setLoading(true);
|
|
247
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}`);
|
|
248
|
+
const data = await res.json();
|
|
249
|
+
setContent(data.content || null);
|
|
250
|
+
setLanguage(data.language || '');
|
|
251
|
+
setLoading(false);
|
|
252
|
+
}, [currentDir]);
|
|
253
|
+
|
|
254
|
+
const openDiff = useCallback(async (path: string) => {
|
|
255
|
+
if (!currentDir) return;
|
|
256
|
+
setDiffFile(path);
|
|
257
|
+
setViewMode('diff');
|
|
258
|
+
setLoading(true);
|
|
259
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&diff=${encodeURIComponent(path)}`);
|
|
260
|
+
const data = await res.json();
|
|
261
|
+
setDiffContent(data.diff || null);
|
|
262
|
+
setLoading(false);
|
|
263
|
+
}, [currentDir]);
|
|
264
|
+
|
|
265
|
+
// Open file and auto-expand its parent dirs in tree
|
|
266
|
+
const locateFile = useCallback((path: string) => {
|
|
267
|
+
setSearch(''); // clear search so tree is visible
|
|
268
|
+
openFile(path);
|
|
269
|
+
}, [openFile]);
|
|
270
|
+
|
|
271
|
+
const allFiles = flattenTree(tree);
|
|
272
|
+
const filtered = search
|
|
273
|
+
? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()))
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
const onDragStart = (e: React.MouseEvent) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
dragRef.current = { startY: e.clientY, startH: terminalHeight };
|
|
279
|
+
const onMove = (ev: MouseEvent) => {
|
|
280
|
+
if (!dragRef.current) return;
|
|
281
|
+
const delta = ev.clientY - dragRef.current.startY;
|
|
282
|
+
setTerminalHeight(Math.max(100, Math.min(600, dragRef.current.startH + delta)));
|
|
283
|
+
};
|
|
284
|
+
const onUp = () => {
|
|
285
|
+
dragRef.current = null;
|
|
286
|
+
window.removeEventListener('mousemove', onMove);
|
|
287
|
+
window.removeEventListener('mouseup', onUp);
|
|
288
|
+
};
|
|
289
|
+
window.addEventListener('mousemove', onMove);
|
|
290
|
+
window.addEventListener('mouseup', onUp);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const handleActiveSession = useCallback((session: string | null) => {
|
|
294
|
+
setActiveSession(session);
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
299
|
+
{/* Terminal — top */}
|
|
300
|
+
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
301
|
+
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
302
|
+
<WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
|
|
303
|
+
</Suspense>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* Resize handle */}
|
|
307
|
+
{codeOpen && (
|
|
308
|
+
<div
|
|
309
|
+
onMouseDown={onDragStart}
|
|
310
|
+
className="h-1 bg-[var(--border)] cursor-row-resize hover:bg-[var(--accent)]/50 shrink-0"
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{/* File browser + code viewer — bottom */}
|
|
315
|
+
{codeOpen && <div className="flex-1 flex min-h-0">
|
|
316
|
+
{/* Sidebar */}
|
|
317
|
+
{sidebarOpen && (
|
|
318
|
+
<aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
319
|
+
{/* Directory name + git */}
|
|
320
|
+
<div className="px-3 py-2 border-b border-[var(--border)]">
|
|
321
|
+
<div className="flex items-center gap-2">
|
|
322
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate">
|
|
323
|
+
{dirName || 'No directory'}
|
|
324
|
+
</span>
|
|
325
|
+
{gitBranch && (
|
|
326
|
+
<span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0">
|
|
327
|
+
{gitBranch}
|
|
328
|
+
</span>
|
|
329
|
+
)}
|
|
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
|
+
)}
|
|
336
|
+
{gitChanges.length > 0 && (
|
|
337
|
+
<button
|
|
338
|
+
onClick={() => setShowGit(v => !v)}
|
|
339
|
+
className="text-[10px] text-yellow-500 hover:text-yellow-400 mt-1 block"
|
|
340
|
+
>
|
|
341
|
+
{gitChanges.length} changes {showGit ? '▾' : '▸'}
|
|
342
|
+
</button>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Git changes — grouped by repo */}
|
|
347
|
+
{showGit && gitChanges.length > 0 && (
|
|
348
|
+
<div className="border-b border-[var(--border)] max-h-48 overflow-y-auto">
|
|
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"
|
|
390
|
+
>
|
|
391
|
+
file
|
|
392
|
+
</button>
|
|
393
|
+
</div>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{/* Search */}
|
|
401
|
+
<div className="p-2 border-b border-[var(--border)]">
|
|
402
|
+
<input
|
|
403
|
+
type="text"
|
|
404
|
+
placeholder="Search..."
|
|
405
|
+
value={search}
|
|
406
|
+
onChange={e => setSearch(e.target.value)}
|
|
407
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{/* Tree */}
|
|
412
|
+
<div className="flex-1 overflow-y-auto p-1">
|
|
413
|
+
{!currentDir ? (
|
|
414
|
+
<div className="text-xs text-[var(--text-secondary)] p-2">Open a terminal to see files</div>
|
|
415
|
+
) : filtered ? (
|
|
416
|
+
filtered.length === 0 ? (
|
|
417
|
+
<div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
|
|
418
|
+
) : (
|
|
419
|
+
filtered.map(f => (
|
|
420
|
+
<button
|
|
421
|
+
key={f.path}
|
|
422
|
+
onClick={() => { openFile(f.path); setSearch(''); }}
|
|
423
|
+
className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
|
|
424
|
+
selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
425
|
+
}`}
|
|
426
|
+
title={f.path}
|
|
427
|
+
>
|
|
428
|
+
<span className="text-[var(--text-primary)]">{f.name}</span>
|
|
429
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
|
|
430
|
+
</button>
|
|
431
|
+
))
|
|
432
|
+
)
|
|
433
|
+
) : (
|
|
434
|
+
tree.map(node => (
|
|
435
|
+
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
|
|
436
|
+
))
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
</aside>
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{/* Code viewer */}
|
|
443
|
+
<main className="flex-1 flex flex-col min-w-0">
|
|
444
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
|
|
445
|
+
<button
|
|
446
|
+
onClick={() => setSidebarOpen(v => !v)}
|
|
447
|
+
className="text-[10px] px-1.5 py-0.5 text-gray-400 hover:text-white hover:bg-[var(--bg-tertiary)] rounded"
|
|
448
|
+
>
|
|
449
|
+
{sidebarOpen ? '◀' : '▶'}
|
|
450
|
+
</button>
|
|
451
|
+
{viewMode === 'diff' && diffFile ? (
|
|
452
|
+
<>
|
|
453
|
+
<span className="text-xs font-semibold text-yellow-400 truncate">{diffFile}</span>
|
|
454
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">diff</span>
|
|
455
|
+
</>
|
|
456
|
+
) : selectedFile ? (
|
|
457
|
+
<>
|
|
458
|
+
<span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile}</span>
|
|
459
|
+
{language && (
|
|
460
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">{LANG_MAP[language] || language}</span>
|
|
461
|
+
)}
|
|
462
|
+
</>
|
|
463
|
+
) : (
|
|
464
|
+
<span className="text-xs text-[var(--text-secondary)]">{dirName || 'Code'}</span>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
{loading ? (
|
|
469
|
+
<div className="flex-1 flex items-center justify-center">
|
|
470
|
+
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
471
|
+
</div>
|
|
472
|
+
) : viewMode === 'diff' && diffContent ? (
|
|
473
|
+
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
474
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
475
|
+
{diffContent.split('\n').map((line, i) => {
|
|
476
|
+
const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
|
|
477
|
+
: line.startsWith('-') ? 'text-red-400 bg-red-900/20'
|
|
478
|
+
: line.startsWith('@@') ? 'text-cyan-400'
|
|
479
|
+
: line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
|
|
480
|
+
: 'text-[var(--text-primary)]';
|
|
481
|
+
return (
|
|
482
|
+
<div key={i} className={`${color} px-2`}>
|
|
483
|
+
{line || ' '}
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
})}
|
|
487
|
+
</pre>
|
|
488
|
+
</div>
|
|
489
|
+
) : selectedFile && content !== null ? (
|
|
490
|
+
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
491
|
+
<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 }}>
|
|
492
|
+
{content.split('\n').map((line, i) => (
|
|
493
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
494
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
495
|
+
<span className="flex-1">{highlightLine(line, language)}</span>
|
|
496
|
+
</div>
|
|
497
|
+
))}
|
|
498
|
+
</pre>
|
|
499
|
+
</div>
|
|
500
|
+
) : (
|
|
501
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
502
|
+
<p className="text-xs">{currentDir ? 'Select a file to view' : 'Terminal will show files for its working directory'}</p>
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
</main>
|
|
506
|
+
</div>}
|
|
507
|
+
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -11,6 +11,8 @@ import type { Task } from '@/src/types';
|
|
|
11
11
|
import type { WebTerminalHandle } from './WebTerminal';
|
|
12
12
|
|
|
13
13
|
const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
14
|
+
const DocsViewer = lazy(() => import('./DocsViewer'));
|
|
15
|
+
const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
14
16
|
|
|
15
17
|
interface UsageSummary {
|
|
16
18
|
provider: string;
|
|
@@ -33,7 +35,7 @@ interface ProjectInfo {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export default function Dashboard({ user }: { user: any }) {
|
|
36
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal'>('
|
|
38
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs'>('terminal');
|
|
37
39
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
38
40
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
39
41
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
@@ -78,34 +80,44 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
78
80
|
{/* View mode toggle */}
|
|
79
81
|
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
80
82
|
<button
|
|
81
|
-
onClick={() => setViewMode('
|
|
83
|
+
onClick={() => setViewMode('terminal')}
|
|
82
84
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
83
|
-
viewMode === '
|
|
85
|
+
viewMode === 'terminal'
|
|
84
86
|
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
85
87
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
86
88
|
}`}
|
|
87
89
|
>
|
|
88
|
-
|
|
90
|
+
Vibe Coding
|
|
89
91
|
</button>
|
|
90
92
|
<button
|
|
91
|
-
onClick={() => setViewMode('
|
|
93
|
+
onClick={() => setViewMode('docs')}
|
|
92
94
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
93
|
-
viewMode === '
|
|
95
|
+
viewMode === 'docs'
|
|
94
96
|
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
95
97
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
96
98
|
}`}
|
|
97
99
|
>
|
|
98
|
-
|
|
100
|
+
Docs
|
|
99
101
|
</button>
|
|
100
102
|
<button
|
|
101
|
-
onClick={() => setViewMode('
|
|
103
|
+
onClick={() => setViewMode('tasks')}
|
|
102
104
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
103
|
-
viewMode === '
|
|
105
|
+
viewMode === 'tasks'
|
|
106
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
107
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
108
|
+
}`}
|
|
109
|
+
>
|
|
110
|
+
Tasks
|
|
111
|
+
</button>
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => setViewMode('sessions')}
|
|
114
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
115
|
+
viewMode === 'sessions'
|
|
104
116
|
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
105
117
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
106
118
|
}`}
|
|
107
119
|
>
|
|
108
|
-
|
|
120
|
+
Sessions
|
|
109
121
|
</button>
|
|
110
122
|
</div>
|
|
111
123
|
|
|
@@ -239,10 +251,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
239
251
|
/>
|
|
240
252
|
) : null}
|
|
241
253
|
|
|
242
|
-
{/*
|
|
243
|
-
<div className={`flex-1 min-h-0 ${viewMode === '
|
|
244
|
-
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading
|
|
245
|
-
<
|
|
254
|
+
{/* Docs — always mounted to keep terminal session alive */}
|
|
255
|
+
<div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
|
|
256
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
257
|
+
<DocsViewer />
|
|
258
|
+
</Suspense>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
|
|
262
|
+
<div className={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
|
|
263
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
264
|
+
<CodeViewer terminalRef={terminalRef} />
|
|
246
265
|
</Suspense>
|
|
247
266
|
</div>
|
|
248
267
|
</div>
|