@aion0/forge 0.3.3 → 0.3.5

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.
@@ -2,9 +2,21 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
+ import TabBar from './TabBar';
5
6
 
6
7
  const DocTerminal = lazy(() => import('./DocTerminal'));
7
8
 
9
+ interface DocTab {
10
+ id: number;
11
+ filePath: string;
12
+ fileName: string;
13
+ rootIdx: number;
14
+ content: string | null;
15
+ isImage: boolean;
16
+ }
17
+
18
+ function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
19
+
8
20
  interface FileNode {
9
21
  name: string;
10
22
  path: string;
@@ -89,6 +101,141 @@ export default function DocsViewer() {
89
101
  const [saving, setSaving] = useState(false);
90
102
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
91
103
 
104
+ // Doc tabs
105
+ const [docTabs, setDocTabs] = useState<DocTab[]>([]);
106
+ const [activeDocTabId, setActiveDocTabId] = useState(0);
107
+ const saveTimerRef = useRef<any>(null);
108
+
109
+ // Load tabs from DB on mount
110
+ useEffect(() => {
111
+ fetch('/api/tabs?type=docs').then(r => r.json())
112
+ .then(data => {
113
+ if (Array.isArray(data.tabs) && data.tabs.length > 0) {
114
+ setDocTabs(data.tabs);
115
+ setActiveDocTabId(data.activeTabId || data.tabs[0].id);
116
+ // Set selectedFile to active tab's file
117
+ const activeId = data.activeTabId || data.tabs[0].id;
118
+ const active = data.tabs.find((t: any) => t.id === activeId);
119
+ if (active) {
120
+ setSelectedFile(active.filePath);
121
+ // Content not stored in DB, fetch it
122
+ if (!active.isImage) {
123
+ fetch(`/api/docs?root=${active.rootIdx}&file=${encodeURIComponent(active.filePath)}`)
124
+ .then(r => r.json())
125
+ .then(d => { setContent(d.content || null); })
126
+ .catch(() => {});
127
+ }
128
+ }
129
+ }
130
+ }).catch(() => {});
131
+ }, []);
132
+
133
+ // Persist tabs (debounced)
134
+ const persistDocTabs = useCallback((tabs: DocTab[], activeId: number) => {
135
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
136
+ saveTimerRef.current = setTimeout(() => {
137
+ fetch('/api/tabs?type=docs', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ tabs: tabs.map(t => ({ id: t.id, filePath: t.filePath, fileName: t.fileName, rootIdx: t.rootIdx, isImage: t.isImage })),
142
+ activeTabId: activeId,
143
+ }),
144
+ }).catch(() => {});
145
+ }, 500);
146
+ }, []);
147
+
148
+ // Open file in tab
149
+ const openFileInTab = useCallback(async (path: string) => {
150
+ setSelectedFile(path);
151
+ setEditing(false);
152
+ setFileWarning(null);
153
+
154
+ const isImg = isImageFile(path);
155
+ const fileName = path.split('/').pop() || path;
156
+
157
+ // Check if tab already exists (use functional update to get latest state)
158
+ let found = false;
159
+ setDocTabs(prev => {
160
+ const existing = prev.find(t => t.filePath === path);
161
+ if (existing) {
162
+ found = true;
163
+ setActiveDocTabId(existing.id);
164
+ setContent(existing.content);
165
+ persistDocTabs(prev, existing.id);
166
+ }
167
+ return prev;
168
+ });
169
+ if (found) return;
170
+
171
+ // Fetch content
172
+ let fileContent: string | null = null;
173
+ if (!isImg) {
174
+ setLoading(true);
175
+ const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
176
+ const data = await res.json();
177
+ if (data.tooLarge) {
178
+ setFileWarning(`File too large (${data.sizeLabel})`);
179
+ } else {
180
+ fileContent = data.content || null;
181
+ }
182
+ setLoading(false);
183
+ }
184
+ setContent(fileContent);
185
+
186
+ const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
187
+ setDocTabs(prev => {
188
+ // Double-check no duplicate
189
+ if (prev.find(t => t.filePath === path)) return prev;
190
+ const updated = [...prev, newTab];
191
+ setActiveDocTabId(newTab.id);
192
+ persistDocTabs(updated, newTab.id);
193
+ return updated;
194
+ });
195
+ }, [activeRoot, persistDocTabs]);
196
+
197
+ const closeDocTab = useCallback((tabId: number) => {
198
+ setDocTabs(prev => {
199
+ const updated = prev.filter(t => t.id !== tabId);
200
+ let newActiveId = activeDocTabId;
201
+ if (tabId === activeDocTabId) {
202
+ const idx = prev.findIndex(t => t.id === tabId);
203
+ const next = updated[Math.min(idx, updated.length - 1)];
204
+ newActiveId = next?.id || 0;
205
+ if (next) { setSelectedFile(next.filePath); setContent(next.content); }
206
+ else { setSelectedFile(null); setContent(null); }
207
+ }
208
+ setActiveDocTabId(newActiveId);
209
+ persistDocTabs(updated, newActiveId);
210
+ return updated;
211
+ });
212
+ }, [activeDocTabId, persistDocTabs]);
213
+
214
+ const activateDocTab = useCallback(async (tabId: number) => {
215
+ const tab = docTabs.find(t => t.id === tabId);
216
+ if (tab) {
217
+ setActiveDocTabId(tabId);
218
+ setSelectedFile(tab.filePath);
219
+ setEditing(false);
220
+ if (tab.rootIdx !== activeRoot) setActiveRoot(tab.rootIdx);
221
+ persistDocTabs(docTabs, tabId);
222
+
223
+ // Use cached content or re-fetch
224
+ if (tab.content) {
225
+ setContent(tab.content);
226
+ } else if (!tab.isImage) {
227
+ setLoading(true);
228
+ const res = await fetch(`/api/docs?root=${tab.rootIdx}&file=${encodeURIComponent(tab.filePath)}`);
229
+ const data = await res.json();
230
+ const fetched = data.content || null;
231
+ setContent(fetched);
232
+ // Cache in tab
233
+ setDocTabs(prev => prev.map(t => t.id === tabId ? { ...t, content: fetched } : t));
234
+ setLoading(false);
235
+ }
236
+ }
237
+ }, [docTabs, activeRoot, persistDocTabs]);
238
+
92
239
  // Fetch tree
