@aion0/forge 0.3.3 → 0.3.5

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,53 +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'>('code');
81
- const [claudeMdContent, setClaudeMdContent] = useState('');
82
- const [claudeMdExists, setClaudeMdExists] = useState(false);
83
- const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
84
- const [claudeInjectedIds, setClaudeInjectedIds] = useState<Set<string>>(new Set());
85
- const [claudeEditing, setClaudeEditing] = useState(false);
86
- const [claudeEditContent, setClaudeEditContent] = useState('');
87
- const [claudeSelectedTemplate, setClaudeSelectedTemplate] = useState<string | null>(null);
88
- const [expandedSkillItem, setExpandedSkillItem] = useState<string | null>(null);
89
- const [skillItemFiles, setSkillItemFiles] = useState<{ path: string; size: number }[]>([]);
90
- const [skillFileContent, setSkillFileContent] = useState('');
91
- const [skillFileHash, setSkillFileHash] = useState('');
92
- const [skillActivePath, setSkillActivePath] = useState('');
93
- const [skillEditing, setSkillEditing] = useState(false);
94
- const [skillEditContent, setSkillEditContent] = useState('');
95
- 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);
96
39
 
97
40
  // Fetch projects
98
41
  useEffect(() => {
@@ -101,282 +44,125 @@ export default function ProjectManager() {
101
44
  .catch(() => {});
102
45
  }, []);
103
46
 
104
- // Fetch git info when project selected
105
- const fetchGitInfo = useCallback(async (project: Project) => {
106
- if (!project.hasGit) { setGitInfo(null); return; }
107
- setLoading(true);
108
- try {
109
- const res = await fetch(`/api/git?dir=${encodeURIComponent(project.path)}`);
110
- const data = await res.json();
111
- if (!data.error) setGitInfo(data);
112
- else setGitInfo(null);
113
- } catch { setGitInfo(null); }
114
- setLoading(false);
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(() => {});
115
52
  }, []);
116
53
 
117
- // Fetch file tree
118
- const fetchTree = useCallback(async (project: Project) => {
119
- try {
120
- const res = await fetch(`/api/code?dir=${encodeURIComponent(project.path)}`);
121
- const data = await res.json();
122
- setFileTree(data.tree || []);
123
- } catch { setFileTree([]); }
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));
124
66
  }, []);
125
67
 
