@aion0/forge 0.2.1 → 0.2.3
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/README.md +166 -175
- package/app/api/code/route.ts +31 -4
- 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/pipelines/[id]/route.ts +28 -0
- package/app/api/pipelines/route.ts +52 -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 +205 -4
- package/components/Dashboard.tsx +85 -1
- package/components/DocsViewer.tsx +64 -6
- package/components/NewTaskModal.tsx +7 -7
- package/components/PipelineEditor.tsx +399 -0
- package/components/PipelineView.tsx +435 -0
- 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 +109 -9
- package/lib/pipeline.ts +514 -0
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +2 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Project {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
root: string;
|
|
9
|
+
hasGit: boolean;
|
|
10
|
+
language: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface GitInfo {
|
|
14
|
+
branch: string;
|
|
15
|
+
changes: { status: string; path: string }[];
|
|
16
|
+
remote: string;
|
|
17
|
+
ahead: number;
|
|
18
|
+
behind: number;
|
|
19
|
+
lastCommit: string;
|
|
20
|
+
log: { hash: string; message: string; author: string; date: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ProjectManager() {
|
|
24
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
25
|
+
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
26
|
+
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [commitMsg, setCommitMsg] = useState('');
|
|
29
|
+
const [gitLoading, setGitLoading] = useState(false);
|
|
30
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
31
|
+
const [showClone, setShowClone] = useState(false);
|
|
32
|
+
const [cloneUrl, setCloneUrl] = useState('');
|
|
33
|
+
const [cloneLoading, setCloneLoading] = useState(false);
|
|
34
|
+
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
35
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
36
|
+
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
37
|
+
const [fileLanguage, setFileLanguage] = useState('');
|
|
38
|
+
const [fileLoading, setFileLoading] = useState(false);
|
|
39
|
+
const [showLog, setShowLog] = useState(false);
|
|
40
|
+
|
|
41
|
+
// Fetch projects
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
fetch('/api/projects').then(r => r.json())
|
|
44
|
+
.then((p: Project[]) => { if (Array.isArray(p)) setProjects(p); })
|
|
45
|
+
.catch(() => {});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Fetch git info when project selected
|
|
49
|
+
const fetchGitInfo = useCallback(async (project: Project) => {
|
|
50
|
+
if (!project.hasGit) { setGitInfo(null); return; }
|
|
51
|
+
setLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`/api/git?dir=${encodeURIComponent(project.path)}`);
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (!data.error) setGitInfo(data);
|
|
56
|
+
else setGitInfo(null);
|
|
57
|
+
} catch { setGitInfo(null); }
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Fetch file tree
|
|
62
|
+
const fetchTree = useCallback(async (project: Project) => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(project.path)}`);
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
setFileTree(data.tree || []);
|
|
67
|
+
} catch { setFileTree([]); }
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const selectProject = useCallback((p: Project) => {
|
|
71
|
+
setSelectedProject(p);
|
|
72
|
+
setSelectedFile(null);
|
|
73
|
+
setFileContent(null);
|
|
74
|
+
setGitResult(null);
|
|
75
|
+
setCommitMsg('');
|
|
76
|
+
fetchGitInfo(p);
|
|
77
|
+
fetchTree(p);
|
|
78
|
+
}, [fetchGitInfo, fetchTree]);
|
|
79
|
+
|
|
80
|
+
const openFile = useCallback(async (path: string) => {
|
|
81
|
+
if (!selectedProject) return;
|
|
82
|
+
setSelectedFile(path);
|
|
83
|
+
setFileLoading(true);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
setFileContent(data.content || null);
|
|
88
|
+
setFileLanguage(data.language || '');
|
|
89
|
+
} catch { setFileContent(null); }
|
|
90
|
+
setFileLoading(false);
|
|
91
|
+
}, [selectedProject]);
|
|
92
|
+
|
|
93
|
+
// Git operations
|
|
94
|
+
const gitAction = async (action: string, extra?: any) => {
|
|
95
|
+
if (!selectedProject) return;
|
|
96
|
+
setGitLoading(true);
|
|
97
|
+
setGitResult(null);
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch('/api/git', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ action, dir: selectedProject.path, ...extra }),
|
|
103
|
+
});
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
setGitResult(data);
|
|
106
|
+
if (data.ok) fetchGitInfo(selectedProject);
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
setGitResult({ error: e.message });
|
|
109
|
+
}
|
|
110
|
+
setGitLoading(false);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleClone = async () => {
|
|
114
|
+
if (!cloneUrl.trim()) return;
|
|
115
|
+
setCloneLoading(true);
|
|
116
|
+
setGitResult(null);
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch('/api/git', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ action: 'clone', repoUrl: cloneUrl.trim() }),
|
|
122
|
+
});
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
if (data.ok) {
|
|
125
|
+
setCloneUrl('');
|
|
126
|
+
setShowClone(false);
|
|
127
|
+
// Refresh project list
|
|
128
|
+
const pRes = await fetch('/api/projects');
|
|
129
|
+
const pData = await pRes.json();
|
|
130
|
+
if (Array.isArray(pData)) setProjects(pData);
|
|
131
|
+
setGitResult({ ok: true });
|
|
132
|
+
} else {
|
|
133
|
+
setGitResult(data);
|
|
134
|
+
}
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
setGitResult({ error: e.message });
|
|
137
|
+
}
|
|
138
|
+
setCloneLoading(false);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Group projects by root
|
|
142
|
+
const roots = [...new Set(projects.map(p => p.root))];
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex-1 flex min-h-0">
|
|
146
|
+
{/* Left sidebar — project list */}
|
|
147
|
+
<aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
148
|
+
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
|
149
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Projects</span>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => setShowClone(v => !v)}
|
|
152
|
+
className={`text-[10px] px-2 py-0.5 rounded ${showClone ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
153
|
+
>
|
|
154
|
+
+ Clone
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Clone form */}
|
|
159
|
+
{showClone && (
|
|
160
|
+
<div className="p-2 border-b border-[var(--border)] space-y-2">
|
|
161
|
+
<input
|
|
162
|
+
value={cloneUrl}
|
|
163
|
+
onChange={e => setCloneUrl(e.target.value)}
|
|
164
|
+
onKeyDown={e => e.key === 'Enter' && handleClone()}
|
|
165
|
+
placeholder="https://github.com/user/repo.git"
|
|
166
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
167
|
+
/>
|
|
168
|
+
<button
|
|
169
|
+
onClick={handleClone}
|
|
170
|
+
disabled={cloneLoading || !cloneUrl.trim()}
|
|
171
|
+
className="w-full text-[10px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
172
|
+
>
|
|
173
|
+
{cloneLoading ? 'Cloning...' : 'Clone Repository'}
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{/* Project list */}
|
|
179
|
+
<div className="flex-1 overflow-y-auto">
|
|
180
|
+
{roots.map(root => {
|
|
181
|
+
const rootName = root.split('/').pop() || root;
|
|
182
|
+
const rootProjects = projects.filter(p => p.root === root);
|
|
183
|
+
return (
|
|
184
|
+
<div key={root}>
|
|
185
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
|
|
186
|
+
{rootName}
|
|
187
|
+
</div>
|
|
188
|
+
{rootProjects.map(p => (
|
|
189
|
+
<button
|
|
190
|
+
key={p.path}
|
|
191
|
+
onClick={() => selectProject(p)}
|
|
192
|
+
className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
|
|
193
|
+
selectedProject?.path === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
194
|
+
}`}
|
|
195
|
+
>
|
|
196
|
+
<span className="truncate">{p.name}</span>
|
|
197
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
198
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
199
|
+
</button>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
</aside>
|
|
206
|
+
|
|
207
|
+
{/* Main area */}
|
|
208
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
209
|
+
{selectedProject ? (
|
|
210
|
+
<>
|
|
211
|
+
{/* Project header */}
|
|
212
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
213
|
+
<div className="flex items-center gap-2">
|
|
214
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{selectedProject.name}</span>
|
|
215
|
+
{gitInfo?.branch && (
|
|
216
|
+
<span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
|
|
217
|
+
)}
|
|
218
|
+
{gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
|
|
219
|
+
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => { fetchGitInfo(selectedProject); fetchTree(selectedProject); if (selectedFile) openFile(selectedFile); }}
|
|
222
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
|
|
223
|
+
title="Refresh"
|
|
224
|
+
>
|
|
225
|
+
↻
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
|
|
229
|
+
{selectedProject.path}
|
|
230
|
+
{gitInfo?.remote && (
|
|
231
|
+
<span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{gitInfo?.lastCommit && (
|
|
235
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
236
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => setShowLog(v => !v)}
|
|
239
|
+
className={`text-[9px] px-1.5 py-0.5 rounded shrink-0 ${showLog ? 'text-white bg-[var(--accent)]/30' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
240
|
+
>
|
|
241
|
+
History
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Git log */}
|
|
248
|
+
{showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
249
|
+
<div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
|
|
250
|
+
{gitInfo.log.map(c => (
|
|
251
|
+
<div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
|
|
252
|
+
<span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
|
|
253
|
+
<span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
|
|
254
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
|
|
255
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Content area */}
|
|
262
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
263
|
+
{/* File tree */}
|
|
264
|
+
<div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
|
|
265
|
+
{fileTree.map((node: any) => (
|
|
266
|
+
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* File content — independent scroll */}
|
|
271
|
+
<div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]">
|
|
272
|
+
{fileLoading ? (
|
|
273
|
+
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
274
|
+
) : selectedFile && fileContent !== null ? (
|
|
275
|
+
<>
|
|
276
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
277
|
+
<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 }}>
|
|
278
|
+
{fileContent.split('\n').map((line, i) => (
|
|
279
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
280
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
281
|
+
<span className="flex-1">{line || ' '}</span>
|
|
282
|
+
</div>
|
|
283
|
+
))}
|
|
284
|
+
</pre>
|
|
285
|
+
</>
|
|
286
|
+
) : (
|
|
287
|
+
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
288
|
+
Select a file to view
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Git panel — bottom */}
|
|
295
|
+
{gitInfo && (
|
|
296
|
+
<div className="border-t border-[var(--border)] shrink-0">
|
|
297
|
+
{/* Changes list */}
|
|
298
|
+
{gitInfo.changes.length > 0 && (
|
|
299
|
+
<div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
|
|
300
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
|
|
301
|
+
{gitInfo.changes.length} changes
|
|
302
|
+
</div>
|
|
303
|
+
{gitInfo.changes.map(g => (
|
|
304
|
+
<div key={g.path} className="px-3 py-0.5 text-xs flex items-center gap-2">
|
|
305
|
+
<span className={`text-[10px] font-mono w-4 ${
|
|
306
|
+
g.status.includes('M') ? 'text-yellow-500' :
|
|
307
|
+
g.status.includes('?') ? 'text-green-500' :
|
|
308
|
+
g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
|
|
309
|
+
}`}>
|
|
310
|
+
{g.status.includes('?') ? '+' : g.status[0]}
|
|
311
|
+
</span>
|
|
312
|
+
<span className="text-[var(--text-secondary)] truncate">{g.path}</span>
|
|
313
|
+
</div>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{/* Git actions */}
|
|
319
|
+
<div className="px-3 py-2 flex items-center gap-2">
|
|
320
|
+
<input
|
|
321
|
+
value={commitMsg}
|
|
322
|
+
onChange={e => setCommitMsg(e.target.value)}
|
|
323
|
+
onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
324
|
+
placeholder="Commit message..."
|
|
325
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
326
|
+
/>
|
|
327
|
+
<button
|
|
328
|
+
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
329
|
+
disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
|
|
330
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
331
|
+
>
|
|
332
|
+
Commit
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={() => gitAction('push')}
|
|
336
|
+
disabled={gitLoading || gitInfo.ahead === 0}
|
|
337
|
+
className="text-[10px] px-3 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50 shrink-0"
|
|
338
|
+
>
|
|
339
|
+
Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
|
|
340
|
+
</button>
|
|
341
|
+
<button
|
|
342
|
+
onClick={() => gitAction('pull')}
|
|
343
|
+
disabled={gitLoading}
|
|
344
|
+
className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
345
|
+
>
|
|
346
|
+
Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* Result */}
|
|
351
|
+
{gitResult && (
|
|
352
|
+
<div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
353
|
+
{gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</>
|
|
359
|
+
) : (
|
|
360
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
361
|
+
<div className="text-center space-y-2">
|
|
362
|
+
<p className="text-sm">Select a project</p>
|
|
363
|
+
<p className="text-xs">{projects.length} projects across {roots.length} directories</p>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Simple file tree node
|
|
373
|
+
function FileTreeNode({ node, depth, selected, onSelect }: {
|
|
374
|
+
node: { name: string; path: string; type: string; children?: any[] };
|
|
375
|
+
depth: number;
|
|
376
|
+
selected: string | null;
|
|
377
|
+
onSelect: (path: string) => void;
|
|
378
|
+
}) {
|
|
379
|
+
const [expanded, setExpanded] = useState(depth < 1);
|
|
380
|
+
|
|
381
|
+
if (node.type === 'dir') {
|
|
382
|
+
return (
|
|
383
|
+
<div>
|
|
384
|
+
<button
|
|
385
|
+
onClick={() => setExpanded(v => !v)}
|
|
386
|
+
className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
|
|
387
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
388
|
+
>
|
|
389
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
390
|
+
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
391
|
+
</button>
|
|
392
|
+
{expanded && node.children?.map((child: any) => (
|
|
393
|
+
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<button
|
|
401
|
+
onClick={() => onSelect(node.path)}
|
|
402
|
+
className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
|
|
403
|
+
selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
404
|
+
}`}
|
|
405
|
+
style={{ paddingLeft: depth * 12 + 16 }}
|
|
406
|
+
>
|
|
407
|
+
{node.name}
|
|
408
|
+
</button>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
@@ -216,9 +216,12 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
216
216
|
<input
|
|
217
217
|
value={settings.telegramChatId}
|
|
218
218
|
onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
|
|
219
|
-
placeholder="Chat ID (
|
|
219
|
+
placeholder="Chat ID (comma-separated for multiple)"
|
|
220
220
|
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
221
221
|
/>
|
|
222
|
+
<p className="text-[9px] text-[var(--text-secondary)]">
|
|
223
|
+
Allowed user IDs (whitelist). Multiple IDs separated by commas. Only these users can interact with the bot.
|
|
224
|
+
</p>
|
|
222
225
|
<div className="flex items-center gap-4">
|
|
223
226
|
<label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
|
|
224
227
|
<input
|
|
@@ -231,7 +231,7 @@ export default function TaskDetail({
|
|
|
231
231
|
|
|
232
232
|
{editing && (
|
|
233
233
|
<NewTaskModal
|
|
234
|
-
editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode }}
|
|
234
|
+
editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
|
|
235
235
|
onClose={() => setEditing(false)}
|
|
236
236
|
onCreate={async (data) => {
|
|
237
237
|
await fetch(`/api/tasks/${task.id}`, {
|
|
@@ -26,7 +26,7 @@ interface TmuxSession {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
type SplitNode =
|
|
29
|
-
| { type: 'terminal'; id: number; sessionName?: string }
|
|
29
|
+
| { type: 'terminal'; id: number; sessionName?: string; projectPath?: string }
|
|
30
30
|
| { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
|
|
31
31
|
|
|
32
32
|
interface TabState {
|
|
@@ -35,6 +35,7 @@ interface TabState {
|
|
|
35
35
|
tree: SplitNode;
|
|
36
36
|
ratios: Record<number, number>;
|
|
37
37
|
activeId: number;
|
|
38
|
+
projectPath?: string; // If set, auto-run claude --resume in this dir on session create
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
// ─── Layout persistence ──────────────────────────────────────
|
|
@@ -99,8 +100,8 @@ function initNextIdFromTabs(tabs: TabState[]) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function makeTerminal(sessionName?: string): SplitNode {
|
|
103
|
-
return { type: 'terminal', id: nextId++, sessionName };
|
|
103
|
+
function makeTerminal(sessionName?: string, projectPath?: string): SplitNode {
|
|
104
|
+
return { type: 'terminal', id: nextId++, sessionName, projectPath };
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
|
|
@@ -178,6 +179,10 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
178
179
|
const dragTabRef = useRef<number | null>(null);
|
|
179
180
|
const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
|
|
180
181
|
const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
|
|
182
|
+
const [showNewTabModal, setShowNewTabModal] = useState(false);
|
|
183
|
+
const [projectRoots, setProjectRoots] = useState<string[]>([]);
|
|
184
|
+
const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
|
|
185
|
+
const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
|
|
181
186
|
|
|
182
187
|
// Restore shared state from server after mount
|
|
183
188
|
useEffect(() => {
|
|
@@ -191,6 +196,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
191
196
|
}
|
|
192
197
|
setHydrated(true);
|
|
193
198
|
});
|
|
199
|
+
// Fetch projects and derive roots
|
|
200
|
+
fetch('/api/projects').then(r => r.json())
|
|
201
|
+
.then((p: { name: string; path: string; root: string }[]) => {
|
|
202
|
+
if (!Array.isArray(p)) return;
|
|
203
|
+
setAllProjects(p);
|
|
204
|
+
const roots = [...new Set(p.map(proj => proj.root))];
|
|
205
|
+
setProjectRoots(roots);
|
|
206
|
+
})
|
|
207
|
+
.catch(() => {});
|
|
194
208
|
}, []);
|
|
195
209
|
|
|
196
210
|
// Persist to server on changes (debounced, only after hydration)
|
|
@@ -248,10 +262,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
248
262
|
|
|
249
263
|
// ─── Tab operations ───────────────────────────────────
|
|
250
264
|
|
|
251
|
-
const addTab = useCallback(() => {
|
|
252
|
-
const tree = makeTerminal();
|
|
265
|
+
const addTab = useCallback((projectPath?: string) => {
|
|
266
|
+
const tree = makeTerminal(undefined, projectPath);
|
|
253
267
|
const tabNum = tabs.length + 1;
|
|
254
|
-
const
|
|
268
|
+
const label = projectPath ? projectPath.split('/').pop() || `Terminal ${tabNum}` : `Terminal ${tabNum}`;
|
|
269
|
+
const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree), projectPath };
|
|
255
270
|
setTabs(prev => [...prev, newTab]);
|
|
256
271
|
setActiveTabId(newTab.id);
|
|
257
272
|
}, [tabs.length]);
|
|
@@ -486,9 +501,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
486
501
|
</div>
|
|
487
502
|
))}
|
|
488
503
|
<button
|
|
489
|
-
onClick={
|
|
504
|
+
onClick={() => setShowNewTabModal(true)}
|
|
490
505
|
className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[#2a2a4a]"
|
|
491
|
-
title="New
|
|
506
|
+
title="New tab"
|
|
492
507
|
>
|
|
493
508
|
+
|
|
494
509
|
</button>
|
|
@@ -631,6 +646,75 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
631
646
|
</div>
|
|
632
647
|
)}
|
|
633
648
|
|
|
649
|
+
{/* New tab modal */}
|
|
650
|
+
{showNewTabModal && (
|
|
651
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}>
|
|
652
|
+
<div className="bg-[#1a1a2e] border border-[#2a2a4a] rounded-lg shadow-xl w-[350px] max-h-[70vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
653
|
+
<div className="px-4 py-3 border-b border-[#2a2a4a]">
|
|
654
|
+
<h3 className="text-sm font-semibold text-white">New Tab</h3>
|
|
655
|
+
</div>
|
|
656
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
657
|
+
{/* Plain terminal */}
|
|
658
|
+
<button
|
|
659
|
+
onClick={() => { addTab(); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
660
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
|
|
661
|
+
>
|
|
662
|
+
<span className="text-gray-500">▸</span> Terminal
|
|
663
|
+
</button>
|
|
664
|
+
|
|
665
|
+
{/* Project roots */}
|
|
666
|
+
{projectRoots.length > 0 && (
|
|
667
|
+
<div className="mt-2 pt-2 border-t border-[#2a2a4a]">
|
|
668
|
+
<div className="px-3 py-1 text-[9px] text-gray-500 uppercase">Claude in Project</div>
|
|
669
|
+
{projectRoots.map(root => {
|
|
670
|
+
const rootName = root.split('/').pop() || root;
|
|
671
|
+
const isExpanded = expandedRoot === root;
|
|
672
|
+
const rootProjects = allProjects.filter(p => p.root === root);
|
|
673
|
+
return (
|
|
674
|
+
<div key={root}>
|
|
675
|
+
<button
|
|
676
|
+
onClick={() => setExpandedRoot(isExpanded ? null : root)}
|
|
677
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
|
|
678
|
+
>
|
|
679
|
+
<span className="text-gray-500 text-[10px] w-3">{isExpanded ? '▾' : '▸'}</span>
|
|
680
|
+
<span>{rootName}</span>
|
|
681
|
+
<span className="text-[9px] text-gray-600 ml-auto">{rootProjects.length}</span>
|
|
682
|
+
</button>
|
|
683
|
+
{isExpanded && (
|
|
684
|
+
<div className="ml-4">
|
|
685
|
+
{rootProjects.map(p => (
|
|
686
|
+
<button
|
|
687
|
+
key={p.path}
|
|
688
|
+
onClick={() => { addTab(p.path); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
689
|
+
className="w-full text-left px-3 py-1.5 rounded hover:bg-[#2a2a4a] text-[11px] text-gray-300 flex items-center gap-2 truncate"
|
|
690
|
+
title={p.path}
|
|
691
|
+
>
|
|
692
|
+
<span className="text-gray-600 text-[10px]">↳</span> {p.name}
|
|
693
|
+
</button>
|
|
694
|
+
))}
|
|
695
|
+
{rootProjects.length === 0 && (
|
|
696
|
+
<div className="px-3 py-1.5 text-[10px] text-gray-600">No projects</div>
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
})}
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
</div>
|
|
706
|
+
<div className="px-4 py-2 border-t border-[#2a2a4a]">
|
|
707
|
+
<button
|
|
708
|
+
onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
709
|
+
className="w-full text-center text-[11px] text-gray-500 hover:text-gray-300 py-1"
|
|
710
|
+
>
|
|
711
|
+
Cancel
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
634
718
|
{/* Close confirmation dialog */}
|
|
635
719
|
{closeConfirm && (
|
|
636
720
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
|
|
@@ -706,7 +790,7 @@ function PaneRenderer({
|
|
|
706
790
|
if (node.type === 'terminal') {
|
|
707
791
|
return (
|
|
708
792
|
<div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
|
|
709
|
-
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} onSessionConnected={onSessionConnected} />
|
|
793
|
+
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} onSessionConnected={onSessionConnected} />
|
|
710
794
|
</div>
|
|
711
795
|
);
|
|
712
796
|
}
|
|
@@ -836,15 +920,19 @@ function DraggableSplit({
|
|
|
836
920
|
const MemoTerminalPane = memo(function TerminalPane({
|
|
837
921
|
id,
|
|
838
922
|
sessionName,
|
|
923
|
+
projectPath,
|
|
839
924
|
onSessionConnected,
|
|
840
925
|
}: {
|
|
841
926
|
id: number;
|
|
842
927
|
sessionName?: string;
|
|
928
|
+
projectPath?: string;
|
|
843
929
|
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
844
930
|
}) {
|
|
845
931
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
846
932
|
const sessionNameRef = useRef(sessionName);
|
|
847
933
|
sessionNameRef.current = sessionName;
|
|
934
|
+
const projectPathRef = useRef(projectPath);
|
|
935
|
+
projectPathRef.current = projectPath;
|
|
848
936
|
|
|
849
937
|
useEffect(() => {
|
|
850
938
|
if (!containerRef.current) return;
|
|
@@ -921,6 +1009,7 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
921
1009
|
let createRetries = 0;
|
|
922
1010
|
const MAX_CREATE_RETRIES = 2;
|
|
923
1011
|
let reconnectAttempts = 0;
|
|
1012
|
+
let isNewlyCreated = false;
|
|
924
1013
|
|
|
925
1014
|
function connect() {
|
|
926
1015
|
if (disposed) return;
|
|
@@ -942,6 +1031,7 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
942
1031
|
socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
|
|
943
1032
|
} else if (createRetries < MAX_CREATE_RETRIES) {
|
|
944
1033
|
createRetries++;
|
|
1034
|
+
isNewlyCreated = true;
|
|
945
1035
|
socket.send(JSON.stringify({ type: 'create', cols, rows }));
|
|
946
1036
|
} else {
|
|
947
1037
|
term.write('\r\n\x1b[91m[failed to create session — check server logs]\x1b[0m\r\n');
|
|
@@ -960,6 +1050,16 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
960
1050
|
createRetries = 0;
|
|
961
1051
|
reconnectAttempts = 0;
|
|
962
1052
|
onSessionConnected(id, msg.sessionName);
|
|
1053
|
+
// Auto-run claude --resume for project tabs on new session
|
|
1054
|
+
if (isNewlyCreated && projectPathRef.current) {
|
|
1055
|
+
isNewlyCreated = false;
|
|
1056
|
+
setTimeout(() => {
|
|
1057
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1058
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude --resume\n` }));
|
|
1059
|
+
}
|
|
1060
|
+
}, 300);
|
|
1061
|
+
}
|
|
1062
|
+
isNewlyCreated = false;
|
|
963
1063
|
// Force tmux to redraw by toggling size, then send reset
|
|
964
1064
|
setTimeout(() => {
|
|
965
1065
|
if (disposed || ws?.readyState !== WebSocket.OPEN) return;
|