@aion0/forge 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -2
- package/README.md +9 -9
- package/app/api/claude-templates/route.ts +145 -0
- package/app/api/docs/sessions/route.ts +3 -3
- package/app/api/monitor/route.ts +2 -2
- package/app/api/pipelines/route.ts +2 -2
- package/app/api/preview/[...path]/route.ts +2 -2
- package/app/api/preview/route.ts +3 -4
- package/app/api/projects/route.ts +19 -0
- package/app/api/skills/local/route.ts +228 -0
- package/app/api/skills/route.ts +36 -11
- package/app/api/terminal-state/route.ts +2 -2
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +49 -33
- package/cli/mw.ts +27 -9
- package/components/Dashboard.tsx +14 -0
- package/components/ProjectManager.tsx +581 -42
- package/components/SessionView.tsx +1 -1
- package/components/SettingsModal.tsx +18 -1
- package/components/SkillsPanel.tsx +515 -29
- package/components/WebTerminal.tsx +50 -5
- package/instrumentation.ts +2 -2
- package/lib/claude-sessions.ts +2 -2
- package/lib/claude-templates.ts +227 -0
- package/lib/cloudflared.ts +4 -3
- package/lib/crypto.ts +3 -4
- package/lib/dirs.ts +34 -0
- package/lib/flows.ts +2 -2
- package/lib/init.ts +2 -2
- package/lib/password.ts +2 -2
- package/lib/pipeline.ts +3 -3
- package/lib/session-watcher.ts +2 -2
- package/lib/settings.ts +4 -2
- package/lib/skills.ts +444 -79
- package/lib/telegram-bot.ts +12 -5
- package/lib/terminal-standalone.ts +3 -2
- package/package.json +1 -1
- package/src/config/index.ts +6 -7
- package/src/core/db/database.ts +17 -5
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
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
|
+
}
|
|
4
40
|
|
|
5
41
|
interface Project {
|
|
6
42
|
name: string;
|
|
@@ -37,7 +73,26 @@ export default function ProjectManager() {
|
|
|
37
73
|
const [fileLanguage, setFileLanguage] = useState('');
|
|
38
74
|
const [fileLoading, setFileLoading] = useState(false);
|
|
39
75
|
const [showLog, setShowLog] = useState(false);
|
|
40
|
-
const [
|
|
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);
|
|
41
96
|
|
|
42
97
|
// Fetch projects
|
|
43
98
|
useEffect(() => {
|
|
@@ -68,20 +123,212 @@ export default function ProjectManager() {
|
|
|
68
123
|
} catch { setFileTree([]); }
|
|
69
124
|
}, []);
|
|
70
125
|
|
|
71
|
-
const
|
|
126
|
+
const openDiff = useCallback(async (filePath: string) => {
|
|
127
|
+
if (!selectedProject) return;
|
|
128
|
+
setDiffFile(filePath);
|
|
129
|
+
setDiffContent(null);
|
|
130
|
+
setSelectedFile(null);
|
|
131
|
+
setFileContent(null);
|
|
72
132
|
try {
|
|
73
|
-
const res = await fetch(
|
|
133
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&diff=${encodeURIComponent(filePath)}`);
|
|
74
134
|
const data = await res.json();
|
|
75
|
-
|
|
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 {}
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
const injectToProject = async (templateId: string, projectPath: string) => {
|
|
244
|
+
await fetch('/api/claude-templates', {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ action: 'inject', templateId, projects: [projectPath] }),
|
|
248
|
+
});
|
|
249
|
+
fetchClaudeMd(projectPath);
|
|
250
|
+
};
|
|
251
|
+
|
|
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 }),
|
|
258
|
+
});
|
|
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 }),
|
|
267
|
+
});
|
|
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) =>
|
|
76
285
|
s.installedGlobal || (s.installedProjects || []).includes(projectPath)
|
|
77
286
|
).map((s: any) => ({
|
|
78
287
|
name: s.name,
|
|
79
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,
|
|
80
294
|
scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
|
|
81
295
|
: s.installedGlobal ? 'global'
|
|
82
296
|
: 'project',
|
|
83
297
|
}));
|
|
84
|
-
|
|
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
|
+
}
|
|
330
|
+
}
|
|
331
|
+
setProjectSkills([...merged.values()]);
|
|
85
332
|
} catch { setProjectSkills([]); }
|
|
86
333
|
}, []);
|
|
87
334
|
|
|
@@ -99,6 +346,8 @@ export default function ProjectManager() {
|
|
|
99
346
|
const openFile = useCallback(async (path: string) => {
|
|
100
347
|
if (!selectedProject) return;
|
|
101
348
|
setSelectedFile(path);
|
|
349
|
+
setDiffContent(null);
|
|
350
|
+
setDiffFile(null);
|
|
102
351
|
setFileLoading(true);
|
|
103
352
|
try {
|
|
104
353
|
const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
|
|
@@ -198,7 +447,7 @@ export default function ProjectManager() {
|
|
|
198
447
|
<div className="flex-1 overflow-y-auto">
|
|
199
448
|
{roots.map(root => {
|
|
200
449
|
const rootName = root.split('/').pop() || root;
|
|
201
|
-
const rootProjects = projects.filter(p => p.root === root);
|
|
450
|
+
const rootProjects = projects.filter(p => p.root === root).sort((a, b) => a.name.localeCompare(b.name));
|
|
202
451
|
return (
|
|
203
452
|
<div key={root}>
|
|
204
453
|
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
|
|
@@ -236,13 +485,29 @@ export default function ProjectManager() {
|
|
|
236
485
|
)}
|
|
237
486
|
{gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
|
|
238
487
|
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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>
|
|
246
511
|
</div>
|
|
247
512
|
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
|
|
248
513
|
{selectedProject.path}
|
|
@@ -250,22 +515,37 @@ export default function ProjectManager() {
|
|
|
250
515
|
<span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
251
516
|
)}
|
|
252
517
|
</div>
|
|
253
|
-
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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>
|
|
266
546
|
</div>
|
|
267
|
-
|
|
268
|
-
{gitInfo?.lastCommit && (
|
|
547
|
+
</div>
|
|
548
|
+
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
269
549
|
<div className="flex items-center gap-2 mt-0.5">
|
|
270
550
|
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
271
551
|
<button
|
|
@@ -279,7 +559,7 @@ export default function ProjectManager() {
|
|
|
279
559
|
</div>
|
|
280
560
|
|
|
281
561
|
{/* Git log */}
|
|
282
|
-
{showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
562
|
+
{projectTab === 'code' && showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
283
563
|
<div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
|
|
284
564
|
{gitInfo.log.map(c => (
|
|
285
565
|
<div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
|
|
@@ -292,8 +572,8 @@ export default function ProjectManager() {
|
|
|
292
572
|
</div>
|
|
293
573
|
)}
|
|
294
574
|
|
|
295
|
-
{/*
|
|
296
|
-
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
575
|
+
{/* Code content area */}
|
|
576
|
+
{projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
|
|
297
577
|
{/* File tree */}
|
|
298
578
|
<div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
|
|
299
579
|
{fileTree.map((node: any) => (
|
|
@@ -301,9 +581,28 @@ export default function ProjectManager() {
|
|
|
301
581
|
))}
|
|
302
582
|
</div>
|
|
303
583
|
|
|
304
|
-
{/* File content — independent scroll */}
|
|
305
|
-
<div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]">
|
|
306
|
-
{
|
|
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 ? (
|
|
307
606
|
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
308
607
|
) : selectedFile && fileContent !== null ? (
|
|
309
608
|
<>
|
|
@@ -312,7 +611,7 @@ export default function ProjectManager() {
|
|
|
312
611
|
{fileContent.split('\n').map((line, i) => (
|
|
313
612
|
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
314
613
|
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
315
|
-
<span className="flex-1">{line
|
|
614
|
+
<span className="flex-1">{highlightLine(line)}</span>
|
|
316
615
|
</div>
|
|
317
616
|
))}
|
|
318
617
|
</pre>
|
|
@@ -323,10 +622,237 @@ export default function ProjectManager() {
|
|
|
323
622
|
</div>
|
|
324
623
|
)}
|
|
325
624
|
</div>
|
|
326
|
-
</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
|
+
)}
|
|
327
853
|
|
|
328
|
-
{/* Git panel — bottom */}
|
|
329
|
-
{gitInfo && (
|
|
854
|
+
{/* Git panel — bottom (code tab only) */}
|
|
855
|
+
{projectTab === 'code' && gitInfo && (
|
|
330
856
|
<div className="border-t border-[var(--border)] shrink-0">
|
|
331
857
|
{/* Changes list */}
|
|
332
858
|
{gitInfo.changes.length > 0 && (
|
|
@@ -335,15 +861,28 @@ export default function ProjectManager() {
|
|
|
335
861
|
{gitInfo.changes.length} changes
|
|
336
862
|
</div>
|
|
337
863
|
{gitInfo.changes.map(g => (
|
|
338
|
-
<div key={g.path} className="px-3 py-0.5 text-xs
|
|
339
|
-
<span className={`text-[10px] font-mono w-4 ${
|
|
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 ${
|
|
340
866
|
g.status.includes('M') ? 'text-yellow-500' :
|
|
341
867
|
g.status.includes('?') ? 'text-green-500' :
|
|
342
868
|
g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
|
|
343
869
|
}`}>
|
|
344
870
|
{g.status.includes('?') ? '+' : g.status[0]}
|
|
345
871
|
</span>
|
|
346
|
-
<
|
|
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>
|
|
347
886
|
</div>
|
|
348
887
|
))}
|
|
349
888
|
</div>
|