@aion0/forge 0.3.4 → 0.3.6
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 +15 -0
- package/app/api/favorites/route.ts +26 -0
- package/app/api/git/route.ts +40 -35
- package/app/api/help/route.ts +78 -0
- package/app/api/skills/route.ts +0 -2
- package/app/api/tabs/route.ts +25 -0
- package/bin/forge-server.mjs +1 -1
- package/components/Dashboard.tsx +16 -0
- package/components/DocsViewer.tsx +160 -3
- package/components/HelpDialog.tsx +169 -0
- package/components/HelpTerminal.tsx +130 -0
- package/components/ProjectDetail.tsx +1115 -0
- package/components/ProjectManager.tsx +189 -1105
- package/components/TabBar.tsx +46 -0
- package/lib/help-docs/00-overview.md +34 -0
- package/lib/help-docs/01-settings.md +37 -0
- package/lib/help-docs/02-telegram.md +41 -0
- package/lib/help-docs/03-tunnel.md +31 -0
- package/lib/help-docs/04-tasks.md +52 -0
- package/lib/help-docs/05-pipelines.md +73 -0
- package/lib/help-docs/06-skills.md +43 -0
- package/lib/help-docs/07-projects.md +39 -0
- package/lib/help-docs/08-rules.md +53 -0
- package/lib/help-docs/09-issue-autofix.md +51 -0
- package/lib/help-docs/10-troubleshooting.md +82 -0
- package/lib/settings.ts +2 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +12 -0
|
@@ -1,42 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const KEYWORDS = new Set([
|
|
7
|
-
'import', 'export', 'from', 'const', 'let', 'var', 'function', 'return',
|
|
8
|
-
'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue',
|
|
9
|
-
'class', 'extends', 'new', 'this', 'super', 'typeof', 'instanceof',
|
|
10
|
-
'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
|
|
11
|
-
'default', 'interface', 'type', 'enum', 'implements', 'readonly',
|
|
12
|
-
'public', 'private', 'protected', 'static', 'abstract',
|
|
13
|
-
'true', 'false', 'null', 'undefined', 'void',
|
|
14
|
-
'def', 'self', 'None', 'True', 'False', 'lambda', 'with', 'as', 'in', 'not', 'and', 'or',
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
function highlightLine(line: string): React.ReactNode {
|
|
18
|
-
if (!line) return ' ';
|
|
19
|
-
const commentIdx = line.indexOf('//');
|
|
20
|
-
if (commentIdx === 0 || (commentIdx > 0 && /^\s*$/.test(line.slice(0, commentIdx)))) {
|
|
21
|
-
return <span className="text-gray-500 italic">{line}</span>;
|
|
22
|
-
}
|
|
23
|
-
const parts: React.ReactNode[] = [];
|
|
24
|
-
let lastIdx = 0;
|
|
25
|
-
const regex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+\.?\d*\b)|(\/\/.*$|#.*$)|(\b[A-Z_][A-Z_0-9]+\b)|(\b\w+\b)/g;
|
|
26
|
-
let match;
|
|
27
|
-
while ((match = regex.exec(line)) !== null) {
|
|
28
|
-
if (match.index > lastIdx) parts.push(line.slice(lastIdx, match.index));
|
|
29
|
-
if (match[1]) parts.push(<span key={match.index} className="text-green-400">{match[0]}</span>);
|
|
30
|
-
else if (match[2]) parts.push(<span key={match.index} className="text-orange-300">{match[0]}</span>);
|
|
31
|
-
else if (match[3]) parts.push(<span key={match.index} className="text-gray-500 italic">{match[0]}</span>);
|
|
32
|
-
else if (match[4]) parts.push(<span key={match.index} className="text-cyan-300">{match[0]}</span>);
|
|
33
|
-
else if (match[5] && KEYWORDS.has(match[5])) parts.push(<span key={match.index} className="text-purple-400">{match[0]}</span>);
|
|
34
|
-
else parts.push(match[0]);
|
|
35
|
-
lastIdx = match.index + match[0].length;
|
|
36
|
-
}
|
|
37
|
-
if (lastIdx < line.length) parts.push(line.slice(lastIdx));
|
|
38
|
-
return parts.length > 0 ? <>{parts}</> : line;
|
|
39
|
-
}
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import TabBar from './TabBar';
|
|
5
|
+
import ProjectDetail from './ProjectDetail';
|
|
40
6
|
|
|
41
7
|
interface Project {
|
|
42
8
|
name: string;
|
|
@@ -46,61 +12,30 @@ interface Project {
|
|
|
46
12
|
language: string | null;
|
|
47
13
|
}
|
|
48
14
|
|
|
49
|
-
interface
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
lastCommit: string;
|
|
56
|
-
log: { hash: string; message: string; author: string; date: string }[];
|
|
15
|
+
interface ProjectTab {
|
|
16
|
+
id: number;
|
|
17
|
+
projectPath: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
hasGit: boolean;
|
|
20
|
+
mountedAt: number; // timestamp for LRU eviction
|
|
57
21
|
}
|
|
58
22
|
|
|
23
|
+
const MAX_MOUNTED_TABS = 5;
|
|
24
|
+
function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
|
|
25
|
+
|
|
59
26
|
export default function ProjectManager() {
|
|
60
27
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
61
|
-
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
62
|
-
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
63
|
-
const [loading, setLoading] = useState(false);
|
|
64
|
-
const [commitMsg, setCommitMsg] = useState('');
|
|
65
|
-
const [gitLoading, setGitLoading] = useState(false);
|
|
66
|
-
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
67
28
|
const [showClone, setShowClone] = useState(false);
|
|
68
29
|
const [cloneUrl, setCloneUrl] = useState('');
|
|
69
30
|
const [cloneLoading, setCloneLoading] = useState(false);
|
|
70
|
-
const [
|
|
71
|
-
const [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const [
|
|
75
|
-
const [
|
|
76
|
-
const [
|
|
77
|
-
const
|
|
78
|
-
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
79
|
-
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
80
|
-
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'issues'>('code');
|
|
81
|
-
// Issue autofix state
|
|
82
|
-
const [issueConfig, setIssueConfig] = useState<{ enabled: boolean; interval: number; labels: string[]; baseBranch: string } | null>(null);
|
|
83
|
-
const [issueProcessed, setIssueProcessed] = useState<{ issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[]>([]);
|
|
84
|
-
const [issueScanning, setIssueScanning] = useState(false);
|
|
85
|
-
const [issueManualId, setIssueManualId] = useState('');
|
|
86
|
-
const [issueNextScan, setIssueNextScan] = useState<string | null>(null);
|
|
87
|
-
const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
|
|
88
|
-
const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
|
|
89
|
-
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
90
|
-
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
91
|
-
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
92
|
-
const [claudeInjectedIds, setClaudeInjectedIds] = useState<Set<string>>(new Set());
|
|
93
|
-
const [claudeEditing, setClaudeEditing] = useState(false);
|
|
94
|
-
const [claudeEditContent, setClaudeEditContent] = useState('');
|
|
95
|
-
const [claudeSelectedTemplate, setClaudeSelectedTemplate] = useState<string | null>(null);
|
|
96
|
-
const [expandedSkillItem, setExpandedSkillItem] = useState<string | null>(null);
|
|
97
|
-
const [skillItemFiles, setSkillItemFiles] = useState<{ path: string; size: number }[]>([]);
|
|
98
|
-
const [skillFileContent, setSkillFileContent] = useState('');
|
|
99
|
-
const [skillFileHash, setSkillFileHash] = useState('');
|
|
100
|
-
const [skillActivePath, setSkillActivePath] = useState('');
|
|
101
|
-
const [skillEditing, setSkillEditing] = useState(false);
|
|
102
|
-
const [skillEditContent, setSkillEditContent] = useState('');
|
|
103
|
-
const [skillSaving, setSkillSaving] = useState(false);
|
|
31
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
32
|
+
const [favorites, setFavorites] = useState<string[]>([]); // array of project paths
|
|
33
|
+
|
|
34
|
+
// Tab state
|
|
35
|
+
const [tabs, setTabs] = useState<ProjectTab[]>([]);
|
|
36
|
+
const [activeTabId, setActiveTabId] = useState(0);
|
|
37
|
+
const [tabsLoaded, setTabsLoaded] = useState(false);
|
|
38
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
104
39
|
|
|
105
40
|
// Fetch projects
|
|
106
41
|
useEffect(() => {
|
|
@@ -109,339 +44,125 @@ export default function ProjectManager() {
|
|
|
109
44
|
.catch(() => {});
|
|
110
45
|
}, []);
|
|
111
46
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const res = await fetch(`/api/git?dir=${encodeURIComponent(project.path)}`);
|
|
118
|
-
const data = await res.json();
|
|
119
|
-
if (!data.error) setGitInfo(data);
|
|
120
|
-
else setGitInfo(null);
|
|
121
|
-
} catch { setGitInfo(null); }
|
|
122
|
-
setLoading(false);
|
|
123
|
-
}, []);
|
|
124
|
-
|
|
125
|
-
// Fetch file tree
|
|
126
|
-
const fetchTree = useCallback(async (project: Project) => {
|
|
127
|
-
try {
|
|
128
|
-
const res = await fetch(`/api/code?dir=${encodeURIComponent(project.path)}`);
|
|
129
|
-
const data = await res.json();
|
|
130
|
-
setFileTree(data.tree || []);
|
|
131
|
-
} catch { setFileTree([]); }
|
|
47
|
+
// Load favorites from DB
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
fetch('/api/favorites').then(r => r.json())
|
|
50
|
+
.then(favs => { if (Array.isArray(favs)) setFavorites(favs); })
|
|
51
|
+
.catch(() => {});
|
|
132
52
|
}, []);
|
|
133
53
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const toggleSkillItem = useCallback(async (name: string, type: string, scope: string) => {
|
|
148
|
-
if (expandedSkillItem === name) {
|
|
149
|
-
setExpandedSkillItem(null);
|
|
150
|
-
setSkillEditing(false);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
setExpandedSkillItem(name);
|
|
154
|
-
setSkillItemFiles([]);
|
|
155
|
-
setSkillFileContent('');
|
|
156
|
-
setSkillActivePath('');
|
|
157
|
-
setSkillEditing(false);
|
|
158
|
-
// Global items: don't pass project path
|
|
159
|
-
const isGlobal = scope === 'global';
|
|
160
|
-
const project = isGlobal ? '' : (selectedProject?.path || '');
|
|
161
|
-
try {
|
|
162
|
-
const res = await fetch(`/api/skills/local?action=files&name=${encodeURIComponent(name)}&type=${type}&project=${encodeURIComponent(project)}`);
|
|
163
|
-
const data = await res.json();
|
|
164
|
-
setSkillItemFiles(data.files || []);
|
|
165
|
-
const firstMd = (data.files || []).find((f: any) => f.path.endsWith('.md'));
|
|
166
|
-
if (firstMd) loadSkillFile(name, type, firstMd.path, project);
|
|
167
|
-
} catch {}
|
|
168
|
-
}, [expandedSkillItem, selectedProject]);
|
|
169
|
-
|
|
170
|
-
const loadSkillFile = async (name: string, type: string, path: string, project: string) => {
|
|
171
|
-
setSkillActivePath(path);
|
|
172
|
-
setSkillEditing(false);
|
|
173
|
-
setSkillFileContent('Loading...');
|
|
174
|
-
try {
|
|
175
|
-
const res = await fetch(`/api/skills/local?action=read&name=${encodeURIComponent(name)}&type=${type}&path=${encodeURIComponent(path)}&project=${encodeURIComponent(project)}`);
|
|
176
|
-
const data = await res.json();
|
|
177
|
-
setSkillFileContent(data.content || '');
|
|
178
|
-
setSkillFileHash(data.hash || '');
|
|
179
|
-
} catch { setSkillFileContent('(Failed to load)'); }
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const saveSkillFile = async (name: string, type: string, path: string) => {
|
|
183
|
-
setSkillSaving(true);
|
|
184
|
-
const project = selectedProject?.path || '';
|
|
185
|
-
const res = await fetch('/api/skills/local', {
|
|
186
|
-
method: 'POST',
|
|
187
|
-
headers: { 'Content-Type': 'application/json' },
|
|
188
|
-
body: JSON.stringify({ name, type, project, path, content: skillEditContent, expectedHash: skillFileHash }),
|
|
189
|
-
});
|
|
190
|
-
const data = await res.json();
|
|
191
|
-
if (data.ok) {
|
|
192
|
-
setSkillFileContent(skillEditContent);
|
|
193
|
-
setSkillFileHash(data.hash);
|
|
194
|
-
setSkillEditing(false);
|
|
195
|
-
} else {
|
|
196
|
-
alert(data.error || 'Save failed');
|
|
197
|
-
}
|
|
198
|
-
setSkillSaving(false);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const handleUpdate = async (name: string) => {
|
|
202
|
-
// Check for local modifications first
|
|
203
|
-
const checkRes = await fetch('/api/skills', {
|
|
204
|
-
method: 'POST',
|
|
205
|
-
headers: { 'Content-Type': 'application/json' },
|
|
206
|
-
body: JSON.stringify({ action: 'check-modified', name }),
|
|
207
|
-
});
|
|
208
|
-
const checkData = await checkRes.json();
|
|
209
|
-
if (checkData.modified) {
|
|
210
|
-
if (!confirm('Local files have been modified. Overwrite with remote version?')) return;
|
|
211
|
-
}
|
|
212
|
-
// Re-install (update)
|
|
213
|
-
const target = selectedProject?.path || 'global';
|
|
214
|
-
await fetch('/api/skills', {
|
|
215
|
-
method: 'POST',
|
|
216
|
-
headers: { 'Content-Type': 'application/json' },
|
|
217
|
-
body: JSON.stringify({ action: 'install', name, target, force: true }),
|
|
218
|
-
});
|
|
219
|
-
if (selectedProject) fetchProjectSkills(selectedProject.path);
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const uninstallSkill = async (name: string, scope: string) => {
|
|
223
|
-
const target = scope === 'global' ? 'global' : (selectedProject?.path || '');
|
|
224
|
-
const label = scope === 'global' ? 'global' : selectedProject?.name || 'project';
|
|
225
|
-
if (!confirm(`Uninstall "${name}" from ${label}?`)) return;
|
|
226
|
-
await fetch('/api/skills', {
|
|
227
|
-
method: 'POST',
|
|
228
|
-
headers: { 'Content-Type': 'application/json' },
|
|
229
|
-
body: JSON.stringify({ action: 'uninstall', name, target }),
|
|
230
|
-
});
|
|
231
|
-
if (selectedProject) fetchProjectSkills(selectedProject.path);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const fetchIssueConfig = useCallback(async (projectPath: string) => {
|
|
235
|
-
try {
|
|
236
|
-
const res = await fetch(`/api/issue-scanner?project=${encodeURIComponent(projectPath)}`);
|
|
237
|
-
const data = await res.json();
|
|
238
|
-
setIssueConfig(data.config || { enabled: false, interval: 30, labels: [], baseBranch: '' });
|
|
239
|
-
setIssueProcessed(data.processed || []);
|
|
240
|
-
setIssueLastScan(data.lastScan || null);
|
|
241
|
-
setIssueNextScan(data.nextScan || null);
|
|
242
|
-
} catch {}
|
|
54
|
+
// Load tabs from API
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
fetch('/api/tabs?type=projects').then(r => r.json())
|
|
57
|
+
.then(data => {
|
|
58
|
+
if (Array.isArray(data.tabs) && data.tabs.length > 0) {
|
|
59
|
+
const maxId = Math.max(...data.tabs.map((t: any) => t.id || 0));
|
|
60
|
+
setTabs(data.tabs.map((t: any) => ({ ...t, mountedAt: Date.now() })));
|
|
61
|
+
setActiveTabId(data.activeTabId || data.tabs[0].id);
|
|
62
|
+
}
|
|
63
|
+
setTabsLoaded(true);
|
|
64
|
+
})
|
|
65
|
+
.catch(() => setTabsLoaded(true));
|
|
243
66
|
}, []);
|
|
244
67
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
fetchIssueConfig(projectPath);
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const scanNow = async (projectPath: string) => {
|
|
255
|
-
setIssueScanning(true);
|
|
256
|
-
try {
|
|
257
|
-
const res = await fetch('/api/issue-scanner', {
|
|
68
|
+
// Persist tabs (debounced)
|
|
69
|
+
const persistTabs = useCallback((newTabs: ProjectTab[], newActiveId: number) => {
|
|
70
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
71
|
+
saveTimerRef.current = setTimeout(() => {
|
|
72
|
+
fetch('/api/tabs?type=projects', {
|
|
258
73
|
method: 'POST',
|
|
259
74
|
headers: { 'Content-Type': 'application/json' },
|
|
260
|
-
body: JSON.stringify({
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
|
|
267
|
-
} else {
|
|
268
|
-
alert(`Scanned ${data.total} open issues — no new issues to process`);
|
|
269
|
-
}
|
|
270
|
-
await fetchIssueConfig(projectPath);
|
|
271
|
-
} catch (e) {
|
|
272
|
-
alert('Scan failed');
|
|
273
|
-
}
|
|
274
|
-
setIssueScanning(false);
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
const triggerIssue = async (projectPath: string, issueId: string) => {
|
|
278
|
-
await fetch('/api/issue-scanner', {
|
|
279
|
-
method: 'POST',
|
|
280
|
-
headers: { 'Content-Type': 'application/json' },
|
|
281
|
-
body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName: selectedProject?.name }),
|
|
282
|
-
});
|
|
283
|
-
setIssueManualId('');
|
|
284
|
-
fetchIssueConfig(projectPath);
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
const fetchClaudeMd = useCallback(async (projectPath: string) => {
|
|
288
|
-
try {
|
|
289
|
-
const [contentRes, statusRes, listRes] = await Promise.all([
|
|
290
|
-
fetch(`/api/claude-templates?action=read-claude-md&project=${encodeURIComponent(projectPath)}`),
|
|
291
|
-
fetch(`/api/claude-templates?action=status&project=${encodeURIComponent(projectPath)}`),
|
|
292
|
-
fetch('/api/claude-templates?action=list'),
|
|
293
|
-
]);
|
|
294
|
-
const contentData = await contentRes.json();
|
|
295
|
-
setClaudeMdContent(contentData.content || '');
|
|
296
|
-
setClaudeMdExists(contentData.exists || false);
|
|
297
|
-
const statusData = await statusRes.json();
|
|
298
|
-
setClaudeInjectedIds(new Set((statusData.status || []).filter((s: any) => s.injected).map((s: any) => s.id)));
|
|
299
|
-
const listData = await listRes.json();
|
|
300
|
-
setClaudeTemplates(listData.templates || []);
|
|
301
|
-
} catch {}
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
tabs: newTabs.map(t => ({ id: t.id, projectPath: t.projectPath, projectName: t.projectName, hasGit: t.hasGit })),
|
|
77
|
+
activeTabId: newActiveId,
|
|
78
|
+
}),
|
|
79
|
+
}).catch(() => {});
|
|
80
|
+
}, 500);
|
|
302
81
|
}, []);
|
|
303
82
|
|
|
304
|
-
|
|
305
|
-
|
|
83
|
+
// Save favorites to settings
|
|
84
|
+
const saveFavorite = useCallback((projectPath: string, add: boolean) => {
|
|
85
|
+
fetch('/api/favorites', {
|
|
306
86
|
method: 'POST',
|
|
307
87
|
headers: { 'Content-Type': 'application/json' },
|
|
308
|
-
body: JSON.stringify({ action: '
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
|
|
88
|
+
body: JSON.stringify({ action: add ? 'add' : 'remove', projectPath }),
|
|
89
|
+
}).then(r => r.json())
|
|
90
|
+
.then(favs => { if (Array.isArray(favs)) setFavorites(favs); })
|
|
91
|
+
.catch(() => {});
|
|
92
|
+
}, []);
|
|
312
93
|
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
94
|
+
const toggleFavorite = useCallback((projectPath: string) => {
|
|
95
|
+
const isFav = favorites.includes(projectPath);
|
|
96
|
+
// Optimistic update
|
|
97
|
+
setFavorites(prev => isFav ? prev.filter(p => p !== projectPath) : [...prev, projectPath]);
|
|
98
|
+
saveFavorite(projectPath, !isFav);
|
|
99
|
+
}, [favorites, saveFavorite]);
|
|
100
|
+
|
|
101
|
+
// Open a project in a tab
|
|
102
|
+
const openProjectTab = useCallback((p: Project) => {
|
|
103
|
+
setTabs(prev => {
|
|
104
|
+
const existing = prev.find(t => t.projectPath === p.path);
|
|
105
|
+
if (existing) {
|
|
106
|
+
// Activate existing tab
|
|
107
|
+
const updated = prev.map(t => t.id === existing.id ? { ...t, mountedAt: Date.now() } : t);
|
|
108
|
+
setActiveTabId(existing.id);
|
|
109
|
+
persistTabs(updated, existing.id);
|
|
110
|
+
return updated;
|
|
111
|
+
}
|
|
112
|
+
// Create new tab
|
|
113
|
+
const newTab: ProjectTab = {
|
|
114
|
+
id: genTabId(),
|
|
115
|
+
projectPath: p.path,
|
|
116
|
+
projectName: p.name,
|
|
117
|
+
hasGit: p.hasGit,
|
|
118
|
+
mountedAt: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
const updated = [...prev, newTab];
|
|
121
|
+
setActiveTabId(newTab.id);
|
|
122
|
+
persistTabs(updated, newTab.id);
|
|
123
|
+
return updated;
|
|
319
124
|
});
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
125
|
+
}, [persistTabs]);
|
|
126
|
+
|
|
127
|
+
const activateTab = useCallback((id: number) => {
|
|
128
|
+
setActiveTabId(id);
|
|
129
|
+
setTabs(prev => {
|
|
130
|
+
const updated = prev.map(t => t.id === id ? { ...t, mountedAt: Date.now() } : t);
|
|
131
|
+
persistTabs(updated, id);
|
|
132
|
+
return updated;
|
|
328
133
|
});
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
s.installedGlobal || (s.installedProjects || []).includes(projectPath)
|
|
347
|
-
).map((s: any) => ({
|
|
348
|
-
name: s.name,
|
|
349
|
-
displayName: s.displayName,
|
|
350
|
-
type: s.type || 'command',
|
|
351
|
-
version: s.version || '',
|
|
352
|
-
installedVersion: s.installedVersion || '',
|
|
353
|
-
hasUpdate: s.hasUpdate || false,
|
|
354
|
-
source: 'registry' as const,
|
|
355
|
-
scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
|
|
356
|
-
: s.installedGlobal ? 'global'
|
|
357
|
-
: 'project',
|
|
358
|
-
}));
|
|
359
|
-
|
|
360
|
-
// Local items not in registry
|
|
361
|
-
const registryNames = new Set(registryItems.map((s: any) => s.name));
|
|
362
|
-
const localItems = (localData.items || [])
|
|
363
|
-
.filter((item: any) => !registryNames.has(item.name))
|
|
364
|
-
.map((item: any) => ({
|
|
365
|
-
name: item.name,
|
|
366
|
-
displayName: item.name,
|
|
367
|
-
type: item.type,
|
|
368
|
-
version: '',
|
|
369
|
-
installedVersion: '',
|
|
370
|
-
hasUpdate: false,
|
|
371
|
-
source: 'local' as const,
|
|
372
|
-
scope: item.scope,
|
|
373
|
-
}));
|
|
374
|
-
|
|
375
|
-
// Merge: deduplicate by name, combine scopes
|
|
376
|
-
const merged = new Map<string, any>();
|
|
377
|
-
for (const item of [...registryItems, ...localItems]) {
|
|
378
|
-
const existing = merged.get(item.name);
|
|
379
|
-
if (existing) {
|
|
380
|
-
// Merge scopes
|
|
381
|
-
if (existing.scope !== item.scope) {
|
|
382
|
-
existing.scope = existing.scope.includes(item.scope) ? existing.scope : `${existing.scope} + ${item.scope}`;
|
|
383
|
-
}
|
|
384
|
-
// Registry takes priority over local
|
|
385
|
-
if (item.source === 'registry') {
|
|
386
|
-
Object.assign(existing, { ...item, scope: existing.scope });
|
|
387
|
-
}
|
|
388
|
-
} else {
|
|
389
|
-
merged.set(item.name, { ...item });
|
|
390
|
-
}
|
|
134
|
+
}, [persistTabs]);
|
|
135
|
+
|
|
136
|
+
const closeTab = useCallback((id: number) => {
|
|
137
|
+
setTabs(prev => {
|
|
138
|
+
const idx = prev.findIndex(t => t.id === id);
|
|
139
|
+
const updated = prev.filter(t => t.id !== id);
|
|
140
|
+
if (id === activeTabId && updated.length > 0) {
|
|
141
|
+
// Activate nearest tab
|
|
142
|
+
const newIdx = Math.min(idx, updated.length - 1);
|
|
143
|
+
const newActiveId = updated[newIdx].id;
|
|
144
|
+
setActiveTabId(newActiveId);
|
|
145
|
+
persistTabs(updated, newActiveId);
|
|
146
|
+
} else if (updated.length === 0) {
|
|
147
|
+
setActiveTabId(0);
|
|
148
|
+
persistTabs(updated, 0);
|
|
149
|
+
} else {
|
|
150
|
+
persistTabs(updated, activeTabId);
|
|
391
151
|
}
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
}, []);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
fetchProjectSkills(p.path);
|
|
407
|
-
if (projectTab === 'issues') fetchIssueConfig(p.path);
|
|
408
|
-
if (projectTab === 'claudemd') fetchClaudeMd(p.path);
|
|
409
|
-
}, [fetchGitInfo, fetchTree, fetchProjectSkills, fetchIssueConfig, fetchClaudeMd, projectTab]);
|
|
410
|
-
|
|
411
|
-
const openFile = useCallback(async (path: string) => {
|
|
412
|
-
if (!selectedProject) return;
|
|
413
|
-
setSelectedFile(path);
|
|
414
|
-
setDiffContent(null);
|
|
415
|
-
setDiffFile(null);
|
|
416
|
-
setFileLoading(true);
|
|
417
|
-
try {
|
|
418
|
-
const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
|
|
419
|
-
const data = await res.json();
|
|
420
|
-
setFileContent(data.content || null);
|
|
421
|
-
setFileLanguage(data.language || '');
|
|
422
|
-
} catch { setFileContent(null); }
|
|
423
|
-
setFileLoading(false);
|
|
424
|
-
}, [selectedProject]);
|
|
425
|
-
|
|
426
|
-
// Git operations
|
|
427
|
-
const gitAction = async (action: string, extra?: any) => {
|
|
428
|
-
if (!selectedProject) return;
|
|
429
|
-
setGitLoading(true);
|
|
430
|
-
setGitResult(null);
|
|
431
|
-
try {
|
|
432
|
-
const res = await fetch('/api/git', {
|
|
433
|
-
method: 'POST',
|
|
434
|
-
headers: { 'Content-Type': 'application/json' },
|
|
435
|
-
body: JSON.stringify({ action, dir: selectedProject.path, ...extra }),
|
|
436
|
-
});
|
|
437
|
-
const data = await res.json();
|
|
438
|
-
setGitResult(data);
|
|
439
|
-
if (data.ok) fetchGitInfo(selectedProject);
|
|
440
|
-
} catch (e: any) {
|
|
441
|
-
setGitResult({ error: e.message });
|
|
442
|
-
}
|
|
443
|
-
setGitLoading(false);
|
|
444
|
-
};
|
|
152
|
+
return updated;
|
|
153
|
+
});
|
|
154
|
+
}, [activeTabId, persistTabs]);
|
|
155
|
+
|
|
156
|
+
// Determine which tabs to mount (max 5, LRU eviction)
|
|
157
|
+
const mountedTabIds = new Set<number>();
|
|
158
|
+
// Always mount active tab
|
|
159
|
+
if (activeTabId) mountedTabIds.add(activeTabId);
|
|
160
|
+
// Add rest sorted by mountedAt desc
|
|
161
|
+
const sortedByRecency = [...tabs].sort((a, b) => b.mountedAt - a.mountedAt);
|
|
162
|
+
for (const t of sortedByRecency) {
|
|
163
|
+
if (mountedTabIds.size >= MAX_MOUNTED_TABS) break;
|
|
164
|
+
mountedTabIds.add(t.id);
|
|
165
|
+
}
|
|
445
166
|
|
|
446
167
|
const handleClone = async () => {
|
|
447
168
|
if (!cloneUrl.trim()) return;
|
|
@@ -457,7 +178,6 @@ export default function ProjectManager() {
|
|
|
457
178
|
if (data.ok) {
|
|
458
179
|
setCloneUrl('');
|
|
459
180
|
setShowClone(false);
|
|
460
|
-
// Refresh project list
|
|
461
181
|
const pRes = await fetch('/api/projects');
|
|
462
182
|
const pData = await pRes.json();
|
|
463
183
|
if (Array.isArray(pData)) setProjects(pData);
|
|
@@ -473,6 +193,7 @@ export default function ProjectManager() {
|
|
|
473
193
|
|
|
474
194
|
// Group projects by root
|
|
475
195
|
const roots = [...new Set(projects.map(p => p.root))];
|
|
196
|
+
const favoriteProjects = projects.filter(p => favorites.includes(p.path));
|
|
476
197
|
|
|
477
198
|
return (
|
|
478
199
|
<div className="flex-1 flex min-h-0">
|
|
@@ -510,6 +231,34 @@ export default function ProjectManager() {
|
|
|
510
231
|
|
|
511
232
|
{/* Project list */}
|
|
512
233
|
<div className="flex-1 overflow-y-auto">
|
|
234
|
+
{/* Favorites section */}
|
|
235
|
+
{favoriteProjects.length > 0 && (
|
|
236
|
+
<div>
|
|
237
|
+
<div className="px-3 py-1 text-[9px] text-[var(--yellow)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1">
|
|
238
|
+
<span>★</span> Favorites
|
|
239
|
+
</div>
|
|
240
|
+
{favoriteProjects.map(p => (
|
|
241
|
+
<button
|
|
242
|
+
key={`fav-${p.path}`}
|
|
243
|
+
onClick={() => openProjectTab(p)}
|
|
244
|
+
className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
|
|
245
|
+
tabs.find(t => t.id === activeTabId)?.projectPath === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
246
|
+
}`}
|
|
247
|
+
>
|
|
248
|
+
<span
|
|
249
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
250
|
+
className="text-[13px] text-[var(--yellow)] shrink-0 cursor-pointer leading-none"
|
|
251
|
+
title="Remove from favorites"
|
|
252
|
+
>★</span>
|
|
253
|
+
<span className="truncate">{p.name}</span>
|
|
254
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
255
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
256
|
+
</button>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* All projects by root */}
|
|
513
262
|
{roots.map(root => {
|
|
514
263
|
const rootName = root.split('/').pop() || root;
|
|
515
264
|
const rootProjects = projects.filter(p => p.root === root).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -521,11 +270,16 @@ export default function ProjectManager() {
|
|
|
521
270
|
{rootProjects.map(p => (
|
|
522
271
|
<button
|
|
523
272
|
key={p.path}
|
|
524
|
-
onClick={() =>
|
|
273
|
+
onClick={() => openProjectTab(p)}
|
|
525
274
|
className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
|
|
526
|
-
|
|
275
|
+
tabs.find(t => t.id === activeTabId)?.projectPath === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
527
276
|
}`}
|
|
528
277
|
>
|
|
278
|
+
<span
|
|
279
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
280
|
+
className={`text-[13px] shrink-0 cursor-pointer leading-none ${favorites.includes(p.path) ? 'text-[var(--yellow)]' : 'text-[var(--text-secondary)]/30 hover:text-[var(--yellow)]'}`}
|
|
281
|
+
title={favorites.includes(p.path) ? 'Remove from favorites' : 'Add to favorites'}
|
|
282
|
+
>{favorites.includes(p.path) ? '★' : '☆'}</span>
|
|
529
283
|
<span className="truncate">{p.name}</span>
|
|
530
284
|
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
531
285
|
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
@@ -539,621 +293,37 @@ export default function ProjectManager() {
|
|
|
539
293
|
|
|
540
294
|
{/* Main area */}
|
|
541
295
|
<div className="flex-1 flex flex-col min-w-0">
|
|
542
|
-
{
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
{gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
|
|
552
|
-
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
553
|
-
{/* Action buttons */}
|
|
554
|
-
<div className="flex items-center gap-1.5 ml-auto">
|
|
555
|
-
{/* Open Terminal */}
|
|
556
|
-
<button
|
|
557
|
-
onClick={() => {
|
|
558
|
-
if (!selectedProject) return;
|
|
559
|
-
// Navigate to terminal tab with this project
|
|
560
|
-
const event = new CustomEvent('forge:open-terminal', { detail: { projectPath: selectedProject.path, projectName: selectedProject.name } });
|
|
561
|
-
window.dispatchEvent(event);
|
|
562
|
-
}}
|
|
563
|
-
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
564
|
-
title="Open terminal with claude -c"
|
|
565
|
-
>
|
|
566
|
-
Terminal
|
|
567
|
-
</button>
|
|
568
|
-
<button
|
|
569
|
-
onClick={() => { fetchGitInfo(selectedProject); fetchTree(selectedProject); if (selectedFile) openFile(selectedFile); }}
|
|
570
|
-
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
571
|
-
title="Refresh"
|
|
572
|
-
>
|
|
573
|
-
↻
|
|
574
|
-
</button>
|
|
575
|
-
</div>
|
|
576
|
-
</div>
|
|
577
|
-
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
|
|
578
|
-
{selectedProject.path}
|
|
579
|
-
{gitInfo?.remote && (
|
|
580
|
-
<span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
581
|
-
)}
|
|
582
|
-
</div>
|
|
583
|
-
{/* Tab switcher */}
|
|
584
|
-
<div className="flex items-center gap-2 mt-1.5">
|
|
585
|
-
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
586
|
-
<button
|
|
587
|
-
onClick={() => setProjectTab('code')}
|
|
588
|
-
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
589
|
-
projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
590
|
-
}`}
|
|
591
|
-
>Code</button>
|
|
592
|
-
<button
|
|
593
|
-
onClick={() => setProjectTab('skills')}
|
|
594
|
-
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
595
|
-
projectTab === 'skills' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
596
|
-
}`}
|
|
597
|
-
>
|
|
598
|
-
Skills & Cmds
|
|
599
|
-
{projectSkills.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({projectSkills.length})</span>}
|
|
600
|
-
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[8px] text-[var(--yellow)]">!</span>}
|
|
601
|
-
</button>
|
|
602
|
-
<button
|
|
603
|
-
onClick={() => { setProjectTab('claudemd'); if (selectedProject) fetchClaudeMd(selectedProject.path); }}
|
|
604
|
-
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
605
|
-
projectTab === 'claudemd' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
606
|
-
}`}
|
|
607
|
-
>
|
|
608
|
-
CLAUDE.md
|
|
609
|
-
{claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
610
|
-
</button>
|
|
611
|
-
<button
|
|
612
|
-
onClick={() => { setProjectTab('issues'); if (selectedProject) fetchIssueConfig(selectedProject.path); }}
|
|
613
|
-
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
614
|
-
projectTab === 'issues' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
615
|
-
}`}
|
|
616
|
-
>
|
|
617
|
-
Issues
|
|
618
|
-
{issueConfig?.enabled && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
619
|
-
</button>
|
|
620
|
-
</div>
|
|
621
|
-
</div>
|
|
622
|
-
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
623
|
-
<div className="flex items-center gap-2 mt-0.5">
|
|
624
|
-
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
625
|
-
<button
|
|
626
|
-
onClick={() => setShowLog(v => !v)}
|
|
627
|
-
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'}`}
|
|
628
|
-
>
|
|
629
|
-
History
|
|
630
|
-
</button>
|
|
631
|
-
</div>
|
|
632
|
-
)}
|
|
633
|
-
</div>
|
|
634
|
-
|
|
635
|
-
{/* Git log */}
|
|
636
|
-
{projectTab === 'code' && showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
637
|
-
<div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
|
|
638
|
-
{gitInfo.log.map(c => (
|
|
639
|
-
<div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
|
|
640
|
-
<span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
|
|
641
|
-
<span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
|
|
642
|
-
<span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
|
|
643
|
-
<span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
|
|
644
|
-
</div>
|
|
645
|
-
))}
|
|
646
|
-
</div>
|
|
647
|
-
)}
|
|
648
|
-
|
|
649
|
-
{/* Code content area */}
|
|
650
|
-
{projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
|
|
651
|
-
{/* File tree */}
|
|
652
|
-
<div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
|
|
653
|
-
{fileTree.map((node: any) => (
|
|
654
|
-
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
|
|
655
|
-
))}
|
|
656
|
-
</div>
|
|
657
|
-
|
|
658
|
-
{/* File content — independent scroll, width:0 prevents content from expanding parent */}
|
|
659
|
-
<div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]" style={{ width: 0 }}>
|
|
660
|
-
{/* Diff view */}
|
|
661
|
-
{diffContent !== null && diffFile ? (
|
|
662
|
-
<>
|
|
663
|
-
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] sticky top-0 bg-[var(--bg-primary)] z-10 flex items-center gap-2">
|
|
664
|
-
<span className="text-[var(--yellow)]">DIFF</span>
|
|
665
|
-
<span className="text-[var(--text-secondary)]">{diffFile}</span>
|
|
666
|
-
<button onClick={() => { if (diffFile) openFile(diffFile); }} className="ml-auto text-[9px] text-[var(--accent)] hover:underline">Open Source</button>
|
|
667
|
-
</div>
|
|
668
|
-
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
669
|
-
{diffContent.split('\n').map((line, i) => {
|
|
670
|
-
const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
|
|
671
|
-
: line.startsWith('-') ? 'text-red-400 bg-red-900/20'
|
|
672
|
-
: line.startsWith('@@') ? 'text-cyan-400'
|
|
673
|
-
: line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
|
|
674
|
-
: 'text-[var(--text-primary)]';
|
|
675
|
-
return <div key={i} className={`${color} px-2`}>{line || ' '}</div>;
|
|
676
|
-
})}
|
|
677
|
-
</pre>
|
|
678
|
-
</>
|
|
679
|
-
) : fileLoading ? (
|
|
680
|
-
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
681
|
-
) : selectedFile && fileContent !== null ? (
|
|
682
|
-
<>
|
|
683
|
-
<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>
|
|
684
|
-
<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 }}>
|
|
685
|
-
{fileContent.split('\n').map((line, i) => (
|
|
686
|
-
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
687
|
-
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
688
|
-
<span className="flex-1">{highlightLine(line)}</span>
|
|
689
|
-
</div>
|
|
690
|
-
))}
|
|
691
|
-
</pre>
|
|
692
|
-
</>
|
|
693
|
-
) : (
|
|
694
|
-
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
695
|
-
Select a file to view
|
|
696
|
-
</div>
|
|
697
|
-
)}
|
|
698
|
-
</div>
|
|
699
|
-
</div>}
|
|
700
|
-
|
|
701
|
-
{/* Skills & Commands tab */}
|
|
702
|
-
{projectTab === 'skills' && (
|
|
703
|
-
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
704
|
-
{/* Left: skill/command tree */}
|
|
705
|
-
<div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
|
|
706
|
-
{projectSkills.length === 0 ? (
|
|
707
|
-
<p className="text-[9px] text-[var(--text-secondary)] p-2">No skills or commands installed</p>
|
|
708
|
-
) : (
|
|
709
|
-
projectSkills.map(s => (
|
|
710
|
-
<div key={`${s.name}-${s.scope}-${s.source}`}>
|
|
711
|
-
<button
|
|
712
|
-
onClick={() => toggleSkillItem(s.name, s.type, s.scope)}
|
|
713
|
-
className={`w-full text-left px-2 py-1 text-[10px] rounded flex items-center gap-1.5 group ${
|
|
714
|
-
expandedSkillItem === s.name ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
|
|
715
|
-
}`}
|
|
716
|
-
>
|
|
717
|
-
<span className="text-[8px] text-[var(--text-secondary)]">{expandedSkillItem === s.name ? '▾' : '▸'}</span>
|
|
718
|
-
<span className={`text-[7px] px-1 rounded font-medium shrink-0 ${
|
|
719
|
-
s.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
|
720
|
-
}`}>{s.type === 'skill' ? 'S' : 'C'}</span>
|
|
721
|
-
<span className="truncate flex-1">{s.name}</span>
|
|
722
|
-
<span className={`text-[7px] shrink-0 ${s.scope === 'global' ? 'text-green-400' : 'text-[var(--accent)]'}`}>{s.scope === 'global' ? 'G' : s.scope === 'project' ? 'P' : 'G+P'}</span>
|
|
723
|
-
{s.hasUpdate && <span className="text-[7px] text-[var(--yellow)] shrink-0">!</span>}
|
|
724
|
-
{s.source === 'local' && <span className="text-[7px] text-[var(--text-secondary)] shrink-0">local</span>}
|
|
725
|
-
{s.source === 'registry' && <span className="text-[7px] text-[var(--accent)] shrink-0">mkt</span>}
|
|
726
|
-
<span
|
|
727
|
-
onClick={(e) => { e.stopPropagation(); uninstallSkill(s.name, s.scope); }}
|
|
728
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0 opacity-0 group-hover:opacity-100 cursor-pointer"
|
|
729
|
-
>x</span>
|
|
730
|
-
</button>
|
|
731
|
-
{/* Expanded file list */}
|
|
732
|
-
{expandedSkillItem === s.name && skillItemFiles.length > 0 && (
|
|
733
|
-
<div className="ml-4">
|
|
734
|
-
{skillItemFiles.map(f => (
|
|
735
|
-
<button
|
|
736
|
-
key={f.path}
|
|
737
|
-
onClick={() => loadSkillFile(s.name, s.type, f.path, s.scope === 'global' ? '' : (selectedProject?.path || ''))}
|
|
738
|
-
className={`w-full text-left px-2 py-0.5 text-[9px] rounded truncate ${
|
|
739
|
-
skillActivePath === f.path ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
740
|
-
}`}
|
|
741
|
-
title={f.path}
|
|
742
|
-
>
|
|
743
|
-
{f.path.split('/').pop()}
|
|
744
|
-
</button>
|
|
745
|
-
))}
|
|
746
|
-
</div>
|
|
747
|
-
)}
|
|
748
|
-
</div>
|
|
749
|
-
))
|
|
750
|
-
)}
|
|
751
|
-
</div>
|
|
752
|
-
|
|
753
|
-
{/* Right: file content / editor */}
|
|
754
|
-
<div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
|
|
755
|
-
{skillActivePath ? (
|
|
756
|
-
<>
|
|
757
|
-
<div className="flex items-center gap-2 px-3 py-1 border-b border-[var(--border)] shrink-0">
|
|
758
|
-
<span className="text-[10px] text-[var(--text-secondary)] font-mono truncate flex-1">{skillActivePath}</span>
|
|
759
|
-
{expandedSkillItem && (() => {
|
|
760
|
-
const s = projectSkills.find(x => x.name === expandedSkillItem);
|
|
761
|
-
return s && (
|
|
762
|
-
<div className="flex items-center gap-2 shrink-0">
|
|
763
|
-
{s.version && <span className="text-[8px] text-[var(--text-secondary)] font-mono">v{s.installedVersion || s.version}</span>}
|
|
764
|
-
{s.hasUpdate && (
|
|
765
|
-
<button
|
|
766
|
-
onClick={() => handleUpdate(s.name)}
|
|
767
|
-
className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--yellow)]/20 text-[var(--yellow)] hover:bg-[var(--yellow)]/30"
|
|
768
|
-
>Update → v{s.version}</button>
|
|
769
|
-
)}
|
|
770
|
-
</div>
|
|
771
|
-
);
|
|
772
|
-
})()}
|
|
773
|
-
{!skillEditing ? (
|
|
774
|
-
<button
|
|
775
|
-
onClick={() => { setSkillEditing(true); setSkillEditContent(skillFileContent); }}
|
|
776
|
-
className="text-[9px] text-[var(--accent)] hover:underline shrink-0"
|
|
777
|
-
>Edit</button>
|
|
778
|
-
) : (
|
|
779
|
-
<div className="flex gap-1 shrink-0">
|
|
780
|
-
<button
|
|
781
|
-
onClick={() => { if (expandedSkillItem) saveSkillFile(expandedSkillItem, projectSkills.find(x => x.name === expandedSkillItem)?.type || 'command', skillActivePath); }}
|
|
782
|
-
disabled={skillSaving}
|
|
783
|
-
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
784
|
-
>{skillSaving ? '...' : 'Save'}</button>
|
|
785
|
-
<button
|
|
786
|
-
onClick={() => setSkillEditing(false)}
|
|
787
|
-
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
788
|
-
>Cancel</button>
|
|
789
|
-
</div>
|
|
790
|
-
)}
|
|
791
|
-
</div>
|
|
792
|
-
<div className="flex-1 overflow-auto">
|
|
793
|
-
{skillEditing ? (
|
|
794
|
-
<textarea
|
|
795
|
-
value={skillEditContent}
|
|
796
|
-
onChange={e => setSkillEditContent(e.target.value)}
|
|
797
|
-
className="w-full h-full p-3 text-[11px] font-mono bg-[var(--bg-primary)] text-[var(--text-primary)] border-none outline-none resize-none"
|
|
798
|
-
spellCheck={false}
|
|
799
|
-
/>
|
|
800
|
-
) : (
|
|
801
|
-
<pre className="p-3 text-[11px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
802
|
-
{skillFileContent}
|
|
803
|
-
</pre>
|
|
804
|
-
)}
|
|
805
|
-
</div>
|
|
806
|
-
</>
|
|
807
|
-
) : (
|
|
808
|
-
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
809
|
-
Select a skill or command to view
|
|
810
|
-
</div>
|
|
811
|
-
)}
|
|
812
|
-
</div>
|
|
813
|
-
</div>
|
|
814
|
-
)}
|
|
815
|
-
|
|
816
|
-
{/* CLAUDE.md tab */}
|
|
817
|
-
{projectTab === 'claudemd' && selectedProject && (
|
|
818
|
-
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
819
|
-
{/* Left: templates list */}
|
|
820
|
-
<div className="w-52 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
|
|
821
|
-
<button
|
|
822
|
-
onClick={() => { setClaudeSelectedTemplate(null); setClaudeEditing(false); }}
|
|
823
|
-
className={`w-full px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-left flex items-center gap-1 ${
|
|
824
|
-
!claudeSelectedTemplate && !claudeEditing ? 'text-[var(--accent)] bg-[var(--accent)]/5' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
|
|
825
|
-
}`}
|
|
826
|
-
>
|
|
827
|
-
<span className="font-mono">CLAUDE.md</span>
|
|
828
|
-
{claudeMdExists && <span className="text-[var(--green)] text-[8px]">•</span>}
|
|
829
|
-
</button>
|
|
830
|
-
<div className="px-2 py-1 border-b border-[var(--border)] text-[8px] text-[var(--text-secondary)] uppercase">Templates</div>
|
|
831
|
-
<div className="flex-1 overflow-y-auto">
|
|
832
|
-
{claudeTemplates.map(t => {
|
|
833
|
-
const injected = claudeInjectedIds.has(t.id);
|
|
834
|
-
const isSelected = claudeSelectedTemplate === t.id;
|
|
835
|
-
return (
|
|
836
|
-
<div
|
|
837
|
-
key={t.id}
|
|
838
|
-
className={`px-2 py-1.5 border-b border-[var(--border)]/30 cursor-pointer ${isSelected ? 'bg-[var(--accent)]/10' : 'hover:bg-[var(--bg-tertiary)]'}`}
|
|
839
|
-
onClick={() => setClaudeSelectedTemplate(isSelected ? null : t.id)}
|
|
840
|
-
>
|
|
841
|
-
<div className="flex items-center gap-1.5">
|
|
842
|
-
<span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
|
|
843
|
-
{t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
|
|
844
|
-
{injected ? (
|
|
845
|
-
<button
|
|
846
|
-
onClick={(e) => { e.stopPropagation(); removeFromProject(t.id, selectedProject.path); }}
|
|
847
|
-
className="text-[7px] px-1 rounded bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400"
|
|
848
|
-
title="Remove from CLAUDE.md"
|
|
849
|
-
>added</button>
|
|
850
|
-
) : (
|
|
851
|
-
<button
|
|
852
|
-
onClick={(e) => { e.stopPropagation(); injectToProject(t.id, selectedProject.path); }}
|
|
853
|
-
className="text-[7px] px-1 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
|
|
854
|
-
title="Add to CLAUDE.md"
|
|
855
|
-
>+ add</button>
|
|
856
|
-
)}
|
|
857
|
-
</div>
|
|
858
|
-
<p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
|
|
859
|
-
</div>
|
|
860
|
-
);
|
|
861
|
-
})}
|
|
862
|
-
</div>
|
|
863
|
-
</div>
|
|
864
|
-
|
|
865
|
-
{/* Right: CLAUDE.md content or template preview */}
|
|
866
|
-
<div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
|
|
867
|
-
{/* Header bar */}
|
|
868
|
-
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
869
|
-
{claudeSelectedTemplate ? (
|
|
870
|
-
<>
|
|
871
|
-
<span className="text-[10px] text-[var(--text-secondary)]">Preview:</span>
|
|
872
|
-
<span className="text-[10px] text-[var(--text-primary)] font-semibold">{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.name}</span>
|
|
873
|
-
<button
|
|
874
|
-
onClick={() => setClaudeSelectedTemplate(null)}
|
|
875
|
-
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
|
|
876
|
-
>Show CLAUDE.md</button>
|
|
877
|
-
</>
|
|
878
|
-
) : (
|
|
879
|
-
<>
|
|
880
|
-
<span className="text-[10px] text-[var(--text-primary)] font-mono">CLAUDE.md</span>
|
|
881
|
-
{!claudeMdExists && <span className="text-[8px] text-[var(--yellow)]">not created</span>}
|
|
882
|
-
<div className="flex items-center gap-1 ml-auto">
|
|
883
|
-
{!claudeEditing ? (
|
|
884
|
-
<button
|
|
885
|
-
onClick={() => { setClaudeEditing(true); setClaudeEditContent(claudeMdContent); }}
|
|
886
|
-
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
887
|
-
>Edit</button>
|
|
888
|
-
) : (
|
|
889
|
-
<>
|
|
890
|
-
<button
|
|
891
|
-
onClick={() => saveClaudeMd(selectedProject.path, claudeEditContent)}
|
|
892
|
-
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
893
|
-
>Save</button>
|
|
894
|
-
<button
|
|
895
|
-
onClick={() => setClaudeEditing(false)}
|
|
896
|
-
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
897
|
-
>Cancel</button>
|
|
898
|
-
</>
|
|
899
|
-
)}
|
|
900
|
-
</div>
|
|
901
|
-
</>
|
|
902
|
-
)}
|
|
903
|
-
</div>
|
|
904
|
-
|
|
905
|
-
{/* Content */}
|
|
906
|
-
<div className="flex-1 overflow-auto" style={{ width: 0, minWidth: '100%' }}>
|
|
907
|
-
{claudeSelectedTemplate ? (
|
|
908
|
-
<pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
909
|
-
{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.content || ''}
|
|
910
|
-
</pre>
|
|
911
|
-
) : claudeEditing ? (
|
|
912
|
-
<textarea
|
|
913
|
-
value={claudeEditContent}
|
|
914
|
-
onChange={e => setClaudeEditContent(e.target.value)}
|
|
915
|
-
className="w-full h-full p-3 text-[11px] font-mono bg-[var(--bg-primary)] text-[var(--text-primary)] border-none outline-none resize-none"
|
|
916
|
-
spellCheck={false}
|
|
917
|
-
/>
|
|
918
|
-
) : (
|
|
919
|
-
<pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
920
|
-
{claudeMdContent || '(Empty — add templates or edit directly)'}
|
|
921
|
-
</pre>
|
|
922
|
-
)}
|
|
923
|
-
</div>
|
|
924
|
-
</div>
|
|
925
|
-
</div>
|
|
926
|
-
)}
|
|
927
|
-
|
|
928
|
-
{/* Issues tab — auto-fix config + history */}
|
|
929
|
-
{projectTab === 'issues' && selectedProject && issueConfig && (
|
|
930
|
-
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
931
|
-
{/* Config */}
|
|
932
|
-
<div className="space-y-3">
|
|
933
|
-
<div className="flex items-center gap-3">
|
|
934
|
-
<label className="flex items-center gap-2 cursor-pointer">
|
|
935
|
-
<input
|
|
936
|
-
type="checkbox"
|
|
937
|
-
checked={issueConfig.enabled}
|
|
938
|
-
onChange={e => setIssueConfig({ ...issueConfig, enabled: e.target.checked })}
|
|
939
|
-
className="accent-[var(--accent)]"
|
|
940
|
-
/>
|
|
941
|
-
<span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
|
|
942
|
-
</label>
|
|
943
|
-
{issueConfig.enabled && (<>
|
|
944
|
-
<button
|
|
945
|
-
onClick={() => scanNow(selectedProject.path)}
|
|
946
|
-
disabled={issueScanning}
|
|
947
|
-
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
948
|
-
>
|
|
949
|
-
{issueScanning ? 'Scanning...' : 'Scan Now'}
|
|
950
|
-
</button>
|
|
951
|
-
{issueLastScan && (
|
|
952
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
953
|
-
Last: {new Date(issueLastScan).toLocaleTimeString()}
|
|
954
|
-
</span>
|
|
955
|
-
)}
|
|
956
|
-
{issueNextScan && (
|
|
957
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
958
|
-
Next: {new Date(issueNextScan).toLocaleTimeString()}
|
|
959
|
-
</span>
|
|
960
|
-
)}
|
|
961
|
-
</> )}
|
|
962
|
-
</div>
|
|
963
|
-
|
|
964
|
-
{issueConfig.enabled && (
|
|
965
|
-
<div className="grid grid-cols-2 gap-3">
|
|
966
|
-
<div>
|
|
967
|
-
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Scan Interval (minutes, 0=manual)</label>
|
|
968
|
-
<input
|
|
969
|
-
type="number"
|
|
970
|
-
value={issueConfig.interval}
|
|
971
|
-
onChange={e => setIssueConfig({ ...issueConfig, interval: parseInt(e.target.value) || 0 })}
|
|
972
|
-
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
973
|
-
/>
|
|
974
|
-
</div>
|
|
975
|
-
<div>
|
|
976
|
-
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Base Branch (empty=auto)</label>
|
|
977
|
-
<input
|
|
978
|
-
type="text"
|
|
979
|
-
value={issueConfig.baseBranch}
|
|
980
|
-
onChange={e => setIssueConfig({ ...issueConfig, baseBranch: e.target.value })}
|
|
981
|
-
placeholder="main"
|
|
982
|
-
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
983
|
-
/>
|
|
984
|
-
</div>
|
|
985
|
-
<div className="col-span-2">
|
|
986
|
-
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Labels Filter (comma-separated, empty=all)</label>
|
|
987
|
-
<input
|
|
988
|
-
type="text"
|
|
989
|
-
value={issueConfig.labels.join(', ')}
|
|
990
|
-
onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
|
991
|
-
placeholder="bug, fix"
|
|
992
|
-
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
993
|
-
/>
|
|
994
|
-
</div>
|
|
995
|
-
</div>
|
|
996
|
-
)}
|
|
997
|
-
<div className="mt-3">
|
|
998
|
-
<button
|
|
999
|
-
onClick={() => saveIssueConfig(selectedProject.path, issueConfig)}
|
|
1000
|
-
className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
1001
|
-
>Save Configuration</button>
|
|
1002
|
-
</div>
|
|
1003
|
-
</div>
|
|
1004
|
-
|
|
1005
|
-
{/* Manual trigger */}
|
|
1006
|
-
<div className="border-t border-[var(--border)] pt-3">
|
|
1007
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Manual Trigger</div>
|
|
1008
|
-
<div className="flex gap-2">
|
|
1009
|
-
<input
|
|
1010
|
-
type="text"
|
|
1011
|
-
value={issueManualId}
|
|
1012
|
-
onChange={e => setIssueManualId(e.target.value)}
|
|
1013
|
-
placeholder="Issue #"
|
|
1014
|
-
className="w-24 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
1015
|
-
/>
|
|
1016
|
-
<button
|
|
1017
|
-
onClick={() => issueManualId && triggerIssue(selectedProject.path, issueManualId)}
|
|
1018
|
-
disabled={!issueManualId}
|
|
1019
|
-
className="text-[9px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
1020
|
-
>Fix Issue</button>
|
|
1021
|
-
</div>
|
|
1022
|
-
</div>
|
|
1023
|
-
|
|
1024
|
-
{/* History */}
|
|
1025
|
-
{issueProcessed.length > 0 && (
|
|
1026
|
-
<div className="border-t border-[var(--border)] pt-3">
|
|
1027
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Processed Issues</div>
|
|
1028
|
-
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
1029
|
-
{issueProcessed.map(p => (
|
|
1030
|
-
<div key={p.issueNumber} className="border-b border-[var(--border)]/30 last:border-b-0">
|
|
1031
|
-
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px]">
|
|
1032
|
-
<span className="text-[var(--text-primary)] font-mono">#{p.issueNumber}</span>
|
|
1033
|
-
<span className={`text-[8px] px-1 rounded ${
|
|
1034
|
-
p.status === 'done' ? 'bg-green-500/10 text-green-400' :
|
|
1035
|
-
p.status === 'failed' ? 'bg-red-500/10 text-red-400' :
|
|
1036
|
-
'bg-yellow-500/10 text-yellow-400'
|
|
1037
|
-
}`}>{p.status}</span>
|
|
1038
|
-
{p.prNumber && <span className="text-[var(--accent)]">PR #{p.prNumber}</span>}
|
|
1039
|
-
{p.pipelineId && (
|
|
1040
|
-
<button
|
|
1041
|
-
onClick={() => {
|
|
1042
|
-
const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
|
|
1043
|
-
window.dispatchEvent(event);
|
|
1044
|
-
}}
|
|
1045
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
|
|
1046
|
-
title="View pipeline"
|
|
1047
|
-
>{p.pipelineId.slice(0, 8)}</button>
|
|
1048
|
-
)}
|
|
1049
|
-
<span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
|
|
1050
|
-
<div className="ml-auto flex gap-1">
|
|
1051
|
-
{(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
|
|
1052
|
-
<button
|
|
1053
|
-
onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
|
|
1054
|
-
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1055
|
-
>Retry</button>
|
|
1056
|
-
)}
|
|
1057
|
-
<button
|
|
1058
|
-
onClick={async () => {
|
|
1059
|
-
if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
|
|
1060
|
-
await fetch('/api/issue-scanner', {
|
|
1061
|
-
method: 'POST',
|
|
1062
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1063
|
-
body: JSON.stringify({ action: 'reset', projectPath: selectedProject!.path, issueId: p.issueNumber }),
|
|
1064
|
-
});
|
|
1065
|
-
fetchIssueConfig(selectedProject!.path);
|
|
1066
|
-
}}
|
|
1067
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1068
|
-
>Delete</button>
|
|
1069
|
-
</div>
|
|
1070
|
-
</div>
|
|
1071
|
-
</div>
|
|
1072
|
-
))}
|
|
1073
|
-
</div>
|
|
1074
|
-
</div>
|
|
1075
|
-
)}
|
|
1076
|
-
</div>
|
|
1077
|
-
)}
|
|
1078
|
-
|
|
1079
|
-
{/* Git panel — bottom (code tab only) */}
|
|
1080
|
-
{projectTab === 'code' && gitInfo && (
|
|
1081
|
-
<div className="border-t border-[var(--border)] shrink-0">
|
|
1082
|
-
{/* Changes list */}
|
|
1083
|
-
{gitInfo.changes.length > 0 && (
|
|
1084
|
-
<div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
|
|
1085
|
-
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
|
|
1086
|
-
{gitInfo.changes.length} changes
|
|
1087
|
-
</div>
|
|
1088
|
-
{gitInfo.changes.map(g => (
|
|
1089
|
-
<div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
|
|
1090
|
-
<span className={`text-[10px] font-mono w-4 shrink-0 ${
|
|
1091
|
-
g.status.includes('M') ? 'text-yellow-500' :
|
|
1092
|
-
g.status.includes('?') ? 'text-green-500' :
|
|
1093
|
-
g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
|
|
1094
|
-
}`}>
|
|
1095
|
-
{g.status.includes('?') ? '+' : g.status[0]}
|
|
1096
|
-
</span>
|
|
1097
|
-
<button
|
|
1098
|
-
onClick={() => openDiff(g.path)}
|
|
1099
|
-
className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
1100
|
-
title="View diff"
|
|
1101
|
-
>
|
|
1102
|
-
{g.path}
|
|
1103
|
-
</button>
|
|
1104
|
-
<button
|
|
1105
|
-
onClick={() => openFile(g.path)}
|
|
1106
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
|
|
1107
|
-
title="Open source file"
|
|
1108
|
-
>
|
|
1109
|
-
src
|
|
1110
|
-
</button>
|
|
1111
|
-
</div>
|
|
1112
|
-
))}
|
|
1113
|
-
</div>
|
|
1114
|
-
)}
|
|
296
|
+
{/* Tab bar */}
|
|
297
|
+
{tabs.length > 0 && (
|
|
298
|
+
<TabBar
|
|
299
|
+
tabs={tabs.map(t => ({ id: t.id, label: t.projectName }))}
|
|
300
|
+
activeId={activeTabId}
|
|
301
|
+
onActivate={activateTab}
|
|
302
|
+
onClose={closeTab}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
1115
305
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
306
|
+
{/* Tab content */}
|
|
307
|
+
{tabs.length > 0 ? (
|
|
308
|
+
<div className="flex-1 flex flex-col min-h-0 relative">
|
|
309
|
+
{tabs.map(tab => {
|
|
310
|
+
if (!mountedTabIds.has(tab.id)) return null;
|
|
311
|
+
const isActive = tab.id === activeTabId;
|
|
312
|
+
return (
|
|
313
|
+
<div
|
|
314
|
+
key={tab.id}
|
|
315
|
+
className="flex-1 flex flex-col min-h-0"
|
|
316
|
+
style={{ display: isActive ? 'flex' : 'none' }}
|
|
317
|
+
>
|
|
318
|
+
<ProjectDetail
|
|
319
|
+
projectPath={tab.projectPath}
|
|
320
|
+
projectName={tab.projectName}
|
|
321
|
+
hasGit={tab.hasGit}
|
|
1124
322
|
/>
|
|
1125
|
-
<button
|
|
1126
|
-
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
1127
|
-
disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
|
|
1128
|
-
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
1129
|
-
>
|
|
1130
|
-
Commit
|
|
1131
|
-
</button>
|
|
1132
|
-
<button
|
|
1133
|
-
onClick={() => gitAction('push')}
|
|
1134
|
-
disabled={gitLoading || gitInfo.ahead === 0}
|
|
1135
|
-
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"
|
|
1136
|
-
>
|
|
1137
|
-
Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
|
|
1138
|
-
</button>
|
|
1139
|
-
<button
|
|
1140
|
-
onClick={() => gitAction('pull')}
|
|
1141
|
-
disabled={gitLoading}
|
|
1142
|
-
className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
1143
|
-
>
|
|
1144
|
-
Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
|
|
1145
|
-
</button>
|
|
1146
323
|
</div>
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
<div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
1151
|
-
{gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
|
|
1152
|
-
</div>
|
|
1153
|
-
)}
|
|
1154
|
-
</div>
|
|
1155
|
-
)}
|
|
1156
|
-
</>
|
|
324
|
+
);
|
|
325
|
+
})}
|
|
326
|
+
</div>
|
|
1157
327
|
) : (
|
|
1158
328
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
1159
329
|
<div className="text-center space-y-2">
|
|
@@ -1163,92 +333,6 @@ export default function ProjectManager() {
|
|
|
1163
333
|
</div>
|
|
1164
334
|
)}
|
|
1165
335
|
</div>
|
|
1166
|
-
|
|
1167
|
-
{/* Retry modal */}
|
|
1168
|
-
{retryModal && (
|
|
1169
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
|
|
1170
|
-
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
1171
|
-
<div className="px-4 py-3 border-b border-[var(--border)]">
|
|
1172
|
-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
|
|
1173
|
-
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
|
|
1174
|
-
</div>
|
|
1175
|
-
<div className="p-4">
|
|
1176
|
-
<textarea
|
|
1177
|
-
value={retryModal.context}
|
|
1178
|
-
onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
|
|
1179
|
-
placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
|
|
1180
|
-
className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
1181
|
-
autoFocus
|
|
1182
|
-
/>
|
|
1183
|
-
</div>
|
|
1184
|
-
<div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
|
|
1185
|
-
<button
|
|
1186
|
-
onClick={() => setRetryModal(null)}
|
|
1187
|
-
className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
1188
|
-
>Cancel</button>
|
|
1189
|
-
<button
|
|
1190
|
-
onClick={async () => {
|
|
1191
|
-
if (!selectedProject) return;
|
|
1192
|
-
await fetch('/api/issue-scanner', {
|
|
1193
|
-
method: 'POST',
|
|
1194
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1195
|
-
body: JSON.stringify({
|
|
1196
|
-
action: 'retry',
|
|
1197
|
-
projectPath: selectedProject.path,
|
|
1198
|
-
projectName: selectedProject.name,
|
|
1199
|
-
issueId: retryModal.issueNumber,
|
|
1200
|
-
context: retryModal.context,
|
|
1201
|
-
}),
|
|
1202
|
-
});
|
|
1203
|
-
setRetryModal(null);
|
|
1204
|
-
fetchIssueConfig(selectedProject.path);
|
|
1205
|
-
}}
|
|
1206
|
-
className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
1207
|
-
>Retry</button>
|
|
1208
|
-
</div>
|
|
1209
|
-
</div>
|
|
1210
|
-
</div>
|
|
1211
|
-
)}
|
|
1212
336
|
</div>
|
|
1213
337
|
);
|
|
1214
338
|
}
|
|
1215
|
-
|
|
1216
|
-
// Simple file tree node
|
|
1217
|
-
function FileTreeNode({ node, depth, selected, onSelect }: {
|
|
1218
|
-
node: { name: string; path: string; type: string; children?: any[] };
|
|
1219
|
-
depth: number;
|
|
1220
|
-
selected: string | null;
|
|
1221
|
-
onSelect: (path: string) => void;
|
|
1222
|
-
}) {
|
|
1223
|
-
const [expanded, setExpanded] = useState(depth < 1);
|
|
1224
|
-
|
|
1225
|
-
if (node.type === 'dir') {
|
|
1226
|
-
return (
|
|
1227
|
-
<div>
|
|
1228
|
-
<button
|
|
1229
|
-
onClick={() => setExpanded(v => !v)}
|
|
1230
|
-
className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
|
|
1231
|
-
style={{ paddingLeft: depth * 12 + 4 }}
|
|
1232
|
-
>
|
|
1233
|
-
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
1234
|
-
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
1235
|
-
</button>
|
|
1236
|
-
{expanded && node.children?.map((child: any) => (
|
|
1237
|
-
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
1238
|
-
))}
|
|
1239
|
-
</div>
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
return (
|
|
1244
|
-
<button
|
|
1245
|
-
onClick={() => onSelect(node.path)}
|
|
1246
|
-
className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
|
|
1247
|
-
selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
1248
|
-
}`}
|
|
1249
|
-
style={{ paddingLeft: depth * 12 + 16 }}
|
|
1250
|
-
>
|
|
1251
|
-
{node.name}
|
|
1252
|
-
</button>
|
|
1253
|
-
);
|
|
1254
|
-
}
|