@aion0/forge 0.5.29 → 0.5.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +4 -7
- package/components/ProjectDetail.tsx +23 -20
- package/components/ProjectManager.tsx +141 -82
- package/components/UsagePanel.tsx +431 -49
- package/components/WorkspaceView.tsx +195 -33
- package/lib/usage-scanner.ts +14 -5
- package/lib/workspace/orchestrator.ts +16 -13
- package/lib/workspace/presets.ts +52 -5
- package/lib/workspace-standalone.ts +13 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.31
|
|
2
2
|
|
|
3
|
-
Released: 2026-04-
|
|
3
|
+
Released: 2026-04-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.30
|
|
6
6
|
|
|
7
|
-
### Bug Fixes
|
|
8
|
-
- fix: auto-reconnect workspace terminal WebSocket on disconnect
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.28...v0.5.29
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.30...v0.5.31
|
|
@@ -85,6 +85,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
85
85
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
86
86
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
87
87
|
const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
88
|
+
// Lazy-mount workspace: only mount after first visit, keep mounted to preserve terminal state
|
|
89
|
+
const [wsMounted, setWsMounted] = useState(false);
|
|
90
|
+
useEffect(() => { if (projectTab === 'workspace') setWsMounted(true); }, [projectTab]);
|
|
88
91
|
const wsViewRef = useRef<import('./WorkspaceView').WorkspaceViewHandle>(null);
|
|
89
92
|
// Pipeline bindings state
|
|
90
93
|
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
@@ -566,52 +569,52 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
566
569
|
</div>
|
|
567
570
|
{/* Tab switcher */}
|
|
568
571
|
<div className="flex items-center gap-2 mt-1.5">
|
|
569
|
-
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
572
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-1 gap-0.5">
|
|
570
573
|
<button
|
|
571
574
|
onClick={() => setProjectTab('code')}
|
|
572
|
-
className={`text-[
|
|
573
|
-
projectTab === 'code' ? 'bg-[var(--
|
|
575
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
576
|
+
projectTab === 'code' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
574
577
|
}`}
|
|
575
578
|
>Code</button>
|
|
576
579
|
<button
|
|
577
580
|
onClick={() => setProjectTab('workspace')}
|
|
578
|
-
className={`text-[
|
|
579
|
-
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
581
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
582
|
+
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
580
583
|
}`}
|
|
581
584
|
>🔨 Workspace</button>
|
|
582
585
|
<button
|
|
583
586
|
onClick={() => setProjectTab('sessions')}
|
|
584
|
-
className={`text-[
|
|
585
|
-
projectTab === 'sessions' ? 'bg-[var(--
|
|
587
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
588
|
+
projectTab === 'sessions' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
586
589
|
}`}
|
|
587
590
|
>Sessions</button>
|
|
588
591
|
<button
|
|
589
592
|
onClick={() => setProjectTab('skills')}
|
|
590
|
-
className={`text-[
|
|
591
|
-
projectTab === 'skills' ? 'bg-[var(--
|
|
593
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
594
|
+
projectTab === 'skills' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
592
595
|
}`}
|
|
593
596
|
>
|
|
594
597
|
Skills & Cmds
|
|
595
|
-
{projectSkills.length > 0 && <span className="ml-1 text-[
|
|
596
|
-
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[
|
|
598
|
+
{projectSkills.length > 0 && <span className="ml-1 text-[9px] opacity-70">({projectSkills.length})</span>}
|
|
599
|
+
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[9px] text-[var(--yellow)]">!</span>}
|
|
597
600
|
</button>
|
|
598
601
|
<button
|
|
599
602
|
onClick={() => setProjectTab('claudemd')}
|
|
600
|
-
className={`text-[
|
|
601
|
-
projectTab === 'claudemd' ? 'bg-[var(--
|
|
603
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
604
|
+
projectTab === 'claudemd' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
602
605
|
}`}
|
|
603
606
|
>
|
|
604
607
|
CLAUDE.md
|
|
605
|
-
{claudeMdExists && <span className="ml-1 text-[
|
|
608
|
+
{claudeMdExists && <span className="ml-1 text-[9px] text-[var(--green)]">•</span>}
|
|
606
609
|
</button>
|
|
607
610
|
<button
|
|
608
611
|
onClick={() => setProjectTab('pipelines')}
|
|
609
|
-
className={`text-[
|
|
610
|
-
projectTab === 'pipelines' ? 'bg-[var(--
|
|
612
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
613
|
+
projectTab === 'pipelines' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
611
614
|
}`}
|
|
612
615
|
>
|
|
613
616
|
Pipelines
|
|
614
|
-
{pipelineBindings.length > 0 && <span className="ml-1 text-[
|
|
617
|
+
{pipelineBindings.length > 0 && <span className="ml-1 text-[9px] opacity-70">({pipelineBindings.length})</span>}
|
|
615
618
|
</button>
|
|
616
619
|
</div>
|
|
617
620
|
</div>
|
|
@@ -642,9 +645,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
642
645
|
</div>
|
|
643
646
|
)}
|
|
644
647
|
|
|
645
|
-
{/* Workspace tab */}
|
|
646
|
-
{
|
|
647
|
-
<div className=
|
|
648
|
+
{/* Workspace tab — always mounted to preserve terminal state, hidden via CSS */}
|
|
649
|
+
{wsMounted && (
|
|
650
|
+
<div className={`flex-1 flex min-h-0 overflow-hidden ${projectTab === 'workspace' ? '' : 'hidden'}`}>
|
|
648
651
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
649
652
|
<WorkspaceViewLazy
|
|
650
653
|
ref={wsViewRef}
|
|
@@ -21,12 +21,22 @@ interface ProjectTab {
|
|
|
21
21
|
mountedAt: number; // timestamp for LRU eviction
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const MAX_MOUNTED_TABS =
|
|
24
|
+
const MAX_MOUNTED_TABS = 20;
|
|
25
25
|
function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
|
|
26
26
|
|
|
27
27
|
export default function ProjectManager() {
|
|
28
28
|
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 240, minWidth: 140, maxWidth: 400 });
|
|
29
|
-
const [sidebarCollapsed,
|
|
29
|
+
const [sidebarCollapsed, setSidebarCollapsedState] = useState(false);
|
|
30
|
+
// Load collapsed preference from localStorage on mount
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
try {
|
|
33
|
+
if (localStorage.getItem('forge.pm.sidebarCollapsed') === '1') setSidebarCollapsedState(true);
|
|
34
|
+
} catch {}
|
|
35
|
+
}, []);
|
|
36
|
+
const setSidebarCollapsed = useCallback((v: boolean) => {
|
|
37
|
+
setSidebarCollapsedState(v);
|
|
38
|
+
try { localStorage.setItem('forge.pm.sidebarCollapsed', v ? '1' : '0'); } catch {}
|
|
39
|
+
}, []);
|
|
30
40
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
31
41
|
const [showClone, setShowClone] = useState(false);
|
|
32
42
|
const [cloneUrl, setCloneUrl] = useState('');
|
|
@@ -103,40 +113,37 @@ export default function ProjectManager() {
|
|
|
103
113
|
|
|
104
114
|
// Open a project in a tab
|
|
105
115
|
const openProjectTab = useCallback((p: Project) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return updated;
|
|
127
|
-
});
|
|
128
|
-
}, [persistTabs]);
|
|
116
|
+
const existing = tabs.find(t => t.projectPath === p.path);
|
|
117
|
+
if (existing) {
|
|
118
|
+
const updated = tabs.map(t => t.id === existing.id ? { ...t, mountedAt: Date.now() } : t);
|
|
119
|
+
setTabs(updated);
|
|
120
|
+
setActiveTabId(existing.id);
|
|
121
|
+
persistTabs(updated, existing.id);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const newTab: ProjectTab = {
|
|
125
|
+
id: genTabId(),
|
|
126
|
+
projectPath: p.path,
|
|
127
|
+
projectName: p.name,
|
|
128
|
+
hasGit: p.hasGit,
|
|
129
|
+
mountedAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
const updated = [...tabs, newTab];
|
|
132
|
+
setTabs(updated);
|
|
133
|
+
setActiveTabId(newTab.id);
|
|
134
|
+
persistTabs(updated, newTab.id);
|
|
135
|
+
}, [tabs, persistTabs]);
|
|
129
136
|
|
|
130
137
|
const activateTab = useCallback((id: number) => {
|
|
138
|
+
const updated = tabs.map(t => t.id === id ? { ...t, mountedAt: Date.now() } : t);
|
|
139
|
+
setTabs(updated);
|
|
131
140
|
setActiveTabId(id);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
persistTabs(updated, id);
|
|
135
|
-
return updated;
|
|
136
|
-
});
|
|
137
|
-
}, [persistTabs]);
|
|
141
|
+
persistTabs(updated, id);
|
|
142
|
+
}, [tabs, persistTabs]);
|
|
138
143
|
|
|
139
144
|
const closeTab = useCallback((id: number) => {
|
|
145
|
+
const tab = tabs.find(t => t.id === id);
|
|
146
|
+
if (tab && !window.confirm(`Close "${tab.projectName}"?`)) return;
|
|
140
147
|
setTabs(prev => {
|
|
141
148
|
const idx = prev.findIndex(t => t.id === id);
|
|
142
149
|
const updated = prev.filter(t => t.id !== id);
|
|
@@ -154,7 +161,7 @@ export default function ProjectManager() {
|
|
|
154
161
|
}
|
|
155
162
|
return updated;
|
|
156
163
|
});
|
|
157
|
-
}, [activeTabId, persistTabs]);
|
|
164
|
+
}, [activeTabId, persistTabs, tabs]);
|
|
158
165
|
|
|
159
166
|
// Determine which tabs to mount (max 5, LRU eviction)
|
|
160
167
|
const mountedTabIds = new Set<number>();
|
|
@@ -213,7 +220,7 @@ export default function ProjectManager() {
|
|
|
213
220
|
|
|
214
221
|
return (
|
|
215
222
|
<div className="flex-1 flex min-h-0">
|
|
216
|
-
{/* Collapsed sidebar — narrow strip with
|
|
223
|
+
{/* Collapsed sidebar — narrow strip with open tabs on top, all projects below */}
|
|
217
224
|
{sidebarCollapsed && (
|
|
218
225
|
<div className="w-10 border-r border-[var(--border)] flex flex-col shrink-0 overflow-hidden">
|
|
219
226
|
<button onClick={() => setSidebarCollapsed(false)}
|
|
@@ -221,17 +228,46 @@ export default function ProjectManager() {
|
|
|
221
228
|
title="Expand sidebar">
|
|
222
229
|
▶
|
|
223
230
|
</button>
|
|
231
|
+
{/* Open tabs */}
|
|
232
|
+
{tabs.length > 0 && (
|
|
233
|
+
<>
|
|
234
|
+
<div className="text-[7px] text-[var(--text-secondary)] text-center py-0.5 border-y border-[var(--border)] bg-[var(--bg-tertiary)]">OPEN</div>
|
|
235
|
+
{tabs.map(t => {
|
|
236
|
+
const isActive = t.id === activeTabId;
|
|
237
|
+
const initial = t.projectName.slice(0, 2).toUpperCase();
|
|
238
|
+
return (
|
|
239
|
+
<div key={`tab-${t.id}`} className="group relative">
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => activateTab(t.id)}
|
|
242
|
+
title={t.projectName}
|
|
243
|
+
className={`w-full py-1.5 text-[9px] font-bold text-center border-l-2 ${
|
|
244
|
+
isActive ? 'text-[var(--accent)] bg-[var(--accent)]/10 border-[var(--accent)]' : 'text-[var(--green)] border-transparent hover:bg-[var(--bg-secondary)]'
|
|
245
|
+
}`}
|
|
246
|
+
>
|
|
247
|
+
{initial}
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
250
|
+
onClick={(e) => { e.stopPropagation(); closeTab(t.id); }}
|
|
251
|
+
className="absolute top-0 right-0 text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] opacity-0 group-hover:opacity-100 transition-opacity px-0.5"
|
|
252
|
+
title="Close"
|
|
253
|
+
>×</button>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
})}
|
|
257
|
+
<div className="border-b border-[var(--border)] my-1"></div>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
{/* All projects */}
|
|
224
261
|
<div className="flex-1 overflow-y-auto">
|
|
225
262
|
{[...projects].sort((a, b) => a.name.localeCompare(b.name)).map(p => {
|
|
226
|
-
const
|
|
263
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
264
|
+
if (openTab) return null; // already in Open section above
|
|
227
265
|
const initial = p.name.slice(0, 2).toUpperCase();
|
|
228
266
|
return (
|
|
229
267
|
<button key={p.path}
|
|
230
268
|
onClick={() => openProjectTab(p)}
|
|
231
269
|
title={p.name}
|
|
232
|
-
className=
|
|
233
|
-
isActive ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
234
|
-
}`}>
|
|
270
|
+
className="w-full py-1.5 text-[9px] font-bold text-center text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
|
235
271
|
{initial}
|
|
236
272
|
</button>
|
|
237
273
|
);
|
|
@@ -285,24 +321,40 @@ export default function ProjectManager() {
|
|
|
285
321
|
<span className="text-[8px]">{collapsedRoots.has('__favorites__') ? '▸' : '▾'}</span>
|
|
286
322
|
<span>★</span> Favorites ({favoriteProjects.length})
|
|
287
323
|
</button>
|
|
288
|
-
{!collapsedRoots.has('__favorites__') && favoriteProjects.map(p =>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
324
|
+
{!collapsedRoots.has('__favorites__') && favoriteProjects.map(p => {
|
|
325
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
326
|
+
const isActive = openTab?.id === activeTabId;
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
key={`fav-${p.path}`}
|
|
330
|
+
className={`group w-full border-b border-[var(--border)]/30 flex items-center gap-2 px-3 py-1.5 text-xs ${
|
|
331
|
+
isActive ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : openTab ? 'bg-[var(--bg-tertiary)]/40 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
332
|
+
}`}
|
|
333
|
+
>
|
|
334
|
+
<span
|
|
335
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
336
|
+
className="text-[13px] text-[var(--yellow)] shrink-0 cursor-pointer leading-none"
|
|
337
|
+
title="Remove from favorites"
|
|
338
|
+
>★</span>
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => openProjectTab(p)}
|
|
341
|
+
className="flex-1 text-left flex items-center gap-2 min-w-0"
|
|
342
|
+
>
|
|
343
|
+
{openTab && <span className="w-1.5 h-1.5 rounded-full bg-[var(--green)] shrink-0" title="Open"></span>}
|
|
344
|
+
<span className="truncate">{p.name}</span>
|
|
345
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
346
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
347
|
+
</button>
|
|
348
|
+
{openTab && (
|
|
349
|
+
<button
|
|
350
|
+
onClick={(e) => { e.stopPropagation(); closeTab(openTab.id); }}
|
|
351
|
+
className="text-[var(--text-secondary)] hover:text-[var(--red)] text-[11px] leading-none opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
352
|
+
title="Close tab"
|
|
353
|
+
>×</button>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
306
358
|
</div>
|
|
307
359
|
)}
|
|
308
360
|
|
|
@@ -320,24 +372,40 @@ export default function ProjectManager() {
|
|
|
320
372
|
<span className="text-[8px]">{isCollapsed ? '▸' : '▾'}</span>
|
|
321
373
|
{rootName} ({rootProjects.length})
|
|
322
374
|
</button>
|
|
323
|
-
{!isCollapsed && rootProjects.map(p =>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
375
|
+
{!isCollapsed && rootProjects.map(p => {
|
|
376
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
377
|
+
const isActive = openTab?.id === activeTabId;
|
|
378
|
+
return (
|
|
379
|
+
<div
|
|
380
|
+
key={p.path}
|
|
381
|
+
className={`group w-full border-b border-[var(--border)]/30 flex items-center gap-2 px-3 py-1.5 text-xs ${
|
|
382
|
+
isActive ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : openTab ? 'bg-[var(--bg-tertiary)]/40 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
383
|
+
}`}
|
|
384
|
+
>
|
|
385
|
+
<span
|
|
386
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
387
|
+
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)]'}`}
|
|
388
|
+
title={favorites.includes(p.path) ? 'Remove from favorites' : 'Add to favorites'}
|
|
389
|
+
>{favorites.includes(p.path) ? '★' : '☆'}</span>
|
|
390
|
+
<button
|
|
391
|
+
onClick={() => openProjectTab(p)}
|
|
392
|
+
className="flex-1 text-left flex items-center gap-2 min-w-0"
|
|
393
|
+
>
|
|
394
|
+
{openTab && <span className="w-1.5 h-1.5 rounded-full bg-[var(--green)] shrink-0" title="Open"></span>}
|
|
395
|
+
<span className="truncate">{p.name}</span>
|
|
396
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
397
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
398
|
+
</button>
|
|
399
|
+
{openTab && (
|
|
400
|
+
<button
|
|
401
|
+
onClick={(e) => { e.stopPropagation(); closeTab(openTab.id); }}
|
|
402
|
+
className="text-[var(--text-secondary)] hover:text-[var(--red)] text-[11px] leading-none opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
403
|
+
title="Close tab"
|
|
404
|
+
>×</button>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
341
409
|
</div>
|
|
342
410
|
);
|
|
343
411
|
})}
|
|
@@ -356,15 +424,6 @@ export default function ProjectManager() {
|
|
|
356
424
|
|
|
357
425
|
{/* Main area */}
|
|
358
426
|
<div className="flex-1 flex flex-col min-w-0">
|
|
359
|
-
{/* Tab bar */}
|
|
360
|
-
{tabs.length > 0 && (
|
|
361
|
-
<TabBar
|
|
362
|
-
tabs={tabs.map(t => ({ id: t.id, label: t.projectName }))}
|
|
363
|
-
activeId={activeTabId}
|
|
364
|
-
onActivate={activateTab}
|
|
365
|
-
onClose={closeTab}
|
|
366
|
-
/>
|
|
367
|
-
)}
|
|
368
427
|
|
|
369
428
|
{/* Tab content */}
|
|
370
429
|
{tabs.length > 0 ? (
|