126
- const openDiff = useCallback(async (filePath: string) => {
127
- if (!selectedProject) return;
128
- setDiffFile(filePath);
129
- setDiffContent(null);
130
- setSelectedFile(null);
131
- setFileContent(null);
132
- try {
133
- const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&diff=${encodeURIComponent(filePath)}`);
134
- const data = await res.json();
135
- setDiffContent(data.diff || 'No changes');
136
- } catch { setDiffContent('(Failed to load diff)'); }
137
- }, [selectedProject]);
138
-
139
- const toggleSkillItem = useCallback(async (name: string, type: string, scope: string) => {
140
- if (expandedSkillItem === name) {
141
- setExpandedSkillItem(null);
142
- setSkillEditing(false);
143
- return;
144
- }
145
- setExpandedSkillItem(name);
146
- setSkillItemFiles([]);
147
- setSkillFileContent('');
148
- setSkillActivePath('');
149
- setSkillEditing(false);
150
- // Global items: don't pass project path
151
- const isGlobal = scope === 'global';
152
- const project = isGlobal ? '' : (selectedProject?.path || '');
153
- try {
154
- const res = await fetch(`/api/skills/local?action=files&name=${encodeURIComponent(name)}&type=${type}&project=${encodeURIComponent(project)}`);
155
- const data = await res.json();
156
- setSkillItemFiles(data.files || []);
157
- const firstMd = (data.files || []).find((f: any) => f.path.endsWith('.md'));
158
- if (firstMd) loadSkillFile(name, type, firstMd.path, project);
159
- } catch {}
160
- }, [expandedSkillItem, selectedProject]);
161
-
162
- const loadSkillFile = async (name: string, type: string, path: string, project: string) => {
163
- setSkillActivePath(path);
164
- setSkillEditing(false);
165
- setSkillFileContent('Loading...');
166
- try {
167
- const res = await fetch(`/api/skills/local?action=read&name=${encodeURIComponent(name)}&type=${type}&path=${encodeURIComponent(path)}&project=${encodeURIComponent(project)}`);
168
- const data = await res.json();
169
- setSkillFileContent(data.content || '');
170
- setSkillFileHash(data.hash || '');
171
- } catch { setSkillFileContent('(Failed to load)'); }
172
- };
173
-
174
- const saveSkillFile = async (name: string, type: string, path: string) => {
175
- setSkillSaving(true);
176
- const project = selectedProject?.path || '';
177
- const res = await fetch('/api/skills/local', {
178
- method: 'POST',
179
- headers: { 'Content-Type': 'application/json' },
180
- body: JSON.stringify({ name, type, project, path, content: skillEditContent, expectedHash: skillFileHash }),
181
- });
182
- const data = await res.json();
183
- if (data.ok) {
184
- setSkillFileContent(skillEditContent);
185
- setSkillFileHash(data.hash);
186
- setSkillEditing(false);
187
- } else {
188
- alert(data.error || 'Save failed');
189
- }
190
- setSkillSaving(false);
191
- };
192
-
193
- const handleUpdate = async (name: string) => {
194
- // Check for local modifications first
195
- const checkRes = await fetch('/api/skills', {
196
- method: 'POST',
197
- headers: { 'Content-Type': 'application/json' },
198
- body: JSON.stringify({ action: 'check-modified', name }),
199
- });
200
- const checkData = await checkRes.json();
201
- if (checkData.modified) {
202
- if (!confirm('Local files have been modified. Overwrite with remote version?')) return;
203
- }
204
- // Re-install (update)
205
- const target = selectedProject?.path || 'global';
206
- await fetch('/api/skills', {
207
- method: 'POST',
208
- headers: { 'Content-Type': 'application/json' },
209
- body: JSON.stringify({ action: 'install', name, target, force: true }),
210
- });
211
- if (selectedProject) fetchProjectSkills(selectedProject.path);
212
- };
213
-
214
- const uninstallSkill = async (name: string, scope: string) => {
215
- const target = scope === 'global' ? 'global' : (selectedProject?.path || '');
216
- const label = scope === 'global' ? 'global' : selectedProject?.name || 'project';
217
- if (!confirm(`Uninstall "${name}" from ${label}?`)) return;
218
- await fetch('/api/skills', {
219
- method: 'POST',
220
- headers: { 'Content-Type': 'application/json' },
221
- body: JSON.stringify({ action: 'uninstall', name, target }),
222
- });
223
- if (selectedProject) fetchProjectSkills(selectedProject.path);
224
- };
225
-
226
- const fetchClaudeMd = useCallback(async (projectPath: string) => {
227
- try {
228
- const [contentRes, statusRes, listRes] = await Promise.all([
229
- fetch(`/api/claude-templates?action=read-claude-md&project=${encodeURIComponent(projectPath)}`),
230
- fetch(`/api/claude-templates?action=status&project=${encodeURIComponent(projectPath)}`),
231
- fetch('/api/claude-templates?action=list'),
232
- ]);
233
- const contentData = await contentRes.json();
234
- setClaudeMdContent(contentData.content || '');
235
- setClaudeMdExists(contentData.exists || false);
236
- const statusData = await statusRes.json();
237
- setClaudeInjectedIds(new Set((statusData.status || []).filter((s: any) => s.injected).map((s: any) => s.id)));
238
- const listData = await listRes.json();
239
- setClaudeTemplates(listData.templates || []);
240
- } catch {}
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', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
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);
241
81
  }, []);
242
82
 
243
- const injectToProject = async (templateId: string, projectPath: string) => {
244
- await fetch('/api/claude-templates', {
83
+ // Save favorites to settings
84
+ const saveFavorite = useCallback((projectPath: string, add: boolean) => {
85
+ fetch('/api/favorites', {
245
86
  method: 'POST',
246
87
  headers: { 'Content-Type': 'application/json' },
247
- body: JSON.stringify({ action: 'inject', templateId, projects: [projectPath] }),
248
- });
249
- fetchClaudeMd(projectPath);
250
- };
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
+ }, []);
251
93
 
252
- const removeFromProject = async (templateId: string, projectPath: string) => {
253
- if (!confirm(`Remove template from this project's CLAUDE.md?`)) return;
254
- await fetch('/api/claude-templates', {
255
- method: 'POST',
256
- headers: { 'Content-Type': 'application/json' },
257
- 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;
258
124
  });
259
- fetchClaudeMd(projectPath);
260
- };
261
-
262
- const saveClaudeMd = async (projectPath: string, content: string) => {
263
- await fetch('/api/claude-templates', {
264
- method: 'POST',
265
- headers: { 'Content-Type': 'application/json' },
266
- 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;
267
133
  });
268
- setClaudeMdContent(content);
269
- setClaudeEditing(false);
270
- fetchClaudeMd(projectPath);
271
- };
272
-
273
- const fetchProjectSkills = useCallback(async (projectPath: string) => {
274
- try {
275
- // Fetch registry skills (with update info)
276
- const [registryRes, localRes] = await Promise.all([
277
- fetch('/api/skills'),
278
- fetch(`/api/skills/local?action=scan&project=${encodeURIComponent(projectPath)}`),
279
- ]);
280
- const registryData = await registryRes.json();
281
- const localData = await localRes.json();
282
-
283
- // Registry items installed for this project
284
- const registryItems = (registryData.skills || []).filter((s: any) =>
285
- s.installedGlobal || (s.installedProjects || []).includes(projectPath)
286
- ).map((s: any) => ({
287
- name: s.name,
288
- displayName: s.displayName,
289
- type: s.type || 'command',
290
- version: s.version || '',
291
- installedVersion: s.installedVersion || '',
292
- hasUpdate: s.hasUpdate || false,
293
- source: 'registry' as const,
294
- scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
295
- : s.installedGlobal ? 'global'
296
- : 'project',
297
- }));
298
-
299
- // Local items not in registry
300
- const registryNames = new Set(registryItems.map((s: any) => s.name));
301
- const localItems = (localData.items || [])
302
- .filter((item: any) => !registryNames.has(item.name))
303
- .map((item: any) => ({
304
- name: item.name,
305
- displayName: item.name,
306
- type: item.type,
307
- version: '',
308
- installedVersion: '',
309
- hasUpdate: false,
310
- source: 'local' as const,
311
- scope: item.scope,
312
- }));
313
-
314
- // Merge: deduplicate by name, combine scopes
315
- const merged = new Map<string, any>();
316
- for (const item of [...registryItems, ...localItems]) {
317
- const existing = merged.get(item.name);
318
- if (existing) {
319
- // Merge scopes
320
- if (existing.scope !== item.scope) {
321
- existing.scope = existing.scope.includes(item.scope) ? existing.scope : `${existing.scope} + ${item.scope}`;
322
- }
323
- // Registry takes priority over local
324
- if (item.source === 'registry') {
325
- Object.assign(existing, { ...item, scope: existing.scope });
326
- }
327
- } else {
328
- merged.set(item.name, { ...item });
329
- }
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);
330
151
  }
331
- setProjectSkills([...merged.values()]);
332
- } catch { setProjectSkills([]); }
333
- }, []);
334
-
335
- const selectProject = useCallback((p: Project) => {
336
- setSelectedProject(p);
337
- setSelectedFile(null);
338
- setFileContent(null);
339
- setGitResult(null);
340
- setCommitMsg('');
341
- fetchGitInfo(p);
342
- fetchTree(p);
343
- fetchProjectSkills(p.path);
344
- }, [fetchGitInfo, fetchTree, fetchProjectSkills]);
345
-
346
- const openFile = useCallback(async (path: string) => {
347
- if (!selectedProject) return;
348
- setSelectedFile(path);
349
- setDiffContent(null);
350
- setDiffFile(null);
351
- setFileLoading(true);
352
- try {
353
- const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
354
- const data = await res.json();
355
- setFileContent(data.content || null);
356
- setFileLanguage(data.language || '');
357
- } catch { setFileContent(null); }
358
- setFileLoading(false);
359
- }, [selectedProject]);
360
-
361
- // Git operations
362
- const gitAction = async (action: string, extra?: any) => {
363
- if (!selectedProject) return;
364
- setGitLoading(true);
365
- setGitResult(null);
366
- try {
367
- const res = await fetch('/api/git', {
368
- method: 'POST',
369
- headers: { 'Content-Type': 'application/json' },
370
- body: JSON.stringify({ action, dir: selectedProject.path, ...extra }),
371
- });
372
- const data = await res.json();
373
- setGitResult(data);
374
- if (data.ok) fetchGitInfo(selectedProject);
375
- } catch (e: any) {
376
- setGitResult({ error: e.message });
377
- }
378
- setGitLoading(false);
379
- };
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
+ }
380
166
 
