@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.
@@ -0,0 +1,1115 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, memo } 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
+ }
40
+
41
+ interface GitInfo {
42
+ branch: string;
43
+ changes: { status: string; path: string }[];
44
+ remote: string;
45
+ ahead: number;
46
+ behind: number;
47
+ lastCommit: string;
48
+ log: { hash: string; message: string; author: string; date: string }[];
49
+ }
50
+
51
+ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }: { projectPath: string; projectName: string; hasGit: boolean }) {
52
+ const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
53
+ const [loading, setLoading] = useState(false);
54
+ const [commitMsg, setCommitMsg] = useState('');
55
+ const [gitLoading, setGitLoading] = useState(false);
56
+ const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
57
+ const [fileTree, setFileTree] = useState<any[]>([]);
58
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
59
+ const [fileContent, setFileContent] = useState<string | null>(null);
60
+ const [fileLanguage, setFileLanguage] = useState('');
61
+ const [fileLoading, setFileLoading] = useState(false);
62
+ const [showLog, setShowLog] = useState(false);
63
+ const [diffContent, setDiffContent] = useState<string | null>(null);
64
+ const [diffFile, setDiffFile] = useState<string | null>(null);
65
+ const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
66
+ const [showSkillsDetail, setShowSkillsDetail] = useState(false);
67
+ const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'issues'>('code');
68
+ // Issue autofix state
69
+ const [issueConfig, setIssueConfig] = useState<{ enabled: boolean; interval: number; labels: string[]; baseBranch: string } | null>(null);
70
+ const [issueProcessed, setIssueProcessed] = useState<{ issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[]>([]);
71
+ const [issueScanning, setIssueScanning] = useState(false);
72
+ const [issueManualId, setIssueManualId] = useState('');
73
+ const [issueNextScan, setIssueNextScan] = useState<string | null>(null);
74
+ const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
75
+ const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
76
+ const [claudeMdContent, setClaudeMdContent] = useState('');
77
+ const [claudeMdExists, setClaudeMdExists] = useState(false);
78
+ const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
79
+ const [claudeInjectedIds, setClaudeInjectedIds] = useState<Set<string>>(new Set());
80
+ const [claudeEditing, setClaudeEditing] = useState(false);
81
+ const [claudeEditContent, setClaudeEditContent] = useState('');
82
+ const [claudeSelectedTemplate, setClaudeSelectedTemplate] = useState<string | null>(null);
83
+ const [expandedSkillItem, setExpandedSkillItem] = useState<string | null>(null);
84
+ const [skillItemFiles, setSkillItemFiles] = useState<{ path: string; size: number }[]>([]);
85
+ const [skillFileContent, setSkillFileContent] = useState('');
86
+ const [skillFileHash, setSkillFileHash] = useState('');
87
+ const [skillActivePath, setSkillActivePath] = useState('');
88
+ const [skillEditing, setSkillEditing] = useState(false);
89
+ const [skillEditContent, setSkillEditContent] = useState('');
90
+ const [skillSaving, setSkillSaving] = useState(false);
91
+
92
+ // Fetch git info
93
+ const fetchGitInfo = useCallback(async () => {
94
+ if (!hasGit) { setGitInfo(null); return; }
95
+ setLoading(true);
96
+ try {
97
+ const res = await fetch(`/api/git?dir=${encodeURIComponent(projectPath)}`);
98
+ const data = await res.json();
99
+ if (!data.error) setGitInfo(data);
100
+ else setGitInfo(null);
101
+ } catch { setGitInfo(null); }
102
+ setLoading(false);
103
+ }, [projectPath, hasGit]);
104
+
105
+ // Fetch file tree
106
+ const fetchTree = useCallback(async () => {
107
+ try {
108
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`);
109
+ const data = await res.json();
110
+ setFileTree(data.tree || []);
111
+ } catch { setFileTree([]); }
112
+ }, [projectPath]);
113
+
114
+ const openFile = useCallback(async (path: string) => {
115
+ setSelectedFile(path);
116
+ setDiffContent(null);
117
+ setDiffFile(null);
118
+ setFileLoading(true);
119
+ try {
120
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
121
+ const data = await res.json();
122
+ setFileContent(data.content || null);
123
+ setFileLanguage(data.language || '');
124
+ } catch { setFileContent(null); }
125
+ setFileLoading(false);
126
+ }, [projectPath]);
127
+
128
+ const openDiff = useCallback(async (filePath: string) => {
129
+ setDiffFile(filePath);
130
+ setDiffContent(null);
131
+ setSelectedFile(null);
132
+ setFileContent(null);
133
+ try {
134
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&diff=${encodeURIComponent(filePath)}`);
135
+ const data = await res.json();
136
+ setDiffContent(data.diff || 'No changes');
137
+ } catch { setDiffContent('(Failed to load diff)'); }
138
+ }, [projectPath]);
139
+
140
+ const toggleSkillItem = useCallback(async (name: string, type: string, scope: string) => {
141
+ if (expandedSkillItem === name) {
142
+ setExpandedSkillItem(null);
143
+ setSkillEditing(false);
144
+ return;
145
+ }
146
+ setExpandedSkillItem(name);
147
+ setSkillItemFiles([]);
148
+ setSkillFileContent('');
149
+ setSkillActivePath('');
150
+ setSkillEditing(false);
151
+ const isGlobal = scope === 'global';
152
+ const project = isGlobal ? '' : projectPath;
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, projectPath]);
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 res = await fetch('/api/skills/local', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ name, type, project: projectPath, path, content: skillEditContent, expectedHash: skillFileHash }),
180
+ });
181
+ const data = await res.json();
182
+ if (data.ok) {
183
+ setSkillFileContent(skillEditContent);
184
+ setSkillFileHash(data.hash);
185
+ setSkillEditing(false);
186
+ } else {
187
+ alert(data.error || 'Save failed');
188
+ }
189
+ setSkillSaving(false);
190
+ };
191
+
192
+ const handleUpdate = async (name: string) => {
193
+ const checkRes = await fetch('/api/skills', {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ action: 'check-modified', name }),
197
+ });
198
+ const checkData = await checkRes.json();
199
+ if (checkData.modified) {
200
+ if (!confirm('Local files have been modified. Overwrite with remote version?')) return;
201
+ }
202
+ const target = projectPath || 'global';
203
+ await fetch('/api/skills', {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ action: 'install', name, target, force: true }),
207
+ });
208
+ fetchProjectSkills();
209
+ };
210
+
211
+ const uninstallSkill = async (name: string, scope: string) => {
212
+ const target = scope === 'global' ? 'global' : projectPath;
213
+ const label = scope === 'global' ? 'global' : projectName;
214
+ if (!confirm(`Uninstall "${name}" from ${label}?`)) return;
215
+ await fetch('/api/skills', {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ action: 'uninstall', name, target }),
219
+ });
220
+ fetchProjectSkills();
221
+ };
222
+
223
+ const fetchIssueConfig = useCallback(async () => {
224
+ try {
225
+ const res = await fetch(`/api/issue-scanner?project=${encodeURIComponent(projectPath)}`);
226
+ const data = await res.json();
227
+ setIssueConfig(data.config || { enabled: false, interval: 30, labels: [], baseBranch: '' });
228
+ setIssueProcessed(data.processed || []);
229
+ setIssueLastScan(data.lastScan || null);
230
+ setIssueNextScan(data.nextScan || null);
231
+ } catch {}
232
+ }, [projectPath]);
233
+
234
+ const saveIssueConfig = async (config: any) => {
235
+ await fetch('/api/issue-scanner', {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ action: 'save-config', projectPath, projectName, ...config }),
239
+ });
240
+ fetchIssueConfig();
241
+ };
242
+
243
+ const scanNow = async () => {
244
+ setIssueScanning(true);
245
+ try {
246
+ const res = await fetch('/api/issue-scanner', {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ action: 'scan', projectPath }),
250
+ });
251
+ const data = await res.json();
252
+ if (data.error) {
253
+ alert(data.error);
254
+ } else if (data.triggered > 0) {
255
+ alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
256
+ } else {
257
+ alert(`Scanned ${data.total} open issues — no new issues to process`);
258
+ }
259
+ await fetchIssueConfig();
260
+ } catch (e) {
261
+ alert('Scan failed');
262
+ }
263
+ setIssueScanning(false);
264
+ };
265
+
266
+ const triggerIssue = async (issueId: string) => {
267
+ await fetch('/api/issue-scanner', {
268
+ method: 'POST',
269
+ headers: { 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName }),
271
+ });
272
+ setIssueManualId('');
273
+ fetchIssueConfig();
274
+ };
275
+
276
+ const fetchClaudeMd = useCallback(async () => {
277
+ try {
278
+ const [contentRes, statusRes, listRes] = await Promise.all([
279
+ fetch(`/api/claude-templates?action=read-claude-md&project=${encodeURIComponent(projectPath)}`),
280
+ fetch(`/api/claude-templates?action=status&project=${encodeURIComponent(projectPath)}`),
281
+ fetch('/api/claude-templates?action=list'),
282
+ ]);
283
+ const contentData = await contentRes.json();
284
+ setClaudeMdContent(contentData.content || '');
285
+ setClaudeMdExists(contentData.exists || false);
286
+ const statusData = await statusRes.json();
287
+ setClaudeInjectedIds(new Set((statusData.status || []).filter((s: any) => s.injected).map((s: any) => s.id)));
288
+ const listData = await listRes.json();
289
+ setClaudeTemplates(listData.templates || []);
290
+ } catch {}
291
+ }, [projectPath]);
292
+
293
+ const injectToProject = async (templateId: string) => {
294
+ await fetch('/api/claude-templates', {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ action: 'inject', templateId, projects: [projectPath] }),
298
+ });
299
+ fetchClaudeMd();
300
+ };
301
+
302
+ const removeFromProject = async (templateId: string) => {
303
+ if (!confirm(`Remove template from this project's CLAUDE.md?`)) return;
304
+ await fetch('/api/claude-templates', {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify({ action: 'remove', templateId, project: projectPath }),
308
+ });
309
+ fetchClaudeMd();
310
+ };
311
+
312
+ const saveClaudeMd = async (content: string) => {
313
+ await fetch('/api/claude-templates', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({ action: 'save-claude-md', project: projectPath, content }),
317
+ });
318
+ setClaudeMdContent(content);
319
+ setClaudeEditing(false);
320
+ fetchClaudeMd();
321
+ };
322
+
323
+ const fetchProjectSkills = useCallback(async () => {
324
+ try {
325
+ const [registryRes, localRes] = await Promise.all([
326
+ fetch('/api/skills'),
327
+ fetch(`/api/skills/local?action=scan&project=${encodeURIComponent(projectPath)}`),
328
+ ]);
329
+ const registryData = await registryRes.json();
330
+ const localData = await localRes.json();
331
+
332
+ const registryItems = (registryData.skills || []).filter((s: any) =>
333
+ s.installedGlobal || (s.installedProjects || []).includes(projectPath)
334
+ ).map((s: any) => ({
335
+ name: s.name,
336
+ displayName: s.displayName,
337
+ type: s.type || 'command',
338
+ version: s.version || '',
339
+ installedVersion: s.installedVersion || '',
340
+ hasUpdate: s.hasUpdate || false,
341
+ source: 'registry' as const,
342
+ scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
343
+ : s.installedGlobal ? 'global'
344
+ : 'project',
345
+ }));
346
+
347
+ const registryNames = new Set(registryItems.map((s: any) => s.name));
348
+ const localItems = (localData.items || [])
349
+ .filter((item: any) => !registryNames.has(item.name))
350
+ .map((item: any) => ({
351
+ name: item.name,
352
+ displayName: item.name,
353
+ type: item.type,
354
+ version: '',
355
+ installedVersion: '',
356
+ hasUpdate: false,
357
+ source: 'local' as const,
358
+ scope: item.scope,
359
+ }));
360
+
361
+ const merged = new Map<string, any>();
362
+ for (const item of [...registryItems, ...localItems]) {
363
+ const existing = merged.get(item.name);
364
+ if (existing) {
365
+ if (existing.scope !== item.scope) {
366
+ existing.scope = existing.scope.includes(item.scope) ? existing.scope : `${existing.scope} + ${item.scope}`;
367
+ }
368
+ if (item.source === 'registry') {
369
+ Object.assign(existing, { ...item, scope: existing.scope });
370
+ }
371
+ } else {
372
+ merged.set(item.name, { ...item });
373
+ }
374
+ }
375
+ setProjectSkills([...merged.values()]);
376
+ } catch { setProjectSkills([]); }
377
+ }, [projectPath]);
378
+
379
+ // Git operations
380
+ const gitAction = async (action: string, extra?: any) => {
381
+ setGitLoading(true);
382
+ setGitResult(null);
383
+ try {
384
+ const res = await fetch('/api/git', {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({ action, dir: projectPath, ...extra }),
388
+ });
389
+ const data = await res.json();
390
+ setGitResult(data);
391
+ if (data.ok) fetchGitInfo();
392
+ } catch (e: any) {
393
+ setGitResult({ error: e.message });
394
+ }
395
+ setGitLoading(false);
396
+ };
397
+
398
+ // Load essential data on mount (git + file tree only)
399
+ useEffect(() => {
400
+ setSelectedFile(null);
401
+ setFileContent(null);
402
+ setGitResult(null);
403
+ setCommitMsg('');
404
+ // Fetch git info and file tree in parallel
405
+ fetchGitInfo();
406
+ fetchTree();
407
+ }, [projectPath, fetchGitInfo, fetchTree]);
408
+
409
+ // Lazy load tab-specific data only when switching to that tab
410
+ useEffect(() => {
411
+ if (projectTab === 'skills') fetchProjectSkills();
412
+ if (projectTab === 'issues') fetchIssueConfig();
413
+ if (projectTab === 'claudemd') fetchClaudeMd();
414
+ }, [projectTab, fetchProjectSkills, fetchIssueConfig, fetchClaudeMd]);
415
+
416
+ return (
417
+ <>
418
+ {/* Project header */}
419
+ <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
420
+ <div className="flex items-center gap-2">
421
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{projectName}</span>
422
+ {gitInfo?.branch && (
423
+ <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
424
+ )}
425
+ {gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
426
+ {gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
427
+ {/* Action buttons */}
428
+ <div className="flex items-center gap-1.5 ml-auto">
429
+ {/* Open Terminal */}
430
+ <button
431
+ onClick={() => {
432
+ const event = new CustomEvent('forge:open-terminal', { detail: { projectPath, projectName } });
433
+ window.dispatchEvent(event);
434
+ }}
435
+ 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"
436
+ title="Open terminal with claude -c"
437
+ >
438
+ Terminal
439
+ </button>
440
+ <button
441
+ onClick={() => { fetchGitInfo(); fetchTree(); if (selectedFile) openFile(selectedFile); }}
442
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
443
+ title="Refresh"
444
+ >
445
+
446
+ </button>
447
+ </div>
448
+ </div>
449
+ <div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
450
+ {projectPath}
451
+ {gitInfo?.remote && (
452
+ <span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
453
+ )}
454
+ </div>
455
+ {/* Tab switcher */}
456
+ <div className="flex items-center gap-2 mt-1.5">
457
+ <div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
458
+ <button
459
+ onClick={() => setProjectTab('code')}
460
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
461
+ projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
462
+ }`}
463
+ >Code</button>
464
+ <button
465
+ onClick={() => setProjectTab('skills')}
466
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
467
+ projectTab === 'skills' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
468
+ }`}
469
+ >
470
+ Skills & Cmds
471
+ {projectSkills.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({projectSkills.length})</span>}
472
+ {projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[8px] text-[var(--yellow)]">!</span>}
473
+ </button>
474
+ <button
475
+ onClick={() => setProjectTab('claudemd')}
476
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
477
+ projectTab === 'claudemd' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
478
+ }`}
479
+ >
480
+ CLAUDE.md
481
+ {claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
482
+ </button>
483
+ <button
484
+ onClick={() => setProjectTab('issues')}
485
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
486
+ projectTab === 'issues' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
487
+ }`}
488
+ >
489
+ Issues
490
+ {issueConfig?.enabled && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
491
+ </button>
492
+ </div>
493
+ </div>
494
+ {projectTab === 'code' && gitInfo?.lastCommit && (
495
+ <div className="flex items-center gap-2 mt-0.5">
496
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
497
+ <button
498
+ onClick={() => setShowLog(v => !v)}
499
+ 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'}`}
500
+ >
501
+ History
502
+ </button>
503
+ </div>
504
+ )}
505
+ </div>
506
+
507
+ {/* Git log */}
508
+ {projectTab === 'code' && showLog && gitInfo?.log && gitInfo.log.length > 0 && (
509
+ <div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
510
+ {gitInfo.log.map(c => (
511
+ <div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
512
+ <span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
513
+ <span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
514
+ <span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
515
+ <span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
516
+ </div>
517
+ ))}
518
+ </div>
519
+ )}
520
+
521
+ {/* Code content area */}
522
+ {projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
523
+ {/* File tree */}
524
+ <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
525
+ {fileTree.map((node: any) => (
526
+ <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
527
+ ))}
528
+ </div>
529
+
530
+ {/* File content */}
531
+ <div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]" style={{ width: 0 }}>
532
+ {/* Diff view */}
533
+ {diffContent !== null && diffFile ? (
534
+ <>
535
+ <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">
536
+ <span className="text-[var(--yellow)]">DIFF</span>
537
+ <span className="text-[var(--text-secondary)]">{diffFile}</span>
538
+ <button onClick={() => { if (diffFile) openFile(diffFile); }} className="ml-auto text-[9px] text-[var(--accent)] hover:underline">Open Source</button>
539
+ </div>
540
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
541
+ {diffContent.split('\n').map((line, i) => {
542
+ const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
543
+ : line.startsWith('-') ? 'text-red-400 bg-red-900/20'
544
+ : line.startsWith('@@') ? 'text-cyan-400'
545
+ : line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
546
+ : 'text-[var(--text-primary)]';
547
+ return <div key={i} className={`${color} px-2`}>{line || ' '}</div>;
548
+ })}
549
+ </pre>
550
+ </>
551
+ ) : fileLoading ? (
552
+ <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
553
+ ) : selectedFile && fileContent !== null ? (
554
+ <>
555
+ <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>
556
+ <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 }}>
557
+ {fileContent.split('\n').map((line, i) => (
558
+ <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
559
+ <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
560
+ <span className="flex-1">{highlightLine(line)}</span>
561
+ </div>
562
+ ))}
563
+ </pre>
564
+ </>
565
+ ) : (
566
+ <div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
567
+ Select a file to view
568
+ </div>
569
+ )}
570
+ </div>
571
+ </div>}
572
+
573
+ {/* Skills & Commands tab */}
574
+ {projectTab === 'skills' && (
575
+ <div className="flex-1 flex min-h-0 overflow-hidden">
576
+ {/* Left: skill/command tree */}
577
+ <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
578
+ {projectSkills.length === 0 ? (
579
+ <p className="text-[9px] text-[var(--text-secondary)] p-2">No skills or commands installed</p>
580
+ ) : (
581
+ projectSkills.map(s => (
582
+ <div key={`${s.name}-${s.scope}-${s.source}`}>
583
+ <button
584
+ onClick={() => toggleSkillItem(s.name, s.type, s.scope)}
585
+ className={`w-full text-left px-2 py-1 text-[10px] rounded flex items-center gap-1.5 group ${
586
+ expandedSkillItem === s.name ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
587
+ }`}
588
+ >
589
+ <span className="text-[8px] text-[var(--text-secondary)]">{expandedSkillItem === s.name ? '▾' : '▸'}</span>
590
+ <span className={`text-[7px] px-1 rounded font-medium shrink-0 ${
591
+ s.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
592
+ }`}>{s.type === 'skill' ? 'S' : 'C'}</span>
593
+ <span className="truncate flex-1">{s.name}</span>
594
+ <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>
595
+ {s.hasUpdate && <span className="text-[7px] text-[var(--yellow)] shrink-0">!</span>}
596
+ {s.source === 'local' && <span className="text-[7px] text-[var(--text-secondary)] shrink-0">local</span>}
597
+ {s.source === 'registry' && <span className="text-[7px] text-[var(--accent)] shrink-0">mkt</span>}
598
+ <span
599
+ onClick={(e) => { e.stopPropagation(); uninstallSkill(s.name, s.scope); }}
600
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0 opacity-0 group-hover:opacity-100 cursor-pointer"
601
+ >x</span>
602
+ </button>
603
+ {/* Expanded file list */}
604
+ {expandedSkillItem === s.name && skillItemFiles.length > 0 && (
605
+ <div className="ml-4">
606
+ {skillItemFiles.map(f => (
607
+ <button
608
+ key={f.path}
609
+ onClick={() => loadSkillFile(s.name, s.type, f.path, s.scope === 'global' ? '' : projectPath)}
610
+ className={`w-full text-left px-2 py-0.5 text-[9px] rounded truncate ${
611
+ skillActivePath === f.path ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
612
+ }`}
613
+ title={f.path}
614
+ >
615
+ {f.path.split('/').pop()}
616
+ </button>
617
+ ))}
618
+ </div>
619
+ )}
620
+ </div>
621
+ ))
622
+ )}
623
+ </div>
624
+
625
+ {/* Right: file content / editor */}
626
+ <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
627
+ {skillActivePath ? (
628
+ <>
629
+ <div className="flex items-center gap-2 px-3 py-1 border-b border-[var(--border)] shrink-0">
630
+ <span className="text-[10px] text-[var(--text-secondary)] font-mono truncate flex-1">{skillActivePath}</span>
631
+ {expandedSkillItem && (() => {
632
+ const s = projectSkills.find(x => x.name === expandedSkillItem);
633
+ return s && (
634
+ <div className="flex items-center gap-2 shrink-0">
635
+ {s.version && <span className="text-[8px] text-[var(--text-secondary)] font-mono">v{s.installedVersion || s.version}</span>}
636
+ {s.hasUpdate && (
637
+ <button
638
+ onClick={() => handleUpdate(s.name)}
639
+ className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--yellow)]/20 text-[var(--yellow)] hover:bg-[var(--yellow)]/30"
640
+ >Update → v{s.version}</button>
641
+ )}
642
+ </div>
643
+ );
644
+ })()}
645
+ {!skillEditing ? (
646
+ <button
647
+ onClick={() => { setSkillEditing(true); setSkillEditContent(skillFileContent); }}
648
+ className="text-[9px] text-[var(--accent)] hover:underline shrink-0"
649
+ >Edit</button>
650
+ ) : (
651
+ <div className="flex gap-1 shrink-0">
652
+ <button
653
+ onClick={() => { if (expandedSkillItem) saveSkillFile(expandedSkillItem, projectSkills.find(x => x.name === expandedSkillItem)?.type || 'command', skillActivePath); }}
654
+ disabled={skillSaving}
655
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
656
+ >{skillSaving ? '...' : 'Save'}</button>
657
+ <button
658
+ onClick={() => setSkillEditing(false)}
659
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
660
+ >Cancel</button>
661
+ </div>
662
+ )}
663
+ </div>
664
+ <div className="flex-1 overflow-auto">
665
+ {skillEditing ? (
666
+ <textarea
667
+ value={skillEditContent}
668
+ onChange={e => setSkillEditContent(e.target.value)}
669
+ 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"
670
+ spellCheck={false}
671
+ />
672
+ ) : (
673
+ <pre className="p-3 text-[11px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
674
+ {skillFileContent}
675
+ </pre>
676
+ )}
677
+ </div>
678
+ </>
679
+ ) : (
680
+ <div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
681
+ Select a skill or command to view
682
+ </div>
683
+ )}
684
+ </div>
685
+ </div>
686
+ )}
687
+
688
+ {/* CLAUDE.md tab */}
689
+ {projectTab === 'claudemd' && (
690
+ <div className="flex-1 flex min-h-0 overflow-hidden">
691
+ {/* Left: templates list */}
692
+ <div className="w-52 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
693
+ <button
694
+ onClick={() => { setClaudeSelectedTemplate(null); setClaudeEditing(false); }}
695
+ className={`w-full px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-left flex items-center gap-1 ${
696
+ !claudeSelectedTemplate && !claudeEditing ? 'text-[var(--accent)] bg-[var(--accent)]/5' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
697
+ }`}
698
+ >
699
+ <span className="font-mono">CLAUDE.md</span>
700
+ {claudeMdExists && <span className="text-[var(--green)] text-[8px]">•</span>}
701
+ </button>
702
+ <div className="px-2 py-1 border-b border-[var(--border)] text-[8px] text-[var(--text-secondary)] uppercase">Templates</div>
703
+ <div className="flex-1 overflow-y-auto">
704
+ {claudeTemplates.map(t => {
705
+ const injected = claudeInjectedIds.has(t.id);
706
+ const isSelected = claudeSelectedTemplate === t.id;
707
+ return (
708
+ <div
709
+ key={t.id}
710
+ className={`px-2 py-1.5 border-b border-[var(--border)]/30 cursor-pointer ${isSelected ? 'bg-[var(--accent)]/10' : 'hover:bg-[var(--bg-tertiary)]'}`}
711
+ onClick={() => setClaudeSelectedTemplate(isSelected ? null : t.id)}
712
+ >
713
+ <div className="flex items-center gap-1.5">
714
+ <span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
715
+ {t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
716
+ {injected ? (
717
+ <button
718
+ onClick={(e) => { e.stopPropagation(); removeFromProject(t.id); }}
719
+ className="text-[7px] px-1 rounded bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400"
720
+ title="Remove from CLAUDE.md"
721
+ >added</button>
722
+ ) : (
723
+ <button
724
+ onClick={(e) => { e.stopPropagation(); injectToProject(t.id); }}
725
+ className="text-[7px] px-1 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
726
+ title="Add to CLAUDE.md"
727
+ >+ add</button>
728
+ )}
729
+ </div>
730
+ <p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
731
+ </div>
732
+ );
733
+ })}
734
+ </div>
735
+ </div>
736
+
737
+ {/* Right: CLAUDE.md content or template preview */}
738
+ <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
739
+ {/* Header bar */}
740
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--border)] shrink-0">
741
+ {claudeSelectedTemplate ? (
742
+ <>
743
+ <span className="text-[10px] text-[var(--text-secondary)]">Preview:</span>
744
+ <span className="text-[10px] text-[var(--text-primary)] font-semibold">{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.name}</span>
745
+ <button
746
+ onClick={() => setClaudeSelectedTemplate(null)}
747
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
748
+ >Show CLAUDE.md</button>
749
+ </>
750
+ ) : (
751
+ <>
752
+ <span className="text-[10px] text-[var(--text-primary)] font-mono">CLAUDE.md</span>
753
+ {!claudeMdExists && <span className="text-[8px] text-[var(--yellow)]">not created</span>}
754
+ <div className="flex items-center gap-1 ml-auto">
755
+ {!claudeEditing ? (
756
+ <button
757
+ onClick={() => { setClaudeEditing(true); setClaudeEditContent(claudeMdContent); }}
758
+ className="text-[9px] text-[var(--accent)] hover:underline"
759
+ >Edit</button>
760
+ ) : (
761
+ <>
762
+ <button
763
+ onClick={() => saveClaudeMd(claudeEditContent)}
764
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
765
+ >Save</button>
766
+ <button
767
+ onClick={() => setClaudeEditing(false)}
768
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
769
+ >Cancel</button>
770
+ </>
771
+ )}
772
+ </div>
773
+ </>
774
+ )}
775
+ </div>
776
+
777
+ {/* Content */}
778
+ <div className="flex-1 overflow-auto" style={{ width: 0, minWidth: '100%' }}>
779
+ {claudeSelectedTemplate ? (
780
+ <pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
781
+ {claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.content || ''}
782
+ </pre>
783
+ ) : claudeEditing ? (
784
+ <textarea
785
+ value={claudeEditContent}
786
+ onChange={e => setClaudeEditContent(e.target.value)}
787
+ 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"
788
+ spellCheck={false}
789
+ />
790
+ ) : (
791
+ <pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
792
+ {claudeMdContent || '(Empty — add templates or edit directly)'}
793
+ </pre>
794
+ )}
795
+ </div>
796
+ </div>
797
+ </div>
798
+ )}
799
+
800
+ {/* Issues tab */}
801
+ {projectTab === 'issues' && issueConfig && (
802
+ <div className="flex-1 overflow-auto p-4 space-y-4">
803
+ {/* Config */}
804
+ <div className="space-y-3">
805
+ <div className="flex items-center gap-3">
806
+ <label className="flex items-center gap-2 cursor-pointer">
807
+ <input
808
+ type="checkbox"
809
+ checked={issueConfig.enabled}
810
+ onChange={e => setIssueConfig({ ...issueConfig, enabled: e.target.checked })}
811
+ className="accent-[var(--accent)]"
812
+ />
813
+ <span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
814
+ </label>
815
+ {issueConfig.enabled && (<>
816
+ <button
817
+ onClick={() => scanNow()}
818
+ disabled={issueScanning}
819
+ className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
820
+ >
821
+ {issueScanning ? 'Scanning...' : 'Scan Now'}
822
+ </button>
823
+ {issueLastScan && (
824
+ <span className="text-[8px] text-[var(--text-secondary)]">
825
+ Last: {new Date(issueLastScan).toLocaleTimeString()}
826
+ </span>
827
+ )}
828
+ {issueNextScan && (
829
+ <span className="text-[8px] text-[var(--text-secondary)]">
830
+ Next: {new Date(issueNextScan).toLocaleTimeString()}
831
+ </span>
832
+ )}
833
+ </> )}
834
+ </div>
835
+
836
+ {issueConfig.enabled && (
837
+ <div className="grid grid-cols-2 gap-3">
838
+ <div>
839
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Scan Interval (minutes, 0=manual)</label>
840
+ <input
841
+ type="number"
842
+ value={issueConfig.interval}
843
+ onChange={e => setIssueConfig({ ...issueConfig, interval: parseInt(e.target.value) || 0 })}
844
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
845
+ />
846
+ </div>
847
+ <div>
848
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Base Branch (empty=auto)</label>
849
+ <input
850
+ type="text"
851
+ value={issueConfig.baseBranch}
852
+ onChange={e => setIssueConfig({ ...issueConfig, baseBranch: e.target.value })}
853
+ placeholder="main"
854
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
855
+ />
856
+ </div>
857
+ <div className="col-span-2">
858
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Labels Filter (comma-separated, empty=all)</label>
859
+ <input
860
+ type="text"
861
+ value={issueConfig.labels.join(', ')}
862
+ onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
863
+ placeholder="bug, fix"
864
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
865
+ />
866
+ </div>
867
+ </div>
868
+ )}
869
+ <div className="mt-3">
870
+ <button
871
+ onClick={() => saveIssueConfig(issueConfig)}
872
+ className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
873
+ >Save Configuration</button>
874
+ </div>
875
+ </div>
876
+
877
+ {/* Manual trigger */}
878
+ <div className="border-t border-[var(--border)] pt-3">
879
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Manual Trigger</div>
880
+ <div className="flex gap-2">
881
+ <input
882
+ type="text"
883
+ value={issueManualId}
884
+ onChange={e => setIssueManualId(e.target.value)}
885
+ placeholder="Issue #"
886
+ className="w-24 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
887
+ />
888
+ <button
889
+ onClick={() => issueManualId && triggerIssue(issueManualId)}
890
+ disabled={!issueManualId}
891
+ className="text-[9px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
892
+ >Fix Issue</button>
893
+ </div>
894
+ </div>
895
+
896
+ {/* History */}
897
+ {issueProcessed.length > 0 && (
898
+ <div className="border-t border-[var(--border)] pt-3">
899
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Processed Issues</div>
900
+ <div className="border border-[var(--border)] rounded overflow-hidden">
901
+ {issueProcessed.map(p => (
902
+ <div key={p.issueNumber} className="border-b border-[var(--border)]/30 last:border-b-0">
903
+ <div className="flex items-center gap-2 px-3 py-1.5 text-[10px]">
904
+ <span className="text-[var(--text-primary)] font-mono">#{p.issueNumber}</span>
905
+ <span className={`text-[8px] px-1 rounded ${
906
+ p.status === 'done' ? 'bg-green-500/10 text-green-400' :
907
+ p.status === 'failed' ? 'bg-red-500/10 text-red-400' :
908
+ 'bg-yellow-500/10 text-yellow-400'
909
+ }`}>{p.status}</span>
910
+ {p.prNumber && <span className="text-[var(--accent)]">PR #{p.prNumber}</span>}
911
+ {p.pipelineId && (
912
+ <button
913
+ onClick={() => {
914
+ const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
915
+ window.dispatchEvent(event);
916
+ }}
917
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
918
+ title="View pipeline"
919
+ >{p.pipelineId.slice(0, 8)}</button>
920
+ )}
921
+ <span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
922
+ <div className="ml-auto flex gap-1">
923
+ {(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
924
+ <button
925
+ onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
926
+ className="text-[8px] text-[var(--accent)] hover:underline"
927
+ >Retry</button>
928
+ )}
929
+ <button
930
+ onClick={async () => {
931
+ if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
932
+ await fetch('/api/issue-scanner', {
933
+ method: 'POST',
934
+ headers: { 'Content-Type': 'application/json' },
935
+ body: JSON.stringify({ action: 'reset', projectPath, issueId: p.issueNumber }),
936
+ });
937
+ fetchIssueConfig();
938
+ }}
939
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
940
+ >Delete</button>
941
+ </div>
942
+ </div>
943
+ </div>
944
+ ))}
945
+ </div>
946
+ </div>
947
+ )}
948
+ </div>
949
+ )}
950
+
951
+ {/* Git panel — bottom (code tab only) */}
952
+ {projectTab === 'code' && gitInfo && (
953
+ <div className="border-t border-[var(--border)] shrink-0">
954
+ {/* Changes list */}
955
+ {gitInfo.changes.length > 0 && (
956
+ <div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
957
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
958
+ {gitInfo.changes.length} changes
959
+ </div>
960
+ {gitInfo.changes.map(g => (
961
+ <div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
962
+ <span className={`text-[10px] font-mono w-4 shrink-0 ${
963
+ g.status.includes('M') ? 'text-yellow-500' :
964
+ g.status.includes('?') ? 'text-green-500' :
965
+ g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
966
+ }`}>
967
+ {g.status.includes('?') ? '+' : g.status[0]}
968
+ </span>
969
+ <button
970
+ onClick={() => openDiff(g.path)}
971
+ className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
972
+ title="View diff"
973
+ >
974
+ {g.path}
975
+ </button>
976
+ <button
977
+ onClick={() => openFile(g.path)}
978
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
979
+ title="Open source file"
980
+ >
981
+ src
982
+ </button>
983
+ </div>
984
+ ))}
985
+ </div>
986
+ )}
987
+
988
+ {/* Git actions */}
989
+ <div className="px-3 py-2 flex items-center gap-2">
990
+ <input
991
+ value={commitMsg}
992
+ onChange={e => setCommitMsg(e.target.value)}
993
+ onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
994
+ placeholder="Commit message..."
995
+ 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)]"
996
+ />
997
+ <button
998
+ onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
999
+ disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
1000
+ className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
1001
+ >
1002
+ Commit
1003
+ </button>
1004
+ <button
1005
+ onClick={() => gitAction('push')}
1006
+ disabled={gitLoading || gitInfo.ahead === 0}
1007
+ 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"
1008
+ >
1009
+ Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
1010
+ </button>
1011
+ <button
1012
+ onClick={() => gitAction('pull')}
1013
+ disabled={gitLoading}
1014
+ className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
1015
+ >
1016
+ Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
1017
+ </button>
1018
+ </div>
1019
+
1020
+ {/* Result */}
1021
+ {gitResult && (
1022
+ <div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
1023
+ {gitResult.ok ? 'Done' : gitResult.error}
1024
+ </div>
1025
+ )}
1026
+ </div>
1027
+ )}
1028
+
1029
+ {/* Retry modal */}
1030
+ {retryModal && (
1031
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
1032
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
1033
+ <div className="px-4 py-3 border-b border-[var(--border)]">
1034
+ <h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
1035
+ <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
1036
+ </div>
1037
+ <div className="p-4">
1038
+ <textarea
1039
+ value={retryModal.context}
1040
+ onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
1041
+ placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
1042
+ className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
1043
+ autoFocus
1044
+ />
1045
+ </div>
1046
+ <div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
1047
+ <button
1048
+ onClick={() => setRetryModal(null)}
1049
+ className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1050
+ >Cancel</button>
1051
+ <button
1052
+ onClick={async () => {
1053
+ await fetch('/api/issue-scanner', {
1054
+ method: 'POST',
1055
+ headers: { 'Content-Type': 'application/json' },
1056
+ body: JSON.stringify({
1057
+ action: 'retry',
1058
+ projectPath,
1059
+ projectName,
1060
+ issueId: retryModal.issueNumber,
1061
+ context: retryModal.context,
1062
+ }),
1063
+ });
1064
+ setRetryModal(null);
1065
+ fetchIssueConfig();
1066
+ }}
1067
+ className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
1068
+ >Retry</button>
1069
+ </div>
1070
+ </div>
1071
+ </div>
1072
+ )}
1073
+ </>
1074
+ );
1075
+ });
1076
+
1077
+ // Simple file tree node
1078
+ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect }: {
1079
+ node: { name: string; path: string; type: string; children?: any[] };
1080
+ depth: number;
1081
+ selected: string | null;
1082
+ onSelect: (path: string) => void;
1083
+ }) {
1084
+ const [expanded, setExpanded] = useState(depth < 1);
1085
+
1086
+ if (node.type === 'dir') {
1087
+ return (
1088
+ <div>
1089
+ <button
1090
+ onClick={() => setExpanded(v => !v)}
1091
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
1092
+ style={{ paddingLeft: depth * 12 + 4 }}
1093
+ >
1094
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
1095
+ <span className="text-[var(--text-primary)]">{node.name}</span>
1096
+ </button>
1097
+ {expanded && node.children?.map((child: any) => (
1098
+ <FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
1099
+ ))}
1100
+ </div>
1101
+ );
1102
+ }
1103
+
1104
+ return (
1105
+ <button
1106
+ onClick={() => onSelect(node.path)}
1107
+ className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
1108
+ selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
1109
+ }`}
1110
+ style={{ paddingLeft: depth * 12 + 16 }}
1111
+ >
1112
+ {node.name}
1113
+ </button>
1114
+ );
1115
+ });