93
240
  const fetchTree = useCallback(async (rootIdx: number) => {
94
241
  const res = await fetch(`/api/docs?root=${rootIdx}`);
@@ -223,7 +370,7 @@ export default function DocsViewer() {
223
370
  filtered.map(f => (
224
371
  <button
225
372
  key={f.path}
226
- onClick={() => { openFile(f.path); setSearch(''); }}
373
+ onClick={() => { openFileInTab(f.path); setSearch(''); }}
227
374
  className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
228
375
  selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
229
376
  }`}
@@ -236,15 +383,25 @@ export default function DocsViewer() {
236
383
  )
237
384
  ) : (
238
385
  tree.map(node => (
239
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
386
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
240
387
  ))
241
388
  )}
242
389
  </div>
243
390
  </aside>
244
391
  )}
245
392
 
246
- {/* Main content — full width markdown */}
393
+ {/* Main content */}
247
394
  <main className="flex-1 flex flex-col min-w-0">
395
+ {/* Doc tab bar */}
396
+ {docTabs.length > 0 && (
397
+ <TabBar
398
+ tabs={docTabs.map(t => ({ id: t.id, label: t.fileName.replace(/\.md$/, '') }))}
399
+ activeId={activeDocTabId}
400
+ onActivate={activateDocTab}
401
+ onClose={closeDocTab}
402
+ />
403
+ )}
404
+
248
405
  {/* Top bar */}
249
406
  <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
250
407
  <button
@@ -17,6 +17,7 @@ interface WorkflowNode {
17
17
  interface Workflow {
18
18
  name: string;
19
19
  description?: string;
20
+ builtin?: boolean;
20
21
  vars: Record<string, string>;
21
22
  input: Record<string, string>;
22
23
  nodes: Record<string, WorkflowNode>;
@@ -168,7 +169,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
168
169
  >
169
170
  <option value="">Editor ▾</option>
170
171
  <option value="">+ New workflow</option>
171
- {workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
172
+ {workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
172
173
  </select>
173
174
  <button
174
175
  onClick={() => setShowCreate(v => !v)}
@@ -188,7 +189,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
188
189
  >
189
190
  <option value="">Select workflow...</option>
190
191
  {workflows.map(w => (
191
- <option key={w.name} value={w.name}>{w.name}{w.description ? ` — ${w.description}` : ''}</option>
192
+ <option key={w.name} value={w.name}>{w.builtin ? '[Built-in] ' : ''}{w.name}{w.description ? ` — ${w.description}` : ''}</option>
192
193
  ))}
193
194
  </select>
194
195