@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.
@@ -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 (your numeric user 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}`, {