@aion0/forge 0.3.4 → 0.3.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/CLAUDE.md +15 -0
- package/app/api/favorites/route.ts +26 -0
- package/app/api/git/route.ts +40 -35
- package/app/api/help/route.ts +78 -0
- package/app/api/skills/route.ts +0 -2
- package/app/api/tabs/route.ts +25 -0
- package/bin/forge-server.mjs +1 -1
- package/components/Dashboard.tsx +16 -0
- package/components/DocsViewer.tsx +160 -3
- package/components/HelpDialog.tsx +169 -0
- package/components/HelpTerminal.tsx +130 -0
- package/components/ProjectDetail.tsx +1115 -0
- package/components/ProjectManager.tsx +189 -1105
- package/components/TabBar.tsx +46 -0
- package/lib/help-docs/00-overview.md +34 -0
- package/lib/help-docs/01-settings.md +37 -0
- package/lib/help-docs/02-telegram.md +41 -0
- package/lib/help-docs/03-tunnel.md +31 -0
- package/lib/help-docs/04-tasks.md +52 -0
- package/lib/help-docs/05-pipelines.md +73 -0
- package/lib/help-docs/06-skills.md +43 -0
- package/lib/help-docs/07-projects.md +39 -0
- package/lib/help-docs/08-rules.md +53 -0
- package/lib/help-docs/09-issue-autofix.md +51 -0
- package/lib/help-docs/10-troubleshooting.md +82 -0
- package/lib/settings.ts +2 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +12 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
interface DocItem {
|
|
6
|
+
name: string;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const HelpTerminal = lazy(() => import('./HelpTerminal'));
|
|
11
|
+
|
|
12
|
+
export default function HelpDialog({ onClose }: { onClose: () => void }) {
|
|
13
|
+
const [docs, setDocs] = useState<DocItem[]>([]);
|
|
14
|
+
const [agent, setAgent] = useState<{ name: string } | null | undefined>(undefined); // undefined = loading
|
|
15
|
+
const [viewDoc, setViewDoc] = useState<string | null>(null);
|
|
16
|
+
const [docContent, setDocContent] = useState('');
|
|
17
|
+
const [search, setSearch] = useState('');
|
|
18
|
+
const [tab, setTab] = useState<'docs' | 'chat'>('docs');
|
|
19
|
+
const [position, setPosition] = useState({ x: Math.max(0, window.innerWidth - 520), y: 50 });
|
|
20
|
+
const [size, setSize] = useState({ w: 500, h: 560 });
|
|
21
|
+
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
22
|
+
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
fetch('/api/help?action=status').then(r => r.json())
|
|
26
|
+
.then(data => setAgent(data.agent || null)).catch(() => setAgent(null));
|
|
27
|
+
fetch('/api/help?action=docs').then(r => r.json())
|
|
28
|
+
.then(data => setDocs(data.docs || [])).catch(() => {});
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const loadDoc = async (name: string) => {
|
|
32
|
+
setViewDoc(name);
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`/api/help?action=doc&name=${encodeURIComponent(name)}`);
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
setDocContent(data.content || '');
|
|
37
|
+
} catch { setDocContent('Failed to load'); }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Drag
|
|
41
|
+
const onDragStart = (e: React.MouseEvent) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: position.x, origY: position.y };
|
|
44
|
+
const onMove = (ev: MouseEvent) => {
|
|
45
|
+
if (!dragRef.current) return;
|
|
46
|
+
setPosition({
|
|
47
|
+
x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX),
|
|
48
|
+
y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY),
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
52
|
+
window.addEventListener('mousemove', onMove);
|
|
53
|
+
window.addEventListener('mouseup', onUp);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Resize
|
|
57
|
+
const onResizeStart = (e: React.MouseEvent) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
|
|
61
|
+
const onMove = (ev: MouseEvent) => {
|
|
62
|
+
if (!resizeRef.current) return;
|
|
63
|
+
setSize({
|
|
64
|
+
w: Math.max(350, resizeRef.current.origW + ev.clientX - resizeRef.current.startX),
|
|
65
|
+
h: Math.max(300, resizeRef.current.origH + ev.clientY - resizeRef.current.startY),
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
69
|
+
window.addEventListener('mousemove', onMove);
|
|
70
|
+
window.addEventListener('mouseup', onUp);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const filtered = search ? docs.filter(d => d.title.toLowerCase().includes(search.toLowerCase())) : docs;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
78
|
+
style={{ left: position.x, top: position.y, width: size.w, height: size.h }}
|
|
79
|
+
>
|
|
80
|
+
{/* Title bar */}
|
|
81
|
+
<div
|
|
82
|
+
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border)] cursor-move shrink-0 select-none"
|
|
83
|
+
onMouseDown={onDragStart}
|
|
84
|
+
>
|
|
85
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Forge Help</span>
|
|
86
|
+
<div className="ml-auto flex items-center gap-1">
|
|
87
|
+
<button
|
|
88
|
+
onClick={() => { setTab('docs'); setViewDoc(null); }}
|
|
89
|
+
className={`text-[9px] px-2 py-0.5 rounded ${tab === 'docs' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
|
|
90
|
+
>Docs</button>
|
|
91
|
+
{agent && (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => setTab('chat')}
|
|
94
|
+
className={`text-[9px] px-2 py-0.5 rounded ${tab === 'chat' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
|
|
95
|
+
>AI Chat</button>
|
|
96
|
+
)}
|
|
97
|
+
<button onClick={onClose} className="text-[var(--text-secondary)] hover:text-[var(--red)] ml-1 text-sm leading-none">✕</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{tab === 'chat' ? (
|
|
102
|
+
/* Embedded terminal */
|
|
103
|
+
<div className="flex-1 min-h-0">
|
|
104
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading terminal...</div>}>
|
|
105
|
+
<HelpTerminal />
|
|
106
|
+
</Suspense>
|
|
107
|
+
</div>
|
|
108
|
+
) : viewDoc ? (
|
|
109
|
+
/* Doc view */
|
|
110
|
+
<>
|
|
111
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
|
|
112
|
+
<button onClick={() => setViewDoc(null)} className="text-[10px] text-[var(--accent)]">← Back</button>
|
|
113
|
+
<span className="text-[10px] text-[var(--text-primary)] font-semibold truncate">
|
|
114
|
+
{docs.find(d => d.name === viewDoc)?.title || viewDoc}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex-1 overflow-y-auto p-3">
|
|
118
|
+
<pre className="text-[11px] text-[var(--text-primary)] whitespace-pre-wrap break-words font-mono leading-relaxed">
|
|
119
|
+
{docContent}
|
|
120
|
+
</pre>
|
|
121
|
+
</div>
|
|
122
|
+
</>
|
|
123
|
+
) : (
|
|
124
|
+
/* Doc list */
|
|
125
|
+
<>
|
|
126
|
+
<div className="px-3 py-2 border-b border-[var(--border)] shrink-0">
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
value={search}
|
|
130
|
+
onChange={e => setSearch(e.target.value)}
|
|
131
|
+
placeholder="Search help topics..."
|
|
132
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
133
|
+
autoFocus
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
{!agent && agent !== undefined && (
|
|
137
|
+
<div className="px-3 py-2 bg-[var(--yellow)]/10 border-b border-[var(--border)] shrink-0">
|
|
138
|
+
<p className="text-[9px] text-[var(--text-secondary)]">
|
|
139
|
+
Install Claude Code for AI help: <code className="text-[var(--accent)]">npm i -g @anthropic-ai/claude-code</code>
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
<div className="flex-1 overflow-y-auto">
|
|
144
|
+
{filtered.map(doc => (
|
|
145
|
+
<button
|
|
146
|
+
key={doc.name}
|
|
147
|
+
onClick={() => loadDoc(doc.name)}
|
|
148
|
+
className="w-full text-left px-3 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--bg-tertiary)] text-[11px] text-[var(--text-primary)] capitalize"
|
|
149
|
+
>
|
|
150
|
+
{doc.title}
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
<div className="px-3 py-2 border-t border-[var(--border)] shrink-0">
|
|
155
|
+
<a href="https://github.com/aiwatching/forge" target="_blank" rel="noopener noreferrer"
|
|
156
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--accent)]">GitHub →</a>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Resize handle */}
|
|
162
|
+
<div
|
|
163
|
+
onMouseDown={onResizeStart}
|
|
164
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
|
|
165
|
+
style={{ background: 'linear-gradient(135deg, transparent 50%, var(--border) 50%)' }}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Terminal } from '@xterm/xterm';
|
|
5
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
6
|
+
import '@xterm/xterm/css/xterm.css';
|
|
7
|
+
|
|
8
|
+
const SESSION_NAME = 'mw-forge-help';
|
|
9
|
+
|
|
10
|
+
function getWsUrl() {
|
|
11
|
+
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
|
|
12
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
|
+
const wsHost = window.location.hostname;
|
|
14
|
+
if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
|
|
15
|
+
return `${wsProtocol}//${window.location.host}/terminal-ws`;
|
|
16
|
+
}
|
|
17
|
+
const webPort = parseInt(window.location.port) || 3000;
|
|
18
|
+
return `${wsProtocol}//${wsHost}:${webPort + 1}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function HelpTerminal() {
|
|
22
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [connected, setConnected] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!containerRef.current) return;
|
|
27
|
+
|
|
28
|
+
let disposed = false;
|
|
29
|
+
const cs = getComputedStyle(document.documentElement);
|
|
30
|
+
const tv = (name: string) => cs.getPropertyValue(name).trim();
|
|
31
|
+
const term = new Terminal({
|
|
32
|
+
cursorBlink: true,
|
|
33
|
+
fontSize: 12,
|
|
34
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
35
|
+
scrollback: 3000,
|
|
36
|
+
logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
37
|
+
theme: {
|
|
38
|
+
background: tv('--term-bg') || '#1a1a2e',
|
|
39
|
+
foreground: tv('--term-fg') || '#e0e0e0',
|
|
40
|
+
cursor: tv('--term-cursor') || '#7c5bf0',
|
|
41
|
+
selectionBackground: (tv('--term-cursor') || '#7c5bf0') + '44',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const fit = new FitAddon();
|
|
45
|
+
term.loadAddon(fit);
|
|
46
|
+
term.open(containerRef.current);
|
|
47
|
+
try { fit.fit(); } catch {}
|
|
48
|
+
|
|
49
|
+
const wsUrl = getWsUrl();
|
|
50
|
+
let ws: WebSocket | null = null;
|
|
51
|
+
let reconnectTimer = 0;
|
|
52
|
+
let isNewSession = false;
|
|
53
|
+
|
|
54
|
+
function connect() {
|
|
55
|
+
if (disposed) return;
|
|
56
|
+
const socket = new WebSocket(wsUrl);
|
|
57
|
+
ws = socket;
|
|
58
|
+
|
|
59
|
+
socket.onopen = () => {
|
|
60
|
+
if (disposed) { socket.close(); return; }
|
|
61
|
+
socket.send(JSON.stringify({ type: 'attach', sessionName: SESSION_NAME, cols: term.cols, rows: term.rows }));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
socket.onmessage = (event) => {
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(event.data);
|
|
68
|
+
if (msg.type === 'output') {
|
|
69
|
+
try { term.write(msg.data); } catch {}
|
|
70
|
+
} else if (msg.type === 'connected') {
|
|
71
|
+
setConnected(true);
|
|
72
|
+
if (isNewSession) {
|
|
73
|
+
isNewSession = false;
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
76
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd ~/.forge/help 2>/dev/null && claude\n` }));
|
|
77
|
+
}
|
|
78
|
+
}, 300);
|
|
79
|
+
}
|
|
80
|
+
} else if (msg.type === 'error') {
|
|
81
|
+
isNewSession = true;
|
|
82
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
83
|
+
socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows, sessionName: SESSION_NAME }));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
socket.onclose = () => {
|
|
90
|
+
if (disposed) return;
|
|
91
|
+
setConnected(false);
|
|
92
|
+
reconnectTimer = window.setTimeout(connect, 3000);
|
|
93
|
+
};
|
|
94
|
+
socket.onerror = () => {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
connect();
|
|
98
|
+
|
|
99
|
+
term.onData((data) => {
|
|
100
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
104
|
+
const el = containerRef.current;
|
|
105
|
+
if (!el || el.offsetWidth < 50 || el.offsetHeight < 30) return;
|
|
106
|
+
try {
|
|
107
|
+
fit.fit();
|
|
108
|
+
if (term.cols < 2 || term.rows < 2) return;
|
|
109
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
110
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
});
|
|
114
|
+
resizeObserver.observe(containerRef.current);
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
disposed = true;
|
|
118
|
+
clearTimeout(reconnectTimer);
|
|
119
|
+
ws?.close();
|
|
120
|
+
resizeObserver.disconnect();
|
|
121
|
+
term.dispose();
|
|
122
|
+
};
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="h-full flex flex-col">
|
|
127
|
+
<div ref={containerRef} className="flex-1" />
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|