@aion0/forge 0.2.4 → 0.2.6
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/app/api/code/route.ts +7 -5
- package/app/api/git/route.ts +2 -2
- package/app/api/pipelines/route.ts +16 -0
- package/app/api/preview/route.ts +101 -87
- package/app/global-error.tsx +15 -0
- package/bin/forge-server.mjs +23 -0
- package/cli/mw.ts +13 -0
- package/components/CodeViewer.tsx +6 -6
- package/components/Dashboard.tsx +1 -1
- package/components/PipelineEditor.tsx +1 -1
- package/components/PipelineView.tsx +27 -7
- package/components/PreviewPanel.tsx +104 -91
- package/components/SettingsModal.tsx +57 -0
- package/components/WebTerminal.tsx +12 -2
- package/dev-test.sh +1 -1
- package/install.sh +29 -0
- package/instrumentation.ts +2 -3
- package/lib/init.ts +4 -3
- package/lib/notify.ts +8 -0
- package/lib/password.ts +1 -1
- package/lib/pipeline.ts +66 -3
- package/lib/settings.ts +6 -0
- package/lib/task-manager.ts +20 -1
- package/lib/telegram-bot.ts +161 -116
- package/package.json +1 -1
- package/tsconfig.json +2 -1
package/app/api/code/route.ts
CHANGED
|
@@ -174,10 +174,12 @@ export async function GET(req: Request) {
|
|
|
174
174
|
}
|
|
175
175
|
const gitRepos: GitRepo[] = [];
|
|
176
176
|
|
|
177
|
+
const gitOpts = { encoding: 'utf-8' as const, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] as any };
|
|
178
|
+
|
|
177
179
|
function scanGitStatus(dir: string, repoName: string, pathPrefix: string) {
|
|
178
180
|
try {
|
|
179
|
-
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir,
|
|
180
|
-
const statusOut = execSync('git status --porcelain -u', { cwd: dir
|
|
181
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { ...gitOpts, cwd: dir, timeout: 3000 }).toString().trim();
|
|
182
|
+
const statusOut = execSync('git status --porcelain -u', { ...gitOpts, cwd: dir }).toString();
|
|
181
183
|
const changes = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
|
|
182
184
|
.map(line => {
|
|
183
185
|
if (line.length < 4) return null;
|
|
@@ -188,7 +190,7 @@ export async function GET(req: Request) {
|
|
|
188
190
|
})
|
|
189
191
|
.filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
|
|
190
192
|
let remote = '';
|
|
191
|
-
try { remote = execSync('git remote get-url origin', { cwd: dir,
|
|
193
|
+
try { remote = execSync('git remote get-url origin', { ...gitOpts, cwd: dir, timeout: 2000 }).toString().trim(); } catch {}
|
|
192
194
|
if (branch || changes.length > 0) {
|
|
193
195
|
gitRepos.push({ name: repoName, branch, remote, changes });
|
|
194
196
|
}
|
|
@@ -197,7 +199,7 @@ export async function GET(req: Request) {
|
|
|
197
199
|
|
|
198
200
|
// Check if root is a git repo
|
|
199
201
|
try {
|
|
200
|
-
execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000 });
|
|
202
|
+
execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
201
203
|
scanGitStatus(resolvedDir, '.', '');
|
|
202
204
|
} catch {
|
|
203
205
|
// Root is not a git repo — scan subdirectories
|
|
@@ -206,7 +208,7 @@ export async function GET(req: Request) {
|
|
|
206
208
|
if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
|
|
207
209
|
const subDir = join(resolvedDir, entry.name);
|
|
208
210
|
try {
|
|
209
|
-
execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000 });
|
|
211
|
+
execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
210
212
|
scanGitStatus(subDir, entry.name, entry.name);
|
|
211
213
|
} catch {}
|
|
212
214
|
}
|
package/app/api/git/route.ts
CHANGED
|
@@ -12,7 +12,7 @@ function isUnderProjectRoot(dir: string): boolean {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function git(cmd: string, cwd: string): string {
|
|
15
|
-
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000 }).trim();
|
|
15
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// GET /api/git?dir=<path> — git status for a project
|
|
@@ -24,7 +24,7 @@ export async function GET(req: NextRequest) {
|
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
26
|
const branch = git('rev-parse --abbrev-ref HEAD', dir);
|
|
27
|
-
const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000 });
|
|
27
|
+
const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
|
|
28
28
|
const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
|
|
29
29
|
status: line.substring(0, 2).trim() || 'M',
|
|
30
30
|
path: line.substring(3).replace(/\/$/, ''),
|
|
@@ -16,6 +16,22 @@ export async function GET(req: Request) {
|
|
|
16
16
|
return NextResponse.json(listWorkflows());
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
if (type === 'workflow-yaml') {
|
|
20
|
+
const name = searchParams.get('name');
|
|
21
|
+
if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
|
|
22
|
+
try {
|
|
23
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
24
|
+
const filePath = join(FLOWS_DIR, `${name}.yaml`);
|
|
25
|
+
const altPath = join(FLOWS_DIR, `${name}.yml`);
|
|
26
|
+
const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
|
|
27
|
+
if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
28
|
+
const yaml = readFileSync(path, 'utf-8');
|
|
29
|
+
return NextResponse.json({ yaml });
|
|
30
|
+
} catch {
|
|
31
|
+
return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
|
20
36
|
}
|
|
21
37
|
|
package/app/api/preview/route.ts
CHANGED
|
@@ -4,30 +4,39 @@ import { join, dirname } from 'node:path';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
|
|
7
|
+
const CONFIG_FILE = join(process.env.FORGE_DATA_DIR || join(homedir(), '.forge'), 'preview.json');
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
interface PreviewEntry {
|
|
10
|
+
port: number;
|
|
11
|
+
url: string | null;
|
|
12
|
+
status: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Persist state across hot-reloads
|
|
10
17
|
const stateKey = Symbol.for('mw-preview-state');
|
|
11
18
|
const g = globalThis as any;
|
|
12
|
-
if (!g[stateKey]) g[stateKey] = {
|
|
13
|
-
const state: { process: ChildProcess | null;
|
|
19
|
+
if (!g[stateKey]) g[stateKey] = { entries: new Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }>() };
|
|
20
|
+
const state: { entries: Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }> } = g[stateKey];
|
|
14
21
|
|
|
15
|
-
function getConfig():
|
|
22
|
+
function getConfig(): PreviewEntry[] {
|
|
16
23
|
try {
|
|
17
|
-
|
|
24
|
+
const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
25
|
+
return Array.isArray(data) ? data : data.port ? [data] : [];
|
|
18
26
|
} catch {
|
|
19
|
-
return
|
|
27
|
+
return [];
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
function saveConfig(
|
|
31
|
+
function saveConfig(entries: PreviewEntry[]) {
|
|
24
32
|
const dir = dirname(CONFIG_FILE);
|
|
25
33
|
mkdirSync(dir, { recursive: true });
|
|
26
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(
|
|
34
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(entries, null, 2));
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
function getCloudflaredPath(): string | null {
|
|
30
|
-
const
|
|
38
|
+
const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
|
|
39
|
+
const binPath = join(dataDir, 'bin', 'cloudflared');
|
|
31
40
|
if (existsSync(binPath)) return binPath;
|
|
32
41
|
try {
|
|
33
42
|
return execSync('which cloudflared', { encoding: 'utf-8' }).trim();
|
|
@@ -36,100 +45,105 @@ function getCloudflaredPath(): string | null {
|
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
// GET —
|
|
48
|
+
// GET — list all previews
|
|
40
49
|
export async function GET() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
url:
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
const entries: PreviewEntry[] = [];
|
|
51
|
+
for (const [port, s] of state.entries) {
|
|
52
|
+
entries.push({ port, url: s.url, status: s.status, label: s.label });
|
|
53
|
+
}
|
|
54
|
+
return NextResponse.json(entries);
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
// POST — start/stop
|
|
57
|
+
// POST — start/stop/manage previews
|
|
49
58
|
export async function POST(req: Request) {
|
|
50
|
-
const
|
|
59
|
+
const body = await req.json();
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
// Stop a preview
|
|
62
|
+
if (body.action === 'stop' && body.port) {
|
|
63
|
+
const entry = state.entries.get(body.port);
|
|
64
|
+
if (entry?.process) {
|
|
65
|
+
entry.process.kill('SIGTERM');
|
|
56
66
|
}
|
|
57
|
-
state.port
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
saveConfig({ port: 0 });
|
|
61
|
-
return NextResponse.json({ port: 0, url: null, status: 'stopped' });
|
|
67
|
+
state.entries.delete(body.port);
|
|
68
|
+
syncConfig();
|
|
69
|
+
return NextResponse.json({ ok: true });
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
// Start a new preview
|
|
73
|
+
if (body.action === 'start' && body.port) {
|
|
74
|
+
const port = parseInt(body.port);
|
|
75
|
+
if (!port || port < 1 || port > 65535) {
|
|
76
|
+
return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
|
|
77
|
+
}
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
// Already running?
|
|
80
|
+
const existing = state.entries.get(port);
|
|
81
|
+
if (existing && existing.status === 'running') {
|
|
82
|
+
return NextResponse.json({ port, url: existing.url, status: 'running', label: existing.label });
|
|
83
|
+
}
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
const binPath = getCloudflaredPath();
|
|
86
|
+
if (!binPath) {
|
|
87
|
+
return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first.' }, { status: 500 });
|
|
88
|
+
}
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
const label = body.label || `localhost:${port}`;
|
|
91
|
+
state.entries.set(port, { process: null, url: null, status: 'starting', label });
|
|
92
|
+
syncConfig();
|
|
93
|
+
|
|
94
|
+
// Start tunnel
|
|
95
|
+
return new Promise<NextResponse>((resolve) => {
|
|
96
|
+
let resolved = false;
|
|
97
|
+
const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const entry = state.entries.get(port)!;
|
|
102
|
+
entry.process = child;
|
|
103
|
+
|
|
104
|
+
const handleOutput = (data: Buffer) => {
|
|
105
|
+
const urlMatch = data.toString().match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
|
|
106
|
+
if (urlMatch && !entry.url) {
|
|
107
|
+
entry.url = urlMatch[1];
|
|
108
|
+
entry.status = 'running';
|
|
109
|
+
syncConfig();
|
|
110
|
+
if (!resolved) {
|
|
111
|
+
resolved = true;
|
|
112
|
+
resolve(NextResponse.json({ port, url: entry.url, status: 'running', label }));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
84
116
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
let resolved = false;
|
|
117
|
+
child.stdout?.on('data', handleOutput);
|
|
118
|
+
child.stderr?.on('data', handleOutput);
|
|
88
119
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const handleOutput = (data: Buffer) => {
|
|
95
|
-
const text = data.toString();
|
|
96
|
-
const urlMatch = text.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
|
|
97
|
-
if (urlMatch && !state.url) {
|
|
98
|
-
state.url = urlMatch[1];
|
|
99
|
-
state.status = 'running';
|
|
120
|
+
child.on('exit', () => {
|
|
121
|
+
entry.process = null;
|
|
122
|
+
entry.status = 'stopped';
|
|
123
|
+
entry.url = null;
|
|
124
|
+
syncConfig();
|
|
100
125
|
if (!resolved) {
|
|
101
126
|
resolved = true;
|
|
102
|
-
resolve(NextResponse.json({ port
|
|
127
|
+
resolve(NextResponse.json({ port, url: null, status: 'stopped', error: 'Tunnel exited' }));
|
|
103
128
|
}
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
child.stdout?.on('data', handleOutput);
|
|
108
|
-
child.stderr?.on('data', handleOutput);
|
|
109
|
-
|
|
110
|
-
child.on('exit', () => {
|
|
111
|
-
state.process = null;
|
|
112
|
-
state.status = 'stopped';
|
|
113
|
-
state.url = null;
|
|
114
|
-
if (!resolved) {
|
|
115
|
-
resolved = true;
|
|
116
|
-
resolve(NextResponse.json({ port: p, url: null, status: 'stopped', error: 'Tunnel exited' }));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
129
|
+
});
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
if (!resolved) {
|
|
133
|
+
resolved = true;
|
|
134
|
+
resolve(NextResponse.json({ port, url: null, status: entry.status, error: 'Timeout' }));
|
|
135
|
+
}
|
|
136
|
+
}, 30000);
|
|
126
137
|
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
|
141
|
+
}
|
|
127
142
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
143
|
+
function syncConfig() {
|
|
144
|
+
const entries: PreviewEntry[] = [];
|
|
145
|
+
for (const [port, s] of state.entries) {
|
|
146
|
+
entries.push({ port, url: s.url, status: s.status, label: s.label });
|
|
147
|
+
}
|
|
148
|
+
saveConfig(entries);
|
|
135
149
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
|
|
4
|
+
return (
|
|
5
|
+
<html>
|
|
6
|
+
<body style={{ background: '#0a0a0a', color: '#e5e5e5', fontFamily: 'monospace', padding: '2rem' }}>
|
|
7
|
+
<h2>Something went wrong</h2>
|
|
8
|
+
<p style={{ color: '#999' }}>{error.message}</p>
|
|
9
|
+
<button onClick={reset} style={{ marginTop: '1rem', padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
|
10
|
+
Try again
|
|
11
|
+
</button>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* forge-server --port 4000 Custom web port (default: 3000)
|
|
13
13
|
* forge-server --terminal-port 4001 Custom terminal port (default: 3001)
|
|
14
14
|
* forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
|
|
15
|
+
* forge-server --reset-terminal Kill terminal server before start (loses tmux sessions)
|
|
15
16
|
*
|
|
16
17
|
* Examples:
|
|
17
18
|
* forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
|
|
@@ -35,11 +36,19 @@ function getArg(name) {
|
|
|
35
36
|
return process.argv[idx + 1];
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// ── Version ──
|
|
40
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
41
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
|
42
|
+
console.log(`@aion0/forge v${pkg.version}`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
const isDev = process.argv.includes('--dev');
|
|
39
47
|
const isBackground = process.argv.includes('--background');
|
|
40
48
|
const isStop = process.argv.includes('--stop');
|
|
41
49
|
const isRestart = process.argv.includes('--restart');
|
|
42
50
|
const isRebuild = process.argv.includes('--rebuild');
|
|
51
|
+
const resetTerminal = process.argv.includes('--reset-terminal');
|
|
43
52
|
|
|
44
53
|
const webPort = parseInt(getArg('--port')) || 3000;
|
|
45
54
|
const terminalPort = parseInt(getArg('--terminal-port')) || 3001;
|
|
@@ -70,6 +79,20 @@ process.env.PORT = String(webPort);
|
|
|
70
79
|
process.env.TERMINAL_PORT = String(terminalPort);
|
|
71
80
|
process.env.FORGE_DATA_DIR = DATA_DIR;
|
|
72
81
|
|
|
82
|
+
// ── Reset terminal server (kill port + tmux sessions) ──
|
|
83
|
+
if (resetTerminal) {
|
|
84
|
+
console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
|
|
85
|
+
try {
|
|
86
|
+
const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
|
|
87
|
+
for (const pid of pids.split('\n').filter(Boolean)) {
|
|
88
|
+
try { execSync(`kill ${pid.trim()}`); } catch {}
|
|
89
|
+
}
|
|
90
|
+
console.log(`[forge] Killed terminal server on port ${terminalPort}`);
|
|
91
|
+
} catch {
|
|
92
|
+
console.log(`[forge] No process on port ${terminalPort}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
// ── Helper: stop running instance ──
|
|
74
97
|
function stopServer() {
|
|
75
98
|
try {
|
package/cli/mw.ts
CHANGED
|
@@ -31,6 +31,19 @@ async function api(path: string, opts?: RequestInit) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
async function main() {
|
|
34
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
35
|
+
const { readFileSync } = await import('node:fs');
|
|
36
|
+
const { join, dirname } = await import('node:path');
|
|
37
|
+
const { fileURLToPath } = await import('node:url');
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
40
|
+
console.log(`@aion0/forge v${pkg.version}`);
|
|
41
|
+
} catch {
|
|
42
|
+
console.log('forge (version unknown)');
|
|
43
|
+
}
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
switch (cmd) {
|
|
35
48
|
case 'task':
|
|
36
49
|
case 't': {
|
|
@@ -394,7 +394,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
394
394
|
}, []);
|
|
395
395
|
|
|
396
396
|
return (
|
|
397
|
-
<div className="flex-1 flex flex-col min-h-0">
|
|
397
|
+
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
|
398
398
|
{/* Task completion notification */}
|
|
399
399
|
{taskNotification && (
|
|
400
400
|
<div className="shrink-0 px-3 py-1.5 bg-green-900/30 border-b border-green-800/50 flex items-center gap-2 text-xs">
|
|
@@ -431,7 +431,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
431
431
|
)}
|
|
432
432
|
|
|
433
433
|
{/* File browser + code viewer — bottom */}
|
|
434
|
-
{codeOpen && <div className="flex-1 flex min-h-0">
|
|
434
|
+
{codeOpen && <div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
|
|
435
435
|
{/* Sidebar */}
|
|
436
436
|
{sidebarOpen && (
|
|
437
437
|
<aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
@@ -609,7 +609,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
609
609
|
)}
|
|
610
610
|
|
|
611
611
|
{/* Code viewer */}
|
|
612
|
-
<main className="flex-1 flex flex-col min-w-0">
|
|
612
|
+
<main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ width: 0 }}>
|
|
613
613
|
<div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
|
|
614
614
|
<button
|
|
615
615
|
onClick={() => setSidebarOpen(v => !v)}
|
|
@@ -672,7 +672,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
672
672
|
</div>
|
|
673
673
|
) : viewMode === 'diff' && diffContent ? (
|
|
674
674
|
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
675
|
-
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
675
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
|
|
676
676
|
{diffContent.split('\n').map((line, i) => {
|
|
677
677
|
const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
|
|
678
678
|
: line.startsWith('-') ? 'text-red-400 bg-red-900/20'
|
|
@@ -689,11 +689,11 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
689
689
|
</div>
|
|
690
690
|
) : selectedFile && content !== null ? (
|
|
691
691
|
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
692
|
-
<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 }}>
|
|
692
|
+
<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, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
|
|
693
693
|
{content.split('\n').map((line, i) => (
|
|
694
694
|
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
695
695
|
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
696
|
-
<span className="
|
|
696
|
+
<span className="whitespace-pre">{highlightLine(line, language)}</span>
|
|
697
697
|
</div>
|
|
698
698
|
))}
|
|
699
699
|
</pre>
|
package/components/Dashboard.tsx
CHANGED
|
@@ -324,7 +324,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
324
324
|
{/* Pipelines */}
|
|
325
325
|
{viewMode === 'pipelines' && (
|
|
326
326
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
327
|
-
<PipelineView />
|
|
327
|
+
<PipelineView onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
|
|
328
328
|
</Suspense>
|
|
329
329
|
)}
|
|
330
330
|
|
|
@@ -168,7 +168,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
168
168
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
|
|
169
169
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
170
170
|
const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
|
|
171
|
-
const [workflowName, setWorkflowName] = useState('
|
|
171
|
+
const [workflowName, setWorkflowName] = useState('');
|
|
172
172
|
const [workflowDesc, setWorkflowDesc] = useState('');
|
|
173
173
|
const [varsProject, setVarsProject] = useState('');
|
|
174
174
|
const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
|
|
@@ -60,7 +60,7 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
60
60
|
skipped: 'text-gray-500',
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
export default function PipelineView() {
|
|
63
|
+
export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
|
|
64
64
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
65
65
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
66
66
|
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
|
|
@@ -69,6 +69,7 @@ export default function PipelineView() {
|
|
|
69
69
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
70
70
|
const [creating, setCreating] = useState(false);
|
|
71
71
|
const [showEditor, setShowEditor] = useState(false);
|
|
72
|
+
const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
|
|
72
73
|
|
|
73
74
|
const fetchData = useCallback(async () => {
|
|
74
75
|
const [pRes, wRes] = await Promise.all([
|
|
@@ -150,12 +151,25 @@ export default function PipelineView() {
|
|
|
150
151
|
<aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
151
152
|
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
|
152
153
|
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
<select
|
|
155
|
+
onChange={async (e) => {
|
|
156
|
+
const name = e.target.value;
|
|
157
|
+
if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
setEditorYaml(data.yaml || undefined);
|
|
162
|
+
} catch { setEditorYaml(undefined); }
|
|
163
|
+
setShowEditor(true);
|
|
164
|
+
e.target.value = '';
|
|
165
|
+
}}
|
|
166
|
+
className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
|
|
167
|
+
defaultValue=""
|
|
156
168
|
>
|
|
157
|
-
Editor
|
|
158
|
-
|
|
169
|
+
<option value="">Editor ▾</option>
|
|
170
|
+
<option value="">+ New workflow</option>
|
|
171
|
+
{workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
|
|
172
|
+
</select>
|
|
159
173
|
<button
|
|
160
174
|
onClick={() => setShowCreate(v => !v)}
|
|
161
175
|
className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
@@ -322,7 +336,12 @@ export default function PipelineView() {
|
|
|
322
336
|
<span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
|
|
323
337
|
<span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
324
338
|
{node.taskId && (
|
|
325
|
-
<
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => onViewTask?.(node.taskId!)}
|
|
341
|
+
className="text-[9px] text-[var(--accent)] font-mono hover:underline"
|
|
342
|
+
>
|
|
343
|
+
task:{node.taskId}
|
|
344
|
+
</button>
|
|
326
345
|
)}
|
|
327
346
|
{node.iterations > 1 && (
|
|
328
347
|
<span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
|
|
@@ -416,6 +435,7 @@ nodes:
|
|
|
416
435
|
{showEditor && (
|
|
417
436
|
<Suspense fallback={null}>
|
|
418
437
|
<PipelineEditor
|
|
438
|
+
initialYaml={editorYaml}
|
|
419
439
|
onSave={async (yaml) => {
|
|
420
440
|
// Save YAML to ~/.forge/flows/
|
|
421
441
|
await fetch('/api/pipelines', {
|