@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
|
@@ -15,8 +15,6 @@ export interface WebTerminalHandle {
|
|
|
15
15
|
export interface WebTerminalProps {
|
|
16
16
|
onActiveSession?: (sessionName: string | null) => void;
|
|
17
17
|
onCodeOpenChange?: (open: boolean) => void;
|
|
18
|
-
browserOpen?: boolean;
|
|
19
|
-
onBrowserToggle?: () => void;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
// ─── Types ───────────────────────────────────────────────────
|
|
@@ -166,7 +164,7 @@ let globalDragging = false;
|
|
|
166
164
|
|
|
167
165
|
// ─── Main component ─────────────────────────────────────────
|
|
168
166
|
|
|
169
|
-
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange
|
|
167
|
+
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
|
|
170
168
|
const [tabs, setTabs] = useState<TabState[]>(() => {
|
|
171
169
|
const tree = makeTerminal();
|
|
172
170
|
return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
|
|
@@ -478,6 +476,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
478
476
|
});
|
|
479
477
|
}, [activeTab, updateActiveTab]);
|
|
480
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
|
+
|
|
481
488
|
const setActiveId = useCallback((id: number) => {
|
|
482
489
|
updateActiveTab(t => ({ ...t, activeId: id }));
|
|
483
490
|
}, [updateActiveTab]);
|
|
@@ -636,20 +643,6 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
636
643
|
Code
|
|
637
644
|
</button>
|
|
638
645
|
)}
|
|
639
|
-
{onBrowserToggle && (
|
|
640
|
-
<button
|
|
641
|
-
onClick={onBrowserToggle}
|
|
642
|
-
className={`text-[11px] px-3 py-1 rounded font-bold ${browserOpen ? 'text-white bg-blue-500 hover:bg-blue-400' : 'text-blue-400 border border-blue-500 hover:bg-blue-500 hover:text-white'}`}
|
|
643
|
-
title={browserOpen ? 'Close browser' : 'Open browser'}
|
|
644
|
-
>
|
|
645
|
-
Browser
|
|
646
|
-
</button>
|
|
647
|
-
)}
|
|
648
|
-
{activeTab && countTerminals(activeTab.tree) > 1 && (
|
|
649
|
-
<button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:text-red-400 hover:bg-[var(--term-border)] rounded font-medium">
|
|
650
|
-
Close Pane
|
|
651
|
-
</button>
|
|
652
|
-
)}
|
|
653
646
|
</div>
|
|
654
647
|
</div>
|
|
655
648
|
|
|
@@ -876,6 +869,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
876
869
|
onSessionConnected={onSessionConnected}
|
|
877
870
|
refreshKeys={refreshKeys}
|
|
878
871
|
skipPermissions={skipPermissions}
|
|
872
|
+
canClose={countTerminals(tab.tree) > 1}
|
|
873
|
+
onClosePane={tab.id === activeTabId ? closePaneById : undefined}
|
|
879
874
|
/>
|
|
880
875
|
</div>
|
|
881
876
|
))}
|
|
@@ -888,7 +883,7 @@ export default WebTerminal;
|
|
|
888
883
|
// ─── Pane renderer ───────────────────────────────────────────
|
|
889
884
|
|
|
890
885
|
function PaneRenderer({
|
|
891
|
-
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions,
|
|
886
|
+
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
|
|
892
887
|
}: {
|
|
893
888
|
node: SplitNode;
|
|
894
889
|
activeId: number;
|
|
@@ -898,11 +893,20 @@ function PaneRenderer({
|
|
|
898
893
|
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
899
894
|
refreshKeys: Record<number, number>;
|
|
900
895
|
skipPermissions?: boolean;
|
|
896
|
+
canClose?: boolean;
|
|
897
|
+
onClosePane?: (id: number) => void;
|
|
901
898
|
}) {
|
|
902
899
|
if (node.type === 'terminal') {
|
|
903
900
|
return (
|
|
904
|
-
<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)}>
|
|
905
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
|
+
)}
|
|
906
910
|
</div>
|
|
907
911
|
);
|
|
908
912
|
}
|
|
@@ -911,8 +915,8 @@ function PaneRenderer({
|
|
|
911
915
|
|
|
912
916
|
return (
|
|
913
917
|
<DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
|
|
914
|
-
<PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
|
|
915
|
-
<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} />
|
|
916
920
|
</DraggableSplit>
|
|
917
921
|
);
|
|
918
922
|
}
|
|
@@ -1198,12 +1202,29 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1198
1202
|
// Auto-run claude for project tabs (only if no pendingCommand already set)
|
|
1199
1203
|
if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
|
|
1200
1204
|
isNewlyCreated = false;
|
|
1201
|
-
|
|
1202
|
-
|
|
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' : '';
|
|
1203
1213
|
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
+
});
|
|
1207
1228
|
}
|
|
1208
1229
|
isNewlyCreated = false;
|
|
1209
1230
|
// Force tmux to redraw by toggling size, then send reset
|
package/lib/claude-sessions.ts
CHANGED
|
@@ -257,8 +257,8 @@ export function tailSessionFile(
|
|
|
257
257
|
|
|
258
258
|
watcher.on('error', (err) => onError?.(err));
|
|
259
259
|
|
|
260
|
-
// Poll every
|
|
261
|
-
const pollTimer = setInterval(readNewBytes,
|
|
260
|
+
// Poll every 1 second as fallback (fs.watch is unreliable on macOS)
|
|
261
|
+
const pollTimer = setInterval(readNewBytes, 1000);
|
|
262
262
|
|
|
263
263
|
return () => {
|
|
264
264
|
watcher.close();
|
package/lib/init.ts
CHANGED
|
@@ -82,11 +82,28 @@ 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');
|
|
88
105
|
syncSkills().catch(() => {});
|
|
89
|
-
setInterval(() => { syncSkills().catch(() => {}); },
|
|
106
|
+
setInterval(() => { syncSkills().catch(() => {}); }, 60 * 60 * 1000);
|
|
90
107
|
} catch {}
|
|
91
108
|
|
|
92
109
|
// Task runner is safe in every worker (DB-level coordination)
|
|
@@ -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
|
-
}
|