381
167
  const handleClone = async () => {
382
168
  if (!cloneUrl.trim()) return;
@@ -392,7 +178,6 @@ export default function ProjectManager() {
392
178
  if (data.ok) {
393
179
  setCloneUrl('');
394
180
  setShowClone(false);
395
- // Refresh project list
396
181
  const pRes = await fetch('/api/projects');
397
182
  const pData = await pRes.json();
398
183
  if (Array.isArray(pData)) setProjects(pData);
@@ -408,6 +193,7 @@ export default function ProjectManager() {
408
193
 
409
194
  // Group projects by root
410
195
  const roots = [...new Set(projects.map(p => p.root))];
196
+ const favoriteProjects = projects.filter(p => favorites.includes(p.path));
411
197
 
412
198
  return (
413
199
  <div className="flex-1 flex min-h-0">
@@ -445,6 +231,34 @@ export default function ProjectManager() {
445
231
 
446
232
  {/* Project list */}
447
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 */}
448
262
  {roots.map(root => {
449
263
  const rootName = root.split('/').pop() || root;
450
264
  const rootProjects = projects.filter(p => p.root === root).sort((a, b) => a.name.localeCompare(b.name));
@@ -456,11 +270,16 @@ export default function ProjectManager() {
456
270
  {rootProjects.map(p => (
457
271
  <button
458
272
  key={p.path}
459
- onClick={() => selectProject(p)}
273
+ onClick={() => openProjectTab(p)}
460
274
  className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
461
- 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)]'
462
276
  }`}
463
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>
464
283
  <span className="truncate">{p.name}</span>
465
284
  {p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
466
285
  {p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
@@ -474,461 +293,37 @@ export default function ProjectManager() {
474
293
 
475
294
  {/* Main area */}
476
295
  <div className="flex-1 flex flex-col min-w-0">
477
- {selectedProject ? (
478
- <>
479
- {/* Project header */}
480
- <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
481
- <div className="flex items-center gap-2">
482
- <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedProject.name}</span>
483
- {gitInfo?.branch && (
484
- <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
485
- )}
486
- {gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
487
- {gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
488
- {/* Action buttons */}
489
- <div className="flex items-center gap-1.5 ml-auto">
490
- {/* Open Terminal */}
491
- <button
492
- onClick={() => {
493
- if (!selectedProject) return;
494
- // Navigate to terminal tab with this project
495
- const event = new CustomEvent('forge:open-terminal', { detail: { projectPath: selectedProject.path, projectName: selectedProject.name } });
496
- window.dispatchEvent(event);
497
- }}
498
- 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"
499
- title="Open terminal with claude -c"
500
- >
501
- Terminal
502
- </button>
503
- <button
504
- onClick={() => { fetchGitInfo(selectedProject); fetchTree(selectedProject); if (selectedFile) openFile(selectedFile); }}
505
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
506
- title="Refresh"
507
- >
508
-
509
- </button>
510
- </div>
511
- </div>
512
- <div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
513
- {selectedProject.path}
514
- {gitInfo?.remote && (
515
- <span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
516
- )}
517
- </div>
518
- {/* Tab switcher */}
519
- <div className="flex items-center gap-2 mt-1.5">
520
- <div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
521
- <button
522
- onClick={() => setProjectTab('code')}
523
- className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
524
- projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
525
- }`}
526
- >Code</button>
527
- <button
528
- onClick={() => setProjectTab('skills')}
529
- className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
530
- projectTab === 'skills' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
531
- }`}
532
- >
533
- Skills & Cmds
534
- {projectSkills.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({projectSkills.length})</span>}
535
- {projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[8px] text-[var(--yellow)]">!</span>}
536
- </button>
537
- <button
538
- onClick={() => { setProjectTab('claudemd'); if (selectedProject) fetchClaudeMd(selectedProject.path); }}
539
- className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
540
- projectTab === 'claudemd' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
541
- }`}
542
- >
543
- CLAUDE.md
544
- {claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
545
- </button>
546
- </div>
547
- </div>
548
- {projectTab === 'code' && gitInfo?.lastCommit && (
549
- <div className="flex items-center gap-2 mt-0.5">
550
- <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
551
- <button
552
- onClick={() => setShowLog(v => !v)}
553
- 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'}`}
554
- >
555
- History
556
- </button>
557
- </div>
558
- )}
559
- </div>
560
-
561
- {/* Git log */}
562
- {projectTab === 'code' && showLog && gitInfo?.log && gitInfo.log.length > 0 && (
563
- <div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
564
- {gitInfo.log.map(c => (
565
- <div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
566
- <span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
567
- <span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
568
- <span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
569
- <span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
570
- </div>
571
- ))}
572
- </div>
573
- )}
574
-
575
- {/* Code content area */}
576
- {projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
577
- {/* File tree */}
578
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
579
- {fileTree.map((node: any) => (
580
- <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
581
- ))}
582
- </div>
583
-
584
- {/* File content — independent scroll, width:0 prevents content from expanding parent */}
585
- <div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]" style={{ width: 0 }}>
586
- {/* Diff view */}
587
- {diffContent !== null && diffFile ? (
588
- <>
589
- <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">
590
- <span className="text-[var(--yellow)]">DIFF</span>
591
- <span className="text-[var(--text-secondary)]">{diffFile}</span>
592
- <button onClick={() => { if (diffFile) openFile(diffFile); }} className="ml-auto text-[9px] text-[var(--accent)] hover:underline">Open Source</button>
593
- </div>
594
- <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
595
- {diffContent.split('\n').map((line, i) => {
596
- const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
597
- : line.startsWith('-') ? 'text-red-400 bg-red-900/20'
598
- : line.startsWith('@@') ? 'text-cyan-400'
599
- : line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
600
- : 'text-[var(--text-primary)]';
601
- return <div key={i} className={`${color} px-2`}>{line || ' '}</div>;
602
- })}
603
- </pre>
604
- </>
605
- ) : fileLoading ? (
606
- <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
607
- ) : selectedFile && fileContent !== null ? (
608
- <>
609
- <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>
610
- <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 }}>
611
- {fileContent.split('\n').map((line, i) => (
612
- <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
613
- <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
614
- <span className="flex-1">{highlightLine(line)}</span>
615
- </div>
616
- ))}
617
- </pre>
618
- </>
619
- ) : (
620
- <div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
621
- Select a file to view
622
- </div>
623
- )}
624
- </div>
625
- </div>}
626
-
627
- {/* Skills & Commands tab */}
628
- {projectTab === 'skills' && (
629
- <div className="flex-1 flex min-h-0 overflow-hidden">
630
- {/* Left: skill/command tree */}
631
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
632
- {projectSkills.length === 0 ? (
633
- <p className="text-[9px] text-[var(--text-secondary)] p-2">No skills or commands installed</p>
634
- ) : (
635
- projectSkills.map(s => (
636
- <div key={`${s.name}-${s.scope}-${s.source}`}>
637
- <button
638
- onClick={() => toggleSkillItem(s.name, s.type, s.scope)}
639
- className={`w-full text-left px-2 py-1 text-[10px] rounded flex items-center gap-1.5 group ${
640
- expandedSkillItem === s.name ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
641
- }`}
642
- >
643
- <span className="text-[8px] text-[var(--text-secondary)]">{expandedSkillItem === s.name ? '▾' : '▸'}</span>
644
- <span className={`text-[7px] px-1 rounded font-medium shrink-0 ${
645
- s.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
646
- }`}>{s.type === 'skill' ? 'S' : 'C'}</span>
647
- <span className="truncate flex-1">{s.name}</span>
648
- <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>
649
- {s.hasUpdate && <span className="text-[7px] text-[var(--yellow)] shrink-0">!</span>}
650
- {s.source === 'local' && <span className="text-[7px] text-[var(--text-secondary)] shrink-0">local</span>}
651
- {s.source === 'registry' && <span className="text-[7px] text-[var(--accent)] shrink-0">mkt</span>}
652
- <span
653
- onClick={(e) => { e.stopPropagation(); uninstallSkill(s.name, s.scope); }}
654
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0 opacity-0 group-hover:opacity-100 cursor-pointer"
655
- >x</span>
656
- </button>
657
- {/* Expanded file list */}
658
- {expandedSkillItem === s.name && skillItemFiles.length > 0 && (
659
- <div className="ml-4">
660
- {skillItemFiles.map(f => (
661
- <button
662
- key={f.path}
663
- onClick={() => loadSkillFile(s.name, s.type, f.path, s.scope === 'global' ? '' : (selectedProject?.path || ''))}
664
- className={`w-full text-left px-2 py-0.5 text-[9px] rounded truncate ${
665
- skillActivePath === f.path ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
666
- }`}
667
- title={f.path}
668
- >
669
- {f.path.split('/').pop()}
670
- </button>
671
- ))}
672
- </div>
673
- )}
674
- </div>
675
- ))
676
- )}
677
- </div>
678
-
679
- {/* Right: file content / editor */}
680
- <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
681
- {skillActivePath ? (
682
- <>
683
- <div className="flex items-center gap-2 px-3 py-1 border-b border-[var(--border)] shrink-0">
684
- <span className="text-[10px] text-[var(--text-secondary)] font-mono truncate flex-1">{skillActivePath}</span>
685
- {expandedSkillItem && (() => {
686
- const s = projectSkills.find(x => x.name === expandedSkillItem);
687
- return s && (
688
- <div className="flex items-center gap-2 shrink-0">
689
- {s.version && <span className="text-[8px] text-[var(--text-secondary)] font-mono">v{s.installedVersion || s.version}</span>}
690
- {s.hasUpdate && (
691
- <button
692
- onClick={() => handleUpdate(s.name)}
693
- className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--yellow)]/20 text-[var(--yellow)] hover:bg-[var(--yellow)]/30"
694
- >Update → v{s.version}</button>
695
- )}
696
- </div>
697
- );
698
- })()}
699
- {!skillEditing ? (
700
- <button
701
- onClick={() => { setSkillEditing(true); setSkillEditContent(skillFileContent); }}
702
- className="text-[9px] text-[var(--accent)] hover:underline shrink-0"
703
- >Edit</button>
704
- ) : (
705
- <div className="flex gap-1 shrink-0">
706
- <button
707
- onClick={() => { if (expandedSkillItem) saveSkillFile(expandedSkillItem, projectSkills.find(x => x.name === expandedSkillItem)?.type || 'command', skillActivePath); }}
708
- disabled={skillSaving}
709
- className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
710
- >{skillSaving ? '...' : 'Save'}</button>
711
- <button
712
- onClick={() => setSkillEditing(false)}
713
- className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
714
- >Cancel</button>
715
- </div>
716
- )}
717
- </div>
718
- <div className="flex-1 overflow-auto">
719
- {skillEditing ? (
720
- <textarea
721
- value={skillEditContent}
722
- onChange={e => setSkillEditContent(e.target.value)}
723
- 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"
724
- spellCheck={false}
725
- />
726
- ) : (
727
- <pre className="p-3 text-[11px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
728
- {skillFileContent}
729
- </pre>
730
- )}
731
- </div>
732
- </>
733
- ) : (
734
- <div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
735
- Select a skill or command to view
736
- </div>
737
- )}
738
- </div>
739
- </div>
740
- )}
741
-
742
- {/* CLAUDE.md tab */}
743
- {projectTab === 'claudemd' && selectedProject && (
744
- <div className="flex-1 flex min-h-0 overflow-hidden">
745
- {/* Left: templates list */}
746
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
747
- <button
748
- onClick={() => { setClaudeSelectedTemplate(null); setClaudeEditing(false); }}
749
- className={`w-full px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-left flex items-center gap-1 ${
750
- !claudeSelectedTemplate && !claudeEditing ? 'text-[var(--accent)] bg-[var(--accent)]/5' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
751
- }`}
752
- >
753
- <span className="font-mono">CLAUDE.md</span>
754
- {claudeMdExists && <span className="text-[var(--green)] text-[8px]">•</span>}
755
- </button>
756
- <div className="px-2 py-1 border-b border-[var(--border)] text-[8px] text-[var(--text-secondary)] uppercase">Templates</div>
757
- <div className="flex-1 overflow-y-auto">
758
- {claudeTemplates.map(t => {
759
- const injected = claudeInjectedIds.has(t.id);
760
- const isSelected = claudeSelectedTemplate === t.id;
761
- return (
762
- <div
763
- key={t.id}
764
- className={`px-2 py-1.5 border-b border-[var(--border)]/30 cursor-pointer ${isSelected ? 'bg-[var(--accent)]/10' : 'hover:bg-[var(--bg-tertiary)]'}`}
765
- onClick={() => setClaudeSelectedTemplate(isSelected ? null : t.id)}
766
- >
767
- <div className="flex items-center gap-1.5">
768
- <span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
769
- {t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
770
- {injected ? (
771
- <button
772
- onClick={(e) => { e.stopPropagation(); removeFromProject(t.id, selectedProject.path); }}
773
- className="text-[7px] px-1 rounded bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400"
774
- title="Remove from CLAUDE.md"
775
- >added</button>
776
- ) : (
777
- <button
778
- onClick={(e) => { e.stopPropagation(); injectToProject(t.id, selectedProject.path); }}
779
- className="text-[7px] px-1 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
780
- title="Add to CLAUDE.md"
781
- >+ add</button>
782
- )}
783
- </div>
784
- <p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
785
- </div>
786
- );
787
- })}
788
- </div>
789
- </div>
790
-
791
- {/* Right: CLAUDE.md content or template preview */}
792
- <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
793
- {/* Header bar */}
794
- <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--border)] shrink-0">
795
- {claudeSelectedTemplate ? (
796
- <>
797
- <span className="text-[10px] text-[var(--text-secondary)]">Preview:</span>
798
- <span className="text-[10px] text-[var(--text-primary)] font-semibold">{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.name}</span>
799
- <button
800
- onClick={() => setClaudeSelectedTemplate(null)}
801
- className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
802
- >Show CLAUDE.md</button>
803
- </>
804
- ) : (
805
- <>
806
- <span className="text-[10px] text-[var(--text-primary)] font-mono">CLAUDE.md</span>
807
- {!claudeMdExists && <span className="text-[8px] text-[var(--yellow)]">not created</span>}
808
- <div className="flex items-center gap-1 ml-auto">
809
- {!claudeEditing ? (
810
- <button
811
- onClick={() => { setClaudeEditing(true); setClaudeEditContent(claudeMdContent); }}
812
- className="text-[9px] text-[var(--accent)] hover:underline"
813
- >Edit</button>
814
- ) : (
815
- <>
816
- <button
817
- onClick={() => saveClaudeMd(selectedProject.path, claudeEditContent)}
818
- className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
819
- >Save</button>
820
- <button
821
- onClick={() => setClaudeEditing(false)}
822
- className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
823
- >Cancel</button>
824
- </>
825
- )}
826
- </div>
827
- </>
828
- )}
829
- </div>
830
-
831
- {/* Content */}
832
- <div className="flex-1 overflow-auto" style={{ width: 0, minWidth: '100%' }}>
833
- {claudeSelectedTemplate ? (
834
- <pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
835
- {claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.content || ''}
836
- </pre>
837
- ) : claudeEditing ? (
838
- <textarea
839
- value={claudeEditContent}
840
- onChange={e => setClaudeEditContent(e.target.value)}
841
- 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"
842
- spellCheck={false}
843
- />
844
- ) : (
845
- <pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
846
- {claudeMdContent || '(Empty — add templates or edit directly)'}
847
- </pre>
848
- )}
849
- </div>
850
- </div>
851
- </div>
852
- )}
853
-
854
- {/* Git panel — bottom (code tab only) */}
855
- {projectTab === 'code' && gitInfo && (
856
- <div className="border-t border-[var(--border)] shrink-0">
857
- {/* Changes list */}
858
- {gitInfo.changes.length > 0 && (
859
- <div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
860
- <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
861
- {gitInfo.changes.length} changes
862
- </div>
863
- {gitInfo.changes.map(g => (
864
- <div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
865
- <span className={`text-[10px] font-mono w-4 shrink-0 ${
866
- g.status.includes('M') ? 'text-yellow-500' :
867
- g.status.includes('?') ? 'text-green-500' :
868
- g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
869
- }`}>
870
- {g.status.includes('?') ? '+' : g.status[0]}
871
- </span>
872
- <button
873
- onClick={() => openDiff(g.path)}
874
- className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
875
- title="View diff"
876
- >
877
- {g.path}
878
- </button>
879
- <button
880
- onClick={() => openFile(g.path)}
881
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
882
- title="Open source file"
883
- >
884
- src
885
- </button>
886
- </div>
887
- ))}
888
- </div>
889
- )}
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
+ )}
890
305
 
891
- {/* Git actions */}
892
- <div className="px-3 py-2 flex items-center gap-2">
893
- <input
894
- value={commitMsg}
895
- onChange={e => setCommitMsg(e.target.value)}
896
- onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
897
- placeholder="Commit message..."
898
- 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}
899
322
  />
