@aion0/forge 0.4.8 → 0.4.10
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 +11 -4
- package/app/api/help/route.ts +10 -4
- package/app/api/preview/route.ts +8 -0
- package/components/BrowserPanel.tsx +169 -0
- package/components/CodeViewer.tsx +1 -1
- package/components/Dashboard.tsx +160 -20
- package/components/HelpTerminal.tsx +8 -2
- package/components/ProjectDetail.tsx +5 -5
- package/components/WebTerminal.tsx +46 -14
- package/lib/init.ts +17 -0
- package/lib/pipeline-scheduler.ts +18 -6
- package/package.json +1 -1
- package/components/PreviewPanel.tsx +0 -167
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.10
|
|
2
2
|
|
|
3
3
|
Released: 2026-03-22
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.9
|
|
6
6
|
|
|
7
7
|
### Features
|
|
8
|
-
- feat:
|
|
8
|
+
- feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
|
|
9
9
|
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
- fix: auto-retry failed issues, batch dedup, gh auth hint
|
|
12
|
+
- feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
### Refactoring
|
|
15
|
+
- refactor: Browser as independent panel with float/left/right modes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.9...v0.4.10
|
package/app/api/help/route.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { getConfigDir } from '@/lib/dirs';
|
|
4
|
+
import { getConfigDir, getDataDir } from '@/lib/dirs';
|
|
5
5
|
import { loadSettings } from '@/lib/settings';
|
|
6
6
|
import { execSync } from 'node:child_process';
|
|
7
7
|
|
|
8
8
|
const HELP_DIR = join(getConfigDir(), 'help');
|
|
9
9
|
const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
|
|
10
10
|
|
|
11
|
-
/** Ensure help docs are copied to ~/.forge/help/ */
|
|
11
|
+
/** Ensure help docs are copied to ~/.forge/help/ and CLAUDE.md to ~/.forge/data/ */
|
|
12
12
|
function ensureHelpDocs() {
|
|
13
13
|
if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
|
|
14
14
|
if (existsSync(SOURCE_HELP_DIR)) {
|
|
@@ -16,10 +16,16 @@ function ensureHelpDocs() {
|
|
|
16
16
|
if (!file.endsWith('.md')) continue;
|
|
17
17
|
const src = join(SOURCE_HELP_DIR, file);
|
|
18
18
|
const dest = join(HELP_DIR, file);
|
|
19
|
-
// Always overwrite to keep docs up to date
|
|
20
19
|
writeFileSync(dest, readFileSync(src));
|
|
21
20
|
}
|
|
22
21
|
}
|
|
22
|
+
// Copy CLAUDE.md to data dir so Help AI (working in ~/.forge/data/) picks it up
|
|
23
|
+
const dataDir = getDataDir();
|
|
24
|
+
const claudeMdSrc = join(HELP_DIR, 'CLAUDE.md');
|
|
25
|
+
const claudeMdDest = join(dataDir, 'CLAUDE.md');
|
|
26
|
+
if (existsSync(claudeMdSrc)) {
|
|
27
|
+
writeFileSync(claudeMdDest, readFileSync(claudeMdSrc));
|
|
28
|
+
}
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
/** Check if any agent CLI is available */
|
|
@@ -51,7 +57,7 @@ export async function GET(req: Request) {
|
|
|
51
57
|
const docs = existsSync(HELP_DIR)
|
|
52
58
|
? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
|
|
53
59
|
: [];
|
|
54
|
-
return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
|
|
60
|
+
return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR, dataDir: getDataDir() });
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
if (action === 'docs') {
|
package/app/api/preview/route.ts
CHANGED
|
@@ -62,6 +62,14 @@ export async function POST(req: Request) {
|
|
|
62
62
|
const entry = state.entries.get(body.port);
|
|
63
63
|
if (entry?.process) {
|
|
64
64
|
entry.process.kill('SIGTERM');
|
|
65
|
+
} else {
|
|
66
|
+
// Process ref lost (hot-reload) — kill by port match
|
|
67
|
+
try {
|
|
68
|
+
const pids = execSync(`pgrep -f 'cloudflared tunnel.*localhost:${body.port}'`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
69
|
+
for (const pid of pids.split('\n').filter(Boolean)) {
|
|
70
|
+
try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
65
73
|
}
|
|
66
74
|
state.entries.delete(body.port);
|
|
67
75
|
syncConfig();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PreviewEntry {
|
|
6
|
+
port: number;
|
|
7
|
+
url: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
|
|
13
|
+
const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
|
|
14
|
+
const [browserKey, setBrowserKey] = useState(0);
|
|
15
|
+
const [previews, setPreviews] = useState<PreviewEntry[]>([]);
|
|
16
|
+
const [tunnelStarting, setTunnelStarting] = useState(false);
|
|
17
|
+
const browserUrlRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
const isRemote = typeof window !== 'undefined' && !['localhost', '127.0.0.1'].includes(window.location.hostname);
|
|
19
|
+
|
|
20
|
+
const fetchPreviews = useCallback(() => {
|
|
21
|
+
fetch('/api/preview').then(r => r.json()).then(data => {
|
|
22
|
+
if (Array.isArray(data)) setPreviews(data.filter((p: any) => p.status === 'running'));
|
|
23
|
+
}).catch(() => {});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
fetchPreviews();
|
|
28
|
+
const timer = setInterval(fetchPreviews, 10000);
|
|
29
|
+
return () => clearInterval(timer);
|
|
30
|
+
}, [fetchPreviews]);
|
|
31
|
+
|
|
32
|
+
const navigate = (url: string) => {
|
|
33
|
+
setBrowserUrl(url);
|
|
34
|
+
localStorage.setItem('forge-browser-url', url);
|
|
35
|
+
if (browserUrlRef.current) browserUrlRef.current.value = url;
|
|
36
|
+
setBrowserKey(k => k + 1);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleTunnel = async () => {
|
|
40
|
+
const input = prompt('Enter port(s) to create tunnel (e.g. 3100 or 3100,8080):');
|
|
41
|
+
if (!input) return;
|
|
42
|
+
const ports = input.split(',').map(s => parseInt(s.trim())).filter(p => p > 0 && p <= 65535);
|
|
43
|
+
if (ports.length === 0) { alert('Invalid port(s)'); return; }
|
|
44
|
+
setTunnelStarting(true);
|
|
45
|
+
const results: string[] = [];
|
|
46
|
+
for (const port of ports) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch('/api/preview', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ action: 'start', port }),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (data.url) {
|
|
55
|
+
results.push(data.url);
|
|
56
|
+
setPreviews(prev => {
|
|
57
|
+
const exists = prev.find(p => p.port === port);
|
|
58
|
+
if (exists) return prev.map(p => p.port === port ? { ...p, url: data.url, status: 'running' } : p);
|
|
59
|
+
return [...prev, { port, url: data.url, status: 'running' }];
|
|
60
|
+
});
|
|
61
|
+
} else if (data.status === 'starting' || data.status === 'stopped') {
|
|
62
|
+
// Tunnel started but URL not ready yet or exited
|
|
63
|
+
results.push('');
|
|
64
|
+
} else {
|
|
65
|
+
alert(`Port ${port}: ${data.error || 'Failed'}`);
|
|
66
|
+
}
|
|
67
|
+
} catch { alert(`Port ${port}: Failed to start tunnel`); }
|
|
68
|
+
}
|
|
69
|
+
// Navigate to first successful URL
|
|
70
|
+
const firstUrl = results.find(u => u);
|
|
71
|
+
if (firstUrl) navigate(firstUrl);
|
|
72
|
+
// Refresh list to pick up any that were still starting
|
|
73
|
+
setTimeout(fetchPreviews, 3000);
|
|
74
|
+
setTunnelStarting(false);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const stopTunnel = async (port: number) => {
|
|
78
|
+
await fetch('/api/preview', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ action: 'stop', port }),
|
|
82
|
+
});
|
|
83
|
+
setPreviews(prev => prev.filter(x => x.port !== port));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
88
|
+
{/* URL bar */}
|
|
89
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
|
|
90
|
+
<input
|
|
91
|
+
ref={browserUrlRef}
|
|
92
|
+
type="text"
|
|
93
|
+
defaultValue={browserUrl}
|
|
94
|
+
placeholder="Enter URL"
|
|
95
|
+
onKeyDown={e => {
|
|
96
|
+
if (e.key === 'Enter') {
|
|
97
|
+
const val = (e.target as HTMLInputElement).value.trim();
|
|
98
|
+
if (!val) return;
|
|
99
|
+
const url = /^\d+$/.test(val) ? `http://localhost:${val}` : val;
|
|
100
|
+
navigate(url);
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
|
|
104
|
+
/>
|
|
105
|
+
<button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
|
|
106
|
+
<button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
|
|
107
|
+
<button
|
|
108
|
+
disabled={tunnelStarting}
|
|
109
|
+
onClick={handleTunnel}
|
|
110
|
+
className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--accent)] disabled:opacity-50"
|
|
111
|
+
title="Create tunnel for a port (remote access)"
|
|
112
|
+
>{tunnelStarting ? 'Starting...' : 'Tunnel'}</button>
|
|
113
|
+
{onClose && (
|
|
114
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
{/* Active tunnels bar */}
|
|
118
|
+
{previews.length > 0 && (
|
|
119
|
+
<div className="flex items-center gap-1 px-2 py-0.5 border-b border-[var(--border)]/50 bg-[var(--bg-secondary)] shrink-0 overflow-x-auto">
|
|
120
|
+
{previews.map(p => (
|
|
121
|
+
<div key={p.port} className="flex items-center gap-1 shrink-0">
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => {
|
|
124
|
+
const url = isRemote && p.url ? p.url : `http://localhost:${p.port}`;
|
|
125
|
+
navigate(url);
|
|
126
|
+
}}
|
|
127
|
+
className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
128
|
+
>
|
|
129
|
+
<span className="text-green-400 mr-0.5">●</span>
|
|
130
|
+
:{p.port}
|
|
131
|
+
</button>
|
|
132
|
+
{p.url && (
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => navigator.clipboard.writeText(p.url!).then(() => alert('Tunnel URL copied'))}
|
|
135
|
+
className="text-[8px] text-green-400 hover:underline truncate max-w-[120px]"
|
|
136
|
+
title={p.url}
|
|
137
|
+
>{p.url.replace('https://', '').slice(0, 20)}...</button>
|
|
138
|
+
)}
|
|
139
|
+
<button onClick={() => stopTunnel(p.port)} className="text-[8px] text-red-400 hover:text-red-300">✕</button>
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
{/* Content */}
|
|
145
|
+
<div className="flex-1 relative">
|
|
146
|
+
{tunnelStarting && (
|
|
147
|
+
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs z-10 bg-[var(--bg-primary)]/80">
|
|
148
|
+
Creating tunnel... this may take up to 30 seconds
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
{browserUrl ? (
|
|
152
|
+
<iframe
|
|
153
|
+
key={browserKey}
|
|
154
|
+
src={browserUrl}
|
|
155
|
+
className="absolute inset-0 w-full h-full border-0 bg-white"
|
|
156
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
|
160
|
+
<div className="text-center space-y-1">
|
|
161
|
+
<p>Enter a URL or port number and press Enter</p>
|
|
162
|
+
<p className="text-[9px]">Click Tunnel to create a public URL for remote access</p>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -420,7 +420,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
420
420
|
</div>
|
|
421
421
|
)}
|
|
422
422
|
|
|
423
|
-
{/* Terminal —
|
|
423
|
+
{/* Terminal + Browser — main area */}
|
|
424
424
|
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
425
425
|
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
426
426
|
<WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
|
package/components/Dashboard.tsx
CHANGED
|
@@ -16,7 +16,7 @@ const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
|
16
16
|
const DocsViewer = lazy(() => import('./DocsViewer'));
|
|
17
17
|
const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
18
18
|
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
19
|
-
const
|
|
19
|
+
const BrowserPanel = lazy(() => import('./BrowserPanel'));
|
|
20
20
|
const PipelineView = lazy(() => import('./PipelineView'));
|
|
21
21
|
const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
22
22
|
const LogViewer = lazy(() => import('./LogViewer'));
|
|
@@ -42,8 +42,64 @@ interface ProjectInfo {
|
|
|
42
42
|
language: string | null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function FloatingBrowser({ onClose }: { onClose: () => void }) {
|
|
46
|
+
const [pos, setPos] = useState({ x: 60, y: 60 });
|
|
47
|
+
const [size, setSize] = useState({ w: 700, h: 500 });
|
|
48
|
+
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
49
|
+
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
54
|
+
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-tertiary)] border-b border-[var(--border)] cursor-move shrink-0 select-none"
|
|
58
|
+
onMouseDown={(e) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
61
|
+
const onMove = (ev: MouseEvent) => {
|
|
62
|
+
if (!dragRef.current) return;
|
|
63
|
+
setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
|
|
64
|
+
};
|
|
65
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
66
|
+
window.addEventListener('mousemove', onMove);
|
|
67
|
+
window.addEventListener('mouseup', onUp);
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Browser</span>
|
|
71
|
+
<button onClick={onClose} className="ml-auto text-[var(--text-secondary)] hover:text-[var(--red)] text-sm leading-none">✕</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
74
|
+
<BrowserPanel />
|
|
75
|
+
</div>
|
|
76
|
+
<div
|
|
77
|
+
onMouseDown={(e) => {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
|
|
81
|
+
const onMove = (ev: MouseEvent) => {
|
|
82
|
+
if (!resizeRef.current) return;
|
|
83
|
+
setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(300, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
|
|
84
|
+
};
|
|
85
|
+
const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
86
|
+
window.addEventListener('mousemove', onMove);
|
|
87
|
+
window.addEventListener('mouseup', onUp);
|
|
88
|
+
}}
|
|
89
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
|
|
90
|
+
style={{ background: 'linear-gradient(135deg, transparent 50%, var(--border) 50%)' }}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
45
96
|
export default function Dashboard({ user }: { user: any }) {
|
|
46
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | '
|
|
97
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs'>('terminal');
|
|
98
|
+
const [browserMode, setBrowserMode] = useState<'none' | 'float' | 'right' | 'left'>('none');
|
|
99
|
+
const [showBrowserMenu, setShowBrowserMenu] = useState(false);
|
|
100
|
+
const [browserWidth, setBrowserWidth] = useState(600);
|
|
101
|
+
const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
|
102
|
+
const [browserDragging, setBrowserDragging] = useState(false);
|
|
47
103
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
48
104
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
49
105
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
@@ -172,7 +228,34 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
172
228
|
const queued = tasks.filter(t => t.status === 'queued');
|
|
173
229
|
|
|
174
230
|
return (
|
|
175
|
-
<div className="h-screen flex
|
|
231
|
+
<div className="h-screen flex">
|
|
232
|
+
{/* Browser — left side */}
|
|
233
|
+
{browserMode === 'left' && (
|
|
234
|
+
<>
|
|
235
|
+
<div style={{ width: browserWidth }} className="shrink-0 flex flex-col relative">
|
|
236
|
+
<Suspense fallback={null}><BrowserPanel onClose={() => setBrowserMode('none')} /></Suspense>
|
|
237
|
+
{browserDragging && <div className="absolute inset-0 z-10" />}
|
|
238
|
+
</div>
|
|
239
|
+
<div
|
|
240
|
+
onMouseDown={(e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
browserDragRef.current = { startX: e.clientX, startW: browserWidth };
|
|
243
|
+
setBrowserDragging(true);
|
|
244
|
+
const onMove = (ev: MouseEvent) => {
|
|
245
|
+
if (!browserDragRef.current) return;
|
|
246
|
+
setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW + (ev.clientX - browserDragRef.current.startX))));
|
|
247
|
+
};
|
|
248
|
+
const onUp = () => { browserDragRef.current = null; setBrowserDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
249
|
+
window.addEventListener('mousemove', onMove);
|
|
250
|
+
window.addEventListener('mouseup', onUp);
|
|
251
|
+
}}
|
|
252
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
|
|
253
|
+
/>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Forge main area */}
|
|
258
|
+
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
|
|
176
259
|
{/* Top bar */}
|
|
177
260
|
<header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
|
|
178
261
|
<div className="flex items-center gap-4">
|
|
@@ -285,17 +368,47 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
285
368
|
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
286
369
|
}`}
|
|
287
370
|
>?</button>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
371
|
+
<div className="relative">
|
|
372
|
+
<button
|
|
373
|
+
onClick={() => setShowBrowserMenu(v => !v)}
|
|
374
|
+
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
375
|
+
browserMode !== 'none'
|
|
376
|
+
? 'border-blue-500 text-blue-400'
|
|
377
|
+
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
378
|
+
}`}
|
|
379
|
+
>
|
|
380
|
+
Browser
|
|
381
|
+
</button>
|
|
382
|
+
{showBrowserMenu && (
|
|
383
|
+
<>
|
|
384
|
+
<div className="fixed inset-0 z-40" onClick={() => setShowBrowserMenu(false)} />
|
|
385
|
+
<div className="absolute top-full right-0 mt-1 z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 min-w-[140px]">
|
|
386
|
+
{browserMode !== 'none' && (
|
|
387
|
+
<button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); }} className="w-full text-left px-3 py-1.5 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
|
|
388
|
+
Close Browser
|
|
389
|
+
</button>
|
|
390
|
+
)}
|
|
391
|
+
<button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
392
|
+
Floating Window
|
|
393
|
+
</button>
|
|
394
|
+
<button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
395
|
+
Right Side
|
|
396
|
+
</button>
|
|
397
|
+
<button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
398
|
+
Left Side
|
|
399
|
+
</button>
|
|
400
|
+
<button onClick={() => {
|
|
401
|
+
const url = localStorage.getItem('forge-browser-url');
|
|
402
|
+
if (url) window.open(url, '_blank');
|
|
403
|
+
else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
|
|
404
|
+
setShowBrowserMenu(false);
|
|
405
|
+
}} className="w-full text-left px-3 py-1.5 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
|
|
406
|
+
New Tab
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
</>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
299
412
|
<TunnelToggle />
|
|
300
413
|
{onlineCount.total > 0 && (
|
|
301
414
|
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
@@ -581,12 +694,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
581
694
|
</Suspense>
|
|
582
695
|
)}
|
|
583
696
|
|
|
584
|
-
{/* Preview */}
|
|
585
|
-
{viewMode === 'preview' && (
|
|
586
|
-
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
587
|
-
<PreviewPanel />
|
|
588
|
-
</Suspense>
|
|
589
|
-
)}
|
|
590
697
|
|
|
591
698
|
{/* Skills */}
|
|
592
699
|
{viewMode === 'skills' && (
|
|
@@ -616,6 +723,39 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
616
723
|
</Suspense>
|
|
617
724
|
</div>
|
|
618
725
|
</div>
|
|
726
|
+
</div>{/* close Forge main area */}
|
|
727
|
+
|
|
728
|
+
{/* Browser — right side */}
|
|
729
|
+
{browserMode === 'right' && (
|
|
730
|
+
<>
|
|
731
|
+
<div
|
|
732
|
+
onMouseDown={(e) => {
|
|
733
|
+
e.preventDefault();
|
|
734
|
+
browserDragRef.current = { startX: e.clientX, startW: browserWidth };
|
|
735
|
+
setBrowserDragging(true);
|
|
736
|
+
const onMove = (ev: MouseEvent) => {
|
|
737
|
+
if (!browserDragRef.current) return;
|
|
738
|
+
setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
|
|
739
|
+
};
|
|
740
|
+
const onUp = () => { browserDragRef.current = null; setBrowserDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
741
|
+
window.addEventListener('mousemove', onMove);
|
|
742
|
+
window.addEventListener('mouseup', onUp);
|
|
743
|
+
}}
|
|
744
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
|
|
745
|
+
/>
|
|
746
|
+
<div style={{ width: browserWidth }} className="shrink-0 flex flex-col relative">
|
|
747
|
+
<Suspense fallback={null}><BrowserPanel onClose={() => setBrowserMode('none')} /></Suspense>
|
|
748
|
+
{browserDragging && <div className="absolute inset-0 z-10" />}
|
|
749
|
+
</div>
|
|
750
|
+
</>
|
|
751
|
+
)}
|
|
752
|
+
|
|
753
|
+
{/* Browser — floating window */}
|
|
754
|
+
{browserMode === 'float' && (
|
|
755
|
+
<Suspense fallback={null}>
|
|
756
|
+
<FloatingBrowser onClose={() => setBrowserMode('none')} />
|
|
757
|
+
</Suspense>
|
|
758
|
+
)}
|
|
619
759
|
|
|
620
760
|
{showNewTask && (
|
|
621
761
|
<NewTaskModal
|
|
@@ -26,6 +26,8 @@ export default function HelpTerminal() {
|
|
|
26
26
|
if (!containerRef.current) return;
|
|
27
27
|
|
|
28
28
|
let disposed = false;
|
|
29
|
+
let dataDir = '~/.forge/data';
|
|
30
|
+
|
|
29
31
|
const cs = getComputedStyle(document.documentElement);
|
|
30
32
|
const tv = (name: string) => cs.getPropertyValue(name).trim();
|
|
31
33
|
const term = new Terminal({
|
|
@@ -73,7 +75,7 @@ export default function HelpTerminal() {
|
|
|
73
75
|
isNewSession = false;
|
|
74
76
|
setTimeout(() => {
|
|
75
77
|
if (socket.readyState === WebSocket.OPEN) {
|
|
76
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd
|
|
78
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && claude\n` }));
|
|
77
79
|
}
|
|
78
80
|
}, 300);
|
|
79
81
|
}
|
|
@@ -94,7 +96,11 @@ export default function HelpTerminal() {
|
|
|
94
96
|
socket.onerror = () => {};
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
connect
|
|
99
|
+
// Fetch data dir then connect
|
|
100
|
+
fetch('/api/help?action=status').then(r => r.json())
|
|
101
|
+
.then(data => { if (data.dataDir) dataDir = data.dataDir; })
|
|
102
|
+
.catch(() => {})
|
|
103
|
+
.finally(() => { if (!disposed) connect(); });
|
|
98
104
|
|
|
99
105
|
term.onData((data) => {
|
|
100
106
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
@@ -999,11 +999,11 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
999
999
|
{/* Issue scan config (for issue-fix-and-review workflow) */}
|
|
1000
1000
|
{(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
|
|
1001
1001
|
<div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
Scheduled mode: auto-scans GitHub issues and fixes new ones
|
|
1005
|
-
|
|
1006
|
-
|
|
1002
|
+
<div className="text-[8px] text-[var(--text-secondary)]">
|
|
1003
|
+
{b.config.interval > 0
|
|
1004
|
+
? 'Scheduled mode: auto-scans GitHub issues and fixes new ones'
|
|
1005
|
+
: 'Requires: gh auth login (run in terminal first)'}
|
|
1006
|
+
</div>
|
|
1007
1007
|
<div className="flex items-center gap-2 text-[9px]">
|
|
1008
1008
|
<label className="text-[var(--text-secondary)]">Labels:</label>
|
|
1009
1009
|
<input
|
|
@@ -476,6 +476,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
476
476
|
});
|
|
477
477
|
}, [activeTab, updateActiveTab]);
|
|
478
478
|
|
|
479
|
+
const closePaneById = useCallback((id: number) => {
|
|
480
|
+
updateActiveTab(t => {
|
|
481
|
+
if (countTerminals(t.tree) <= 1) return t;
|
|
482
|
+
const newTree = removeNodeById(t.tree, id) || t.tree;
|
|
483
|
+
const newActiveId = t.activeId === id ? firstTerminalId(newTree) : t.activeId;
|
|
484
|
+
return { ...t, tree: newTree, activeId: newActiveId };
|
|
485
|
+
});
|
|
486
|
+
}, [updateActiveTab]);
|
|
487
|
+
|
|
479
488
|
const setActiveId = useCallback((id: number) => {
|
|
480
489
|
updateActiveTab(t => ({ ...t, activeId: id }));
|
|
481
490
|
}, [updateActiveTab]);
|
|
@@ -634,11 +643,6 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
634
643
|
Code
|
|
635
644
|
</button>
|
|
636
645
|
)}
|
|
637
|
-
{activeTab && countTerminals(activeTab.tree) > 1 && (
|
|
638
|
-
<button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[var(--term-border)] rounded">
|
|
639
|
-
Close Pane
|
|
640
|
-
</button>
|
|
641
|
-
)}
|
|
642
646
|
</div>
|
|
643
647
|
</div>
|
|
644
648
|
|
|
@@ -865,6 +869,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
865
869
|
onSessionConnected={onSessionConnected}
|
|
866
870
|
refreshKeys={refreshKeys}
|
|
867
871
|
skipPermissions={skipPermissions}
|
|
872
|
+
canClose={countTerminals(tab.tree) > 1}
|
|
873
|
+
onClosePane={tab.id === activeTabId ? closePaneById : undefined}
|
|
868
874
|
/>
|
|
869
875
|
</div>
|
|
870
876
|
))}
|
|
@@ -877,7 +883,7 @@ export default WebTerminal;
|
|
|
877
883
|
// ─── Pane renderer ───────────────────────────────────────────
|
|
878
884
|
|
|
879
885
|
function PaneRenderer({
|
|
880
|
-
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions,
|
|
886
|
+
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
|
|
881
887
|
}: {
|
|
882
888
|
node: SplitNode;
|
|
883
889
|
activeId: number;
|
|
@@ -887,11 +893,20 @@ function PaneRenderer({
|
|
|
887
893
|
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
888
894
|
refreshKeys: Record<number, number>;
|
|
889
895
|
skipPermissions?: boolean;
|
|
896
|
+
canClose?: boolean;
|
|
897
|
+
onClosePane?: (id: number) => void;
|
|
890
898
|
}) {
|
|
891
899
|
if (node.type === 'terminal') {
|
|
892
900
|
return (
|
|
893
|
-
<div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
|
|
901
|
+
<div className={`h-full w-full relative group/pane ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
|
|
894
902
|
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} skipPermissions={skipPermissions} onSessionConnected={onSessionConnected} />
|
|
903
|
+
{canClose && onClosePane && (
|
|
904
|
+
<button
|
|
905
|
+
onClick={(e) => { e.stopPropagation(); if (confirm('Close this pane?')) onClosePane(node.id); }}
|
|
906
|
+
className="absolute top-1.5 right-1.5 z-10 w-6 h-6 flex items-center justify-center rounded bg-red-500/80 text-white hover:bg-red-500 opacity-0 group-hover/pane:opacity-100 transition-opacity text-xs font-bold shadow"
|
|
907
|
+
title="Close this pane"
|
|
908
|
+
>✕</button>
|
|
909
|
+
)}
|
|
895
910
|
</div>
|
|
896
911
|
);
|
|
897
912
|
}
|
|
@@ -900,8 +915,8 @@ function PaneRenderer({
|
|
|
900
915
|
|
|
901
916
|
return (
|
|
902
917
|
<DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
|
|
903
|
-
<PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
|
|
904
|
-
<PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
|
|
918
|
+
<PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
|
|
919
|
+
<PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
|
|
905
920
|
</DraggableSplit>
|
|
906
921
|
);
|
|
907
922
|
}
|
|
@@ -1187,12 +1202,29 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1187
1202
|
// Auto-run claude for project tabs (only if no pendingCommand already set)
|
|
1188
1203
|
if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
|
|
1189
1204
|
isNewlyCreated = false;
|
|
1190
|
-
|
|
1191
|
-
|
|
1205
|
+
// Check if project has existing claude sessions to decide -c flag
|
|
1206
|
+
const pp = projectPathRef.current;
|
|
1207
|
+
const pName = pp.replace(/\/+$/, '').split('/').pop() || '';
|
|
1208
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
|
|
1209
|
+
.then(r => r.json())
|
|
1210
|
+
.then(sData => {
|
|
1211
|
+
const hasSession = Array.isArray(sData) ? sData.length > 0 : false;
|
|
1212
|
+
const resumeFlag = hasSession ? ' -c' : '';
|
|
1192
1213
|
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1214
|
+
setTimeout(() => {
|
|
1215
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1216
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}\n` }));
|
|
1217
|
+
}
|
|
1218
|
+
}, 300);
|
|
1219
|
+
})
|
|
1220
|
+
.catch(() => {
|
|
1221
|
+
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1222
|
+
setTimeout(() => {
|
|
1223
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1224
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${skipFlag}\n` }));
|
|
1225
|
+
}
|
|
1226
|
+
}, 300);
|
|
1227
|
+
});
|
|
1196
1228
|
}
|
|
1197
1229
|
isNewlyCreated = false;
|
|
1198
1230
|
// Force tmux to redraw by toggling size, then send reset
|
package/lib/init.ts
CHANGED
|
@@ -82,6 +82,23 @@ export function ensureInitialized() {
|
|
|
82
82
|
// Auto-detect claude path if not configured
|
|
83
83
|
autoDetectClaude();
|
|
84
84
|
|
|
85
|
+
// Sync help docs + CLAUDE.md to data dir on startup
|
|
86
|
+
try {
|
|
87
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
|
|
88
|
+
const { join: joinPath } = require('node:path');
|
|
89
|
+
const { getConfigDir, getDataDir } = require('./dirs');
|
|
90
|
+
const helpDir = joinPath(getConfigDir(), 'help');
|
|
91
|
+
const sourceDir = joinPath(process.cwd(), 'lib', 'help-docs');
|
|
92
|
+
if (existsSync(sourceDir)) {
|
|
93
|
+
if (!existsSync(helpDir)) mkdirSync(helpDir, { recursive: true });
|
|
94
|
+
for (const f of readdirSync(sourceDir)) {
|
|
95
|
+
if (f.endsWith('.md')) writeFileSync(joinPath(helpDir, f), readFileSync(joinPath(sourceDir, f)));
|
|
96
|
+
}
|
|
97
|
+
const claudeMd = joinPath(helpDir, 'CLAUDE.md');
|
|
98
|
+
if (existsSync(claudeMd)) writeFileSync(joinPath(getDataDir(), 'CLAUDE.md'), readFileSync(claudeMd));
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
85
102
|
// Sync skills registry (async, non-blocking) — on startup + every 30 min
|
|
86
103
|
try {
|
|
87
104
|
const { syncSkills } = require('./skills');
|
|
@@ -150,9 +150,11 @@ export function deleteRun(id: string): void {
|
|
|
150
150
|
|
|
151
151
|
function isDuplicate(projectPath: string, workflowName: string, dedupKey: string): boolean {
|
|
152
152
|
const row = db().prepare(
|
|
153
|
-
'SELECT
|
|
154
|
-
).get(projectPath, workflowName, dedupKey);
|
|
155
|
-
return
|
|
153
|
+
'SELECT status FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? ORDER BY created_at DESC LIMIT 1'
|
|
154
|
+
).get(projectPath, workflowName, dedupKey) as { status: string } | undefined;
|
|
155
|
+
if (!row) return false;
|
|
156
|
+
// Failed runs are not duplicates — allow retry
|
|
157
|
+
return row.status !== 'failed';
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
export function resetDedup(projectPath: string, workflowName: string, dedupKey: string): void {
|
|
@@ -222,7 +224,7 @@ function fetchOpenIssues(projectPath: string, labels: string[]): { number: numbe
|
|
|
222
224
|
if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
|
|
223
225
|
try {
|
|
224
226
|
const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
|
|
225
|
-
const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
|
|
227
|
+
const out = execSync(`gh issue list --state open --limit 30 --json number,title${labelFilter} -R ${repo}`, {
|
|
226
228
|
cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
227
229
|
});
|
|
228
230
|
return JSON.parse(out) || [];
|
|
@@ -246,11 +248,18 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
|
|
|
246
248
|
const recentRuns = getRuns(binding.projectPath, binding.workflowName, 5);
|
|
247
249
|
const hasRunning = recentRuns.some(r => r.status === 'running');
|
|
248
250
|
|
|
251
|
+
// Batch dedup check — one query instead of N
|
|
252
|
+
const processedKeys = new Set(
|
|
253
|
+
(db().prepare(
|
|
254
|
+
'SELECT dedup_key FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key IS NOT NULL AND status != ?'
|
|
255
|
+
).all(binding.projectPath, binding.workflowName, 'failed') as { dedup_key: string }[])
|
|
256
|
+
.map(r => r.dedup_key)
|
|
257
|
+
);
|
|
258
|
+
|
|
249
259
|
const newIssues: { number: number; title: string }[] = [];
|
|
250
260
|
for (const issue of issues) {
|
|
251
261
|
if (issue.number < 0) continue;
|
|
252
|
-
|
|
253
|
-
if (!isDuplicate(binding.projectPath, binding.workflowName, dedupKey)) {
|
|
262
|
+
if (!processedKeys.has(`issue:${issue.number}`)) {
|
|
254
263
|
newIssues.push(issue);
|
|
255
264
|
}
|
|
256
265
|
}
|
|
@@ -269,6 +278,9 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
|
|
|
269
278
|
|
|
270
279
|
const issue = newIssues[0];
|
|
271
280
|
const dedupKey = `issue:${issue.number}`;
|
|
281
|
+
// Remove old failed run so new dedup_key insert won't conflict
|
|
282
|
+
db().prepare('DELETE FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? AND status = ?')
|
|
283
|
+
.run(binding.projectPath, binding.workflowName, dedupKey, 'failed');
|
|
272
284
|
try {
|
|
273
285
|
triggerPipeline(
|
|
274
286
|
binding.projectPath, binding.projectName, binding.workflowName,
|
package/package.json
CHANGED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
|
|
5
|
-
interface PreviewEntry {
|
|
6
|
-
port: number;
|
|
7
|
-
url: string | null;
|
|
8
|
-
status: string;
|
|
9
|
-
label?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export default function PreviewPanel() {
|
|
13
|
-
const [previews, setPreviews] = useState<PreviewEntry[]>([]);
|
|
14
|
-
const [inputPort, setInputPort] = useState('');
|
|
15
|
-
const [inputLabel, setInputLabel] = useState('');
|
|
16
|
-
const [starting, setStarting] = useState(false);
|
|
17
|
-
const [error, setError] = useState('');
|
|
18
|
-
const [activePreview, setActivePreview] = useState<number | null>(null);
|
|
19
|
-
const [isRemote, setIsRemote] = useState(false);
|
|
20
|
-
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
|
|
23
|
-
}, []);
|
|
24
|
-
|
|
25
|
-
const fetchPreviews = useCallback(async () => {
|
|
26
|
-
try {
|
|
27
|
-
const res = await fetch('/api/preview');
|
|
28
|
-
const data = await res.json();
|
|
29
|
-
if (Array.isArray(data)) setPreviews(data);
|
|
30
|
-
} catch {}
|
|
31
|
-
}, []);
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
fetchPreviews();
|
|
35
|
-
const timer = setInterval(fetchPreviews, 5000);
|
|
36
|
-
return () => clearInterval(timer);
|
|
37
|
-
}, [fetchPreviews]);
|
|
38
|
-
|
|
39
|
-
const handleStart = async () => {
|
|
40
|
-
const p = parseInt(inputPort);
|
|
41
|
-
if (!p || p < 1 || p > 65535) { setError('Invalid port'); return; }
|
|
42
|
-
setError('');
|
|
43
|
-
setStarting(true);
|
|
44
|
-
try {
|
|
45
|
-
const res = await fetch('/api/preview', {
|
|
46
|
-
method: 'POST',
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
body: JSON.stringify({ action: 'start', port: p, label: inputLabel || undefined }),
|
|
49
|
-
});
|
|
50
|
-
const data = await res.json();
|
|
51
|
-
if (data.error) setError(data.error);
|
|
52
|
-
else {
|
|
53
|
-
setInputPort('');
|
|
54
|
-
setInputLabel('');
|
|
55
|
-
setActivePreview(p);
|
|
56
|
-
}
|
|
57
|
-
fetchPreviews();
|
|
58
|
-
} catch { setError('Failed'); }
|
|
59
|
-
setStarting(false);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const handleStop = async (port: number) => {
|
|
63
|
-
await fetch('/api/preview', {
|
|
64
|
-
method: 'POST',
|
|
65
|
-
headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
body: JSON.stringify({ action: 'stop', port }),
|
|
67
|
-
});
|
|
68
|
-
if (activePreview === port) setActivePreview(null);
|
|
69
|
-
fetchPreviews();
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const active = previews.find(p => p.port === activePreview);
|
|
73
|
-
const previewSrc = active
|
|
74
|
-
? (isRemote ? active.url : `http://localhost:${active.port}`)
|
|
75
|
-
: null;
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<div className="flex-1 flex flex-col min-h-0">
|
|
79
|
-
{/* Top bar */}
|
|
80
|
-
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0 space-y-2">
|
|
81
|
-
{/* Preview list */}
|
|
82
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
83
|
-
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Demo Preview</span>
|
|
84
|
-
{previews.map(p => (
|
|
85
|
-
<div key={p.port} className="flex items-center gap-1">
|
|
86
|
-
<button
|
|
87
|
-
onClick={() => setActivePreview(p.port)}
|
|
88
|
-
className={`text-[10px] px-2 py-0.5 rounded ${activePreview === p.port ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
89
|
-
>
|
|
90
|
-
<span className={`mr-1 ${p.status === 'running' ? 'text-green-400' : 'text-gray-500'}`}>●</span>
|
|
91
|
-
{p.label || `:${p.port}`}
|
|
92
|
-
</button>
|
|
93
|
-
{p.url && (
|
|
94
|
-
<button
|
|
95
|
-
onClick={() => navigator.clipboard.writeText(p.url!)}
|
|
96
|
-
className="text-[8px] text-green-400 hover:text-green-300 truncate max-w-[150px]"
|
|
97
|
-
title={`Copy: ${p.url}`}
|
|
98
|
-
>
|
|
99
|
-
{p.url.replace('https://', '').slice(0, 20)}...
|
|
100
|
-
</button>
|
|
101
|
-
)}
|
|
102
|
-
<button
|
|
103
|
-
onClick={() => handleStop(p.port)}
|
|
104
|
-
className="text-[9px] text-red-400 hover:text-red-300"
|
|
105
|
-
>
|
|
106
|
-
x
|
|
107
|
-
</button>
|
|
108
|
-
</div>
|
|
109
|
-
))}
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
{/* Add new */}
|
|
113
|
-
<div className="flex items-center gap-2">
|
|
114
|
-
<input
|
|
115
|
-
type="number"
|
|
116
|
-
value={inputPort}
|
|
117
|
-
onChange={e => setInputPort(e.target.value)}
|
|
118
|
-
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
|
119
|
-
placeholder="Port"
|
|
120
|
-
className="w-20 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
|
121
|
-
/>
|
|
122
|
-
<input
|
|
123
|
-
value={inputLabel}
|
|
124
|
-
onChange={e => setInputLabel(e.target.value)}
|
|
125
|
-
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
|
126
|
-
placeholder="Label (optional)"
|
|
127
|
-
className="w-32 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
128
|
-
/>
|
|
129
|
-
<button
|
|
130
|
-
onClick={handleStart}
|
|
131
|
-
disabled={!inputPort || starting}
|
|
132
|
-
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
133
|
-
>
|
|
134
|
-
{starting ? 'Starting...' : '+ Add'}
|
|
135
|
-
</button>
|
|
136
|
-
{active && (
|
|
137
|
-
<a
|
|
138
|
-
href={previewSrc || '#'}
|
|
139
|
-
target="_blank"
|
|
140
|
-
rel="noopener"
|
|
141
|
-
className="text-[10px] text-[var(--accent)] hover:underline ml-auto"
|
|
142
|
-
>
|
|
143
|
-
Open ↗
|
|
144
|
-
</a>
|
|
145
|
-
)}
|
|
146
|
-
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
{/* Preview iframe */}
|
|
151
|
-
{previewSrc && active?.status === 'running' ? (
|
|
152
|
-
<iframe
|
|
153
|
-
src={previewSrc}
|
|
154
|
-
className="flex-1 w-full border-0 bg-white"
|
|
155
|
-
title="Preview"
|
|
156
|
-
/>
|
|
157
|
-
) : (
|
|
158
|
-
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
159
|
-
<div className="text-center space-y-3 max-w-md">
|
|
160
|
-
<p className="text-sm">{previews.length > 0 ? 'Select a preview to display' : 'Preview local dev servers'}</p>
|
|
161
|
-
<p className="text-xs">Enter a port, add a label, and click Add. Each preview gets its own Cloudflare Tunnel URL.</p>
|
|
162
|
-
</div>
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
);
|
|
167
|
-
}
|