@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.
@@ -1,42 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useCallback } from 'react';
4
-
5
- // ─── Syntax highlighting ─────────────────────────────────
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 GitInfo {
50
- branch: string;
51
- changes: { status: string; path: string }[];
52
- remote: string;
53
- ahead: number;
54
- behind: number;
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 [fileTree, setFileTree] = useState<any[]>([]);
71
- const [selectedFile, setSelectedFile] = useState<string | null>(null);
72
- const [fileContent, setFileContent] = useState<string | null>(null);
73
- const [fileLanguage, setFileLanguage] = useState('');
74
- const [fileLoading, setFileLoading] = useState(false);
75
- const [showLog, setShowLog] = useState(false);
76
- const [diffContent, setDiffContent] = useState<string | null>(null);
77
- const [diffFile, setDiffFile] = useState<string | null>(null);
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
- // Fetch git info when project selected
113
- const fetchGitInfo = useCallback(async (project: Project) => {
114
- if (!project.hasGit) { setGitInfo(null); return; }
115
- setLoading(true);
116
- try {
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
- const openDiff = useCallback(async (filePath: string) => {
135
- if (!selectedProject) return;
136
- setDiffFile(filePath);
137
- setDiffContent(null);
138
- setSelectedFile(null);
139
- setFileContent(null);
140
- try {
141
- const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&diff=${encodeURIComponent(filePath)}`);
142
- const data = await res.json();
143
- setDiffContent(data.diff || 'No changes');
144
- } catch { setDiffContent('(Failed to load diff)'); }
145
- }, [selectedProject]);
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
- const saveIssueConfig = async (projectPath: string, config: any) => {
246
- await fetch('/api/issue-scanner', {
247
- method: 'POST',
248
- headers: { 'Content-Type': 'application/json' },
249
- body: JSON.stringify({ action: 'save-config', projectPath, projectName: selectedProject?.name, ...config }),
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({ action: 'scan', projectPath }),
261
- });
262
- const data = await res.json();
263
- if (data.error) {
264
- alert(data.error);
265
- } else if (data.triggered > 0) {
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
- const injectToProject = async (templateId: string, projectPath: string) => {
305
- await fetch('/api/claude-templates', {
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: 'inject', templateId, projects: [projectPath] }),
309
- });
310
- fetchClaudeMd(projectPath);
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 removeFromProject = async (templateId: string, projectPath: string) => {
314
- if (!confirm(`Remove template from this project's CLAUDE.md?`)) return;
315
- await fetch('/api/claude-templates', {
316
- method: 'POST',
317
- headers: { 'Content-Type': 'application/json' },
318
- body: JSON.stringify({ action: 'remove', templateId, project: projectPath }),
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
- fetchClaudeMd(projectPath);
321
- };
322
-
323
- const saveClaudeMd = async (projectPath: string, content: string) => {
324
- await fetch('/api/claude-templates', {
325
- method: 'POST',
326
- headers: { 'Content-Type': 'application/json' },
327
- body: JSON.stringify({ action: 'save-claude-md', project: projectPath, content }),
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
- setClaudeMdContent(content);
330
- setClaudeEditing(false);
331
- fetchClaudeMd(projectPath);
332
- };
333
-
334
- const fetchProjectSkills = useCallback(async (projectPath: string) => {
335
- try {
336
- // Fetch registry skills (with update info)
337
- const [registryRes, localRes] = await Promise.all([
338
- fetch('/api/skills'),
339
- fetch(`/api/skills/local?action=scan&project=${encodeURIComponent(projectPath)}`),
340
- ]);
341
- const registryData = await registryRes.json();
342
- const localData = await localRes.json();
343
-
344
- // Registry items installed for this project
345
- const registryItems = (registryData.skills || []).filter((s: any) =>
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
- setProjectSkills([...merged.values()]);
393
- } catch { setProjectSkills([]); }
394
- }, []);
395
-
396
- const selectProject = useCallback((p: Project) => {
397
- setSelectedProject(p);
398
- setSelectedFile(null);
399
- setFileContent(null);
400
- setGitResult(null);
401
- setCommitMsg('');
402
- setIssueConfig(null);
403
- setIssueProcessed([]);
404
- fetchGitInfo(p);
405
- fetchTree(p);
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={() => selectProject(p)}
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
- selectedProject?.path === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
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
- {selectedProject ? (
543
- <>
544
- {/* Project header */}
545
- <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
546
- <div className="flex items-center gap-2">
547
- <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedProject.name}</span>
548
- {gitInfo?.branch && (
549
- <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
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
- {/* Git actions */}
1117
- <div className="px-3 py-2 flex items-center gap-2">
1118
- <input
1119
- value={commitMsg}
1120
- onChange={e => setCommitMsg(e.target.value)}
1121
- onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
1122
- placeholder="Commit message..."
1123
- 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)]"
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
- {/* Result */}
1149
- {gitResult && (
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
- }