900
- <button
901
- onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
902
- disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
903
- className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
904
- >
905
- Commit
906
- </button>
907
- <button
908
- onClick={() => gitAction('push')}
909
- disabled={gitLoading || gitInfo.ahead === 0}
910
- 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"
911
- >
912
- Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
913
- </button>
914
- <button
915
- onClick={() => gitAction('pull')}
916
- disabled={gitLoading}
917
- className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
918
- >
919
- Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
920
- </button>
921
323
  </div>
922
-
923
- {/* Result */}
924
- {gitResult && (
925
- <div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
926
- {gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
927
- </div>
928
- )}
929
- </div>
930
- )}
931
- </>
324
+ );
325
+ })}
326
+ </div>
932
327
  ) : (
933
328
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
934
329
  <div className="text-center space-y-2">
@@ -941,43 +336,3 @@ export default function ProjectManager() {
941
336
  </div>
942
337
  );
943
338
  }
944
-
945
- // Simple file tree node
946
- function FileTreeNode({ node, depth, selected, onSelect }: {
947
- node: { name: string; path: string; type: string; children?: any[] };
948
- depth: number;
949
- selected: string | null;
950
- onSelect: (path: string) => void;
951
- }) {
952
- const [expanded, setExpanded] = useState(depth < 1);
953
-
954
- if (node.type === 'dir') {
955
- return (
956
- <div>
957
- <button
958
- onClick={() => setExpanded(v => !v)}
959
- className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
960
- style={{ paddingLeft: depth * 12 + 4 }}
961
- >
962
- <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
963
- <span className="text-[var(--text-primary)]">{node.name}</span>
964
- </button>
965
- {expanded && node.children?.map((child: any) => (
966
- <FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
967
- ))}
968
- </div>
969
- );
970
- }
971
-
972
- return (
973
- <button
974
- onClick={() => onSelect(node.path)}
975
- className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
976
- selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
977
- }`}
978
- style={{ paddingLeft: depth * 12 + 16 }}
979
- >
980
- {node.name}
981
- </button>
982
- );
983
- }