@aion0/forge 0.4.9 → 0.4.11
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 +7 -5
- package/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/app/api/help/route.ts +10 -4
- package/app/api/mobile-chat/route.ts +87 -0
- package/app/api/preview/route.ts +8 -0
- package/app/mobile/page.tsx +9 -0
- package/app/page.tsx +13 -1
- package/components/BrowserPanel.tsx +175 -0
- package/components/CodeViewer.tsx +4 -74
- package/components/Dashboard.tsx +166 -20
- package/components/HelpTerminal.tsx +8 -2
- package/components/MobileView.tsx +365 -0
- package/components/ProjectDetail.tsx +5 -5
- package/components/WebTerminal.tsx +47 -26
- package/lib/claude-sessions.ts +2 -2
- package/lib/init.ts +18 -1
- 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,13 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.11
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-23
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.10
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
8
|
+
- mobile page
|
|
9
|
+
- init version for mobile page
|
|
10
|
+
- change sync period time
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
13
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.10...v0.4.11
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getSessionFilePath, readSessionEntries } from '@/lib/claude-sessions';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export async function GET(req: Request, { params }: { params: Promise<{ projectName: string }> }) {
|
|
6
|
+
const { projectName } = await params;
|
|
7
|
+
const url = new URL(req.url);
|
|
8
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
9
|
+
|
|
10
|
+
if (!sessionId) {
|
|
11
|
+
return NextResponse.json({ error: 'sessionId required' }, { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const filePath = getSessionFilePath(decodeURIComponent(projectName), sessionId);
|
|
15
|
+
if (!filePath) {
|
|
16
|
+
return NextResponse.json({ entries: [], count: 0, fileSize: 0 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const entries = readSessionEntries(filePath);
|
|
20
|
+
let fileSize = 0;
|
|
21
|
+
try { fileSize = statSync(filePath).size; } catch {}
|
|
22
|
+
return NextResponse.json({ entries, count: entries.length, fileSize });
|
|
23
|
+
}
|
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') {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { loadSettings } from '@/lib/settings';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
export const runtime = 'nodejs';
|
|
7
|
+
|
|
8
|
+
// POST /api/mobile-chat — send a message to claude and stream response
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
const { message, projectPath, resume } = await req.json() as {
|
|
11
|
+
message: string;
|
|
12
|
+
projectPath: string;
|
|
13
|
+
resume?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (!message || !projectPath) {
|
|
17
|
+
return NextResponse.json({ error: 'message and projectPath required' }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const settings = loadSettings();
|
|
21
|
+
const claudePath = settings.claudePath || 'claude';
|
|
22
|
+
|
|
23
|
+
const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
|
|
24
|
+
if (resume) args.push('-c');
|
|
25
|
+
|
|
26
|
+
const child = spawn(claudePath, args, {
|
|
27
|
+
cwd: projectPath,
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
child.stdin.write(message);
|
|
33
|
+
child.stdin.end();
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
let closed = false;
|
|
37
|
+
|
|
38
|
+
const stream = new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
41
|
+
if (closed) return;
|
|
42
|
+
try {
|
|
43
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk.toString() })}\n\n`));
|
|
44
|
+
} catch {}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
const text = chunk.toString();
|
|
50
|
+
if (text.includes('npm update') || text.includes('WARN')) return;
|
|
51
|
+
try {
|
|
52
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'stderr', text })}\n\n`));
|
|
53
|
+
} catch {}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.on('exit', (code) => {
|
|
57
|
+
if (closed) return;
|
|
58
|
+
closed = true;
|
|
59
|
+
try {
|
|
60
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
|
|
61
|
+
controller.close();
|
|
62
|
+
} catch {}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
child.on('error', (err) => {
|
|
66
|
+
if (closed) return;
|
|
67
|
+
closed = true;
|
|
68
|
+
try {
|
|
69
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`));
|
|
70
|
+
controller.close();
|
|
71
|
+
} catch {}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
closed = true;
|
|
76
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return new Response(stream, {
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
84
|
+
Connection: 'keep-alive',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
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,9 @@
|
|
|
1
|
+
import { auth } from '@/lib/auth';
|
|
2
|
+
import { redirect } from 'next/navigation';
|
|
3
|
+
import MobileView from '@/components/MobileView';
|
|
4
|
+
|
|
5
|
+
export default async function MobilePage() {
|
|
6
|
+
const session = await auth();
|
|
7
|
+
if (!session) redirect('/login');
|
|
8
|
+
return <MobileView />;
|
|
9
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { auth } from '@/lib/auth';
|
|
2
2
|
import { redirect } from 'next/navigation';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
3
4
|
import Dashboard from '@/components/Dashboard';
|
|
4
5
|
|
|
5
|
-
export default async function Home() {
|
|
6
|
+
export default async function Home({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
|
|
6
7
|
const session = await auth();
|
|
7
8
|
if (!session) redirect('/login');
|
|
9
|
+
|
|
10
|
+
const params = await searchParams;
|
|
11
|
+
|
|
12
|
+
// Auto-detect mobile and redirect (skip if ?force=desktop)
|
|
13
|
+
if (params.force !== 'desktop') {
|
|
14
|
+
const headersList = await headers();
|
|
15
|
+
const ua = headersList.get('user-agent') || '';
|
|
16
|
+
const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
|
17
|
+
if (isMobile) redirect('/mobile');
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
return <Dashboard user={session.user} />;
|
|
9
21
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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 normalizeUrl = (val: string): string => {
|
|
33
|
+
if (/^\d+$/.test(val)) return `http://localhost:${val}`;
|
|
34
|
+
if (!/^https?:\/\//i.test(val)) return `http://${val}`;
|
|
35
|
+
return val;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const navigate = (url: string) => {
|
|
39
|
+
const normalized = normalizeUrl(url);
|
|
40
|
+
setBrowserUrl(normalized);
|
|
41
|
+
localStorage.setItem('forge-browser-url', normalized);
|
|
42
|
+
if (browserUrlRef.current) browserUrlRef.current.value = normalized;
|
|
43
|
+
setBrowserKey(k => k + 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleTunnel = async () => {
|
|
47
|
+
const input = prompt('Enter port(s) to create tunnel (e.g. 3100 or 3100,8080):');
|
|
48
|
+
if (!input) return;
|
|
49
|
+
const ports = input.split(',').map(s => parseInt(s.trim())).filter(p => p > 0 && p <= 65535);
|
|
50
|
+
if (ports.length === 0) { alert('Invalid port(s)'); return; }
|
|
51
|
+
setTunnelStarting(true);
|
|
52
|
+
const results: string[] = [];
|
|
53
|
+
for (const port of ports) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch('/api/preview', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ action: 'start', port }),
|
|
59
|
+
});
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
if (data.url) {
|
|
62
|
+
results.push(data.url);
|
|
63
|
+
setPreviews(prev => {
|
|
64
|
+
const exists = prev.find(p => p.port === port);
|
|
65
|
+
if (exists) return prev.map(p => p.port === port ? { ...p, url: data.url, status: 'running' } : p);
|
|
66
|
+
return [...prev, { port, url: data.url, status: 'running' }];
|
|
67
|
+
});
|
|
68
|
+
} else if (data.status === 'starting' || data.status === 'stopped') {
|
|
69
|
+
// Tunnel started but URL not ready yet or exited
|
|
70
|
+
results.push('');
|
|
71
|
+
} else {
|
|
72
|
+
alert(`Port ${port}: ${data.error || 'Failed'}`);
|
|
73
|
+
}
|
|
74
|
+
} catch { alert(`Port ${port}: Failed to start tunnel`); }
|
|
75
|
+
}
|
|
76
|
+
// Navigate to first successful URL
|
|
77
|
+
const firstUrl = results.find(u => u);
|
|
78
|
+
if (firstUrl) navigate(firstUrl);
|
|
79
|
+
// Refresh list to pick up any that were still starting
|
|
80
|
+
setTimeout(fetchPreviews, 3000);
|
|
81
|
+
setTunnelStarting(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const stopTunnel = async (port: number) => {
|
|
85
|
+
await fetch('/api/preview', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ action: 'stop', port }),
|
|
89
|
+
});
|
|
90
|
+
setPreviews(prev => prev.filter(x => x.port !== port));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
95
|
+
{/* URL bar */}
|
|
96
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
|
|
97
|
+
<input
|
|
98
|
+
ref={browserUrlRef}
|
|
99
|
+
type="text"
|
|
100
|
+
defaultValue={browserUrl}
|
|
101
|
+
placeholder="Enter URL"
|
|
102
|
+
onKeyDown={e => {
|
|
103
|
+
if (e.key === 'Enter') {
|
|
104
|
+
const val = (e.target as HTMLInputElement).value.trim();
|
|
105
|
+
if (!val) return;
|
|
106
|
+
navigate(val);
|
|
107
|
+
}
|
|
108
|
+
}}
|
|
109
|
+
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"
|
|
110
|
+
/>
|
|
111
|
+
<button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
|
|
112
|
+
<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>
|
|
113
|
+
<button
|
|
114
|
+
disabled={tunnelStarting}
|
|
115
|
+
onClick={handleTunnel}
|
|
116
|
+
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"
|
|
117
|
+
title="Create tunnel for a port (remote access)"
|
|
118
|
+
>{tunnelStarting ? 'Starting...' : 'Tunnel'}</button>
|
|
119
|
+
{onClose && (
|
|
120
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
{/* Active tunnels bar */}
|
|
124
|
+
{previews.length > 0 && (
|
|
125
|
+
<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">
|
|
126
|
+
{previews.map(p => (
|
|
127
|
+
<div key={p.port} className="flex items-center gap-1 shrink-0">
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => {
|
|
130
|
+
const url = isRemote && p.url ? p.url : `http://localhost:${p.port}`;
|
|
131
|
+
navigate(url);
|
|
132
|
+
}}
|
|
133
|
+
className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
134
|
+
>
|
|
135
|
+
<span className="text-green-400 mr-0.5">●</span>
|
|
136
|
+
:{p.port}
|
|
137
|
+
</button>
|
|
138
|
+
{p.url && (
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => navigator.clipboard.writeText(p.url!).then(() => alert('Tunnel URL copied'))}
|
|
141
|
+
className="text-[8px] text-green-400 hover:underline truncate max-w-[120px]"
|
|
142
|
+
title={p.url}
|
|
143
|
+
>{p.url.replace('https://', '').slice(0, 20)}...</button>
|
|
144
|
+
)}
|
|
145
|
+
<button onClick={() => stopTunnel(p.port)} className="text-[8px] text-red-400 hover:text-red-300">✕</button>
|
|
146
|
+
</div>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
{/* Content */}
|
|
151
|
+
<div className="flex-1 relative">
|
|
152
|
+
{tunnelStarting && (
|
|
153
|
+
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs z-10 bg-[var(--bg-primary)]/80">
|
|
154
|
+
Creating tunnel... this may take up to 30 seconds
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
{browserUrl ? (
|
|
158
|
+
<iframe
|
|
159
|
+
key={browserKey}
|
|
160
|
+
src={browserUrl}
|
|
161
|
+
className="absolute inset-0 w-full h-full border-0 bg-white"
|
|
162
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
163
|
+
/>
|
|
164
|
+
) : (
|
|
165
|
+
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
|
166
|
+
<div className="text-center space-y-1">
|
|
167
|
+
<p>Enter a URL or port number and press Enter</p>
|
|
168
|
+
<p className="text-[9px]">Click Tunnel to create a public URL for remote access</p>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -189,12 +189,6 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
189
189
|
const [editing, setEditing] = useState(false);
|
|
190
190
|
const [editContent, setEditContent] = useState('');
|
|
191
191
|
const [saving, setSaving] = useState(false);
|
|
192
|
-
const [browserOpen, setBrowserOpen] = useState(false);
|
|
193
|
-
const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
|
|
194
|
-
const [browserKey, setBrowserKey] = useState(0);
|
|
195
|
-
const [browserWidth, setBrowserWidth] = useState(640);
|
|
196
|
-
const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
|
197
|
-
const [browserDragging, setBrowserDragging] = useState(false);
|
|
198
192
|
|
|
199
193
|
const handleCodeOpenChange = useCallback((open: boolean) => {
|
|
200
194
|
setCodeOpen(open);
|
|
@@ -427,74 +421,10 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
427
421
|
)}
|
|
428
422
|
|
|
429
423
|
{/* Terminal + Browser — main area */}
|
|
430
|
-
<div className={
|
|
431
|
-
<div className="flex-
|
|
432
|
-
<
|
|
433
|
-
|
|
434
|
-
</Suspense>
|
|
435
|
-
</div>
|
|
436
|
-
{browserOpen && (
|
|
437
|
-
<>
|
|
438
|
-
<div
|
|
439
|
-
onMouseDown={(e) => {
|
|
440
|
-
e.preventDefault();
|
|
441
|
-
browserDragRef.current = { startX: e.clientX, startW: browserWidth };
|
|
442
|
-
setBrowserDragging(true);
|
|
443
|
-
const onMove = (ev: MouseEvent) => {
|
|
444
|
-
if (!browserDragRef.current) return;
|
|
445
|
-
setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
|
|
446
|
-
};
|
|
447
|
-
const onUp = () => {
|
|
448
|
-
browserDragRef.current = null;
|
|
449
|
-
setBrowserDragging(false);
|
|
450
|
-
window.removeEventListener('mousemove', onMove);
|
|
451
|
-
window.removeEventListener('mouseup', onUp);
|
|
452
|
-
};
|
|
453
|
-
window.addEventListener('mousemove', onMove);
|
|
454
|
-
window.addEventListener('mouseup', onUp);
|
|
455
|
-
}}
|
|
456
|
-
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
|
|
457
|
-
/>
|
|
458
|
-
<div style={{ width: browserWidth }} className="shrink-0 flex flex-col">
|
|
459
|
-
<div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
|
|
460
|
-
<input
|
|
461
|
-
type="text"
|
|
462
|
-
defaultValue={browserUrl}
|
|
463
|
-
placeholder="http://localhost:3000"
|
|
464
|
-
onKeyDown={e => {
|
|
465
|
-
if (e.key === 'Enter') {
|
|
466
|
-
const url = (e.target as HTMLInputElement).value.trim();
|
|
467
|
-
if (url) {
|
|
468
|
-
setBrowserUrl(url);
|
|
469
|
-
localStorage.setItem('forge-browser-url', url);
|
|
470
|
-
setBrowserKey(k => k + 1);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}}
|
|
474
|
-
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"
|
|
475
|
-
/>
|
|
476
|
-
<button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
|
|
477
|
-
<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>
|
|
478
|
-
<button onClick={() => setBrowserOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
|
|
479
|
-
</div>
|
|
480
|
-
<div className="flex-1 relative">
|
|
481
|
-
{browserUrl ? (
|
|
482
|
-
<iframe
|
|
483
|
-
key={browserKey}
|
|
484
|
-
src={browserUrl}
|
|
485
|
-
className="absolute inset-0 w-full h-full border-0 bg-white"
|
|
486
|
-
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
487
|
-
/>
|
|
488
|
-
) : (
|
|
489
|
-
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
|
490
|
-
Enter a URL and press Enter
|
|
491
|
-
</div>
|
|
492
|
-
)}
|
|
493
|
-
{browserDragging && <div className="absolute inset-0 z-10" />}
|
|
494
|
-
</div>
|
|
495
|
-
</div>
|
|
496
|
-
</>
|
|
497
|
-
)}
|
|
424
|
+
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
425
|
+
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
426
|
+
<WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
|
|
427
|
+
</Suspense>
|
|
498
428
|
</div>
|
|
499
429
|
|
|
500
430
|
{/* Resize handle */}
|