dsh-contextos-devops 0.0.0-stage → 1.48.31

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/assets/avatar.jpg +0 -0
  4. package/assets/client.css +3316 -0
  5. package/assets/icon-180.png +0 -0
  6. package/build/client.mjs +64 -0
  7. package/client.js +7622 -0
  8. package/cordis.patch.yml +3 -0
  9. package/icon.svg +15 -0
  10. package/index.js +345 -0
  11. package/lib/base/env.js +22 -0
  12. package/lib/base/field.js +25 -0
  13. package/lib/base/http.js +140 -0
  14. package/lib/base/platform.js +33 -0
  15. package/lib/base/registry.js +186 -0
  16. package/lib/base/state.js +66 -0
  17. package/lib/base/table.js +47 -0
  18. package/lib/base/texttable.js +26 -0
  19. package/lib/base/url.js +178 -0
  20. package/lib/core/README.md +17 -0
  21. package/lib/core/apps.js +675 -0
  22. package/lib/core/auth.js +110 -0
  23. package/lib/core/avatar.js +289 -0
  24. package/lib/core/backup.js +105 -0
  25. package/lib/core/codegen.js +401 -0
  26. package/lib/core/copilot.js +419 -0
  27. package/lib/core/data.js +157 -0
  28. package/lib/core/device.js +148 -0
  29. package/lib/core/files.js +152 -0
  30. package/lib/core/gobuild.js +63 -0
  31. package/lib/core/iam.js +122 -0
  32. package/lib/core/identity.js +22 -0
  33. package/lib/core/nodes.js +627 -0
  34. package/lib/core/ops.js +306 -0
  35. package/lib/core/project.js +393 -0
  36. package/lib/core/spaces.js +406 -0
  37. package/lib/core/terminal.js +137 -0
  38. package/lib/core/terminal2.js +141 -0
  39. package/lib/core/users.js +51 -0
  40. package/lib/core/vocab.js +158 -0
  41. package/lib/misc/async.js +15 -0
  42. package/lib/misc/shell.js +27 -0
  43. package/lib/misc/static.js +33 -0
  44. package/locale/en.json +10 -0
  45. package/locale/zh.json +10 -0
  46. package/package.json +91 -4
  47. package/src/client/README.md +37 -0
  48. package/src/client/parts/00-prelude.js +315 -0
  49. package/src/client/parts/05-bootstrap.js +61 -0
  50. package/src/client/parts/08-store.js +85 -0
  51. package/src/client/parts/10-ui.js +188 -0
  52. package/src/client/parts/12-debug.js +361 -0
  53. package/src/client/parts/15-layout.js +86 -0
  54. package/src/client/parts/20-devworkspace.js +789 -0
  55. package/src/client/parts/25-editor-ui.js +28 -0
  56. package/src/client/parts/30-modals.js +85 -0
  57. package/src/client/parts/32-cards.js +176 -0
  58. package/src/client/parts/34-forms.js +176 -0
  59. package/src/client/parts/36-data.js +267 -0
  60. package/src/client/parts/40-project.js +430 -0
  61. package/src/client/parts/45-map.js +109 -0
  62. package/src/client/parts/46-pm.js +158 -0
  63. package/src/client/parts/47-three.js +324 -0
  64. package/src/client/parts/49-msg.js +167 -0
  65. package/src/client/parts/50-chat.js +1422 -0
  66. package/src/client/parts/51-pmselection.js +31 -0
  67. package/src/client/parts/52-pmdata.js +29 -0
  68. package/src/client/parts/55-pm.js +146 -0
  69. package/src/client/parts/58-config.js +381 -0
  70. package/src/client/parts/59-monitor.js +262 -0
  71. package/src/client/parts/60-page.js +1518 -0
  72. package/src/client/styles/kit.css +20 -0
  73. package/src/client/styles/main.css +3294 -0
  74. package/src/client/styles/tokens.css +2 -0
  75. package/vendor/leaflet/leaflet.css +661 -0
  76. package/vendor/leaflet/leaflet.js +6 -0
  77. package/vendor/three/three.module.js +53044 -0
  78. package/vendor/xterm/addon-fit.js +2 -0
  79. package/vendor/xterm/xterm.css +218 -0
  80. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,789 @@
1
+ function DevWorkspace({ locale, node, space, bg, theme, active, nodes, spaces, projects, activeKey, onSelectProject, onCloseProject, onSelectNode, onSelectSpace, onActiveFile, requests, onPick }) {
2
+ const zh = locale === 'zh';
3
+ const sp = space || '';
4
+ const normPath = (v) => String(v || '').replace(/^\/+/, '');
5
+ const [dirs, setDirs] = useState({});
6
+ const [expanded, setExpanded] = useState({ '': true });
7
+ const [tabs, setTabs] = useState([]);
8
+ const [activePath, setActivePath] = useState('');
9
+ const [status, setStatus] = useState('');
10
+ const [treeMenu, setTreeMenu] = useState(null);
11
+ const [nameDlg, setNameDlg] = useState(null);
12
+ const [crumbMenu, setCrumbMenu] = useState(null);
13
+ const [showLeft, setShowLeft] = useState(true);
14
+ const [showRight, setShowRight] = useState(false);
15
+ const [showBottom, setShowBottom] = useState(false);
16
+ const [previewNonce, setPreviewNonce] = useState(0);
17
+ const [previewWidth, setPreviewWidth] = useState(() => Number(cxReadText('preview-w', '360')) || 360);
18
+ useEffect(() => cxWriteText('preview-w', previewWidth), [previewWidth]);
19
+ const startResize = (e) => {
20
+ e.preventDefault();
21
+ const startX = e.clientX; const startW = previewWidth;
22
+ const onMove = (ev) => { const w = Math.min(Math.max(240, startW - (ev.clientX - startX)), 900); setPreviewWidth(w); };
23
+ const onUp = () => { document.body.style.userSelect = ''; window.removeEventListener('mousemove', onMove); };
24
+ document.body.style.userSelect = 'none';
25
+ window.addEventListener('mousemove', onMove);
26
+ window.addEventListener('mouseup', onUp, { once: true });
27
+ };
28
+ const [git, setGit] = useState(null);
29
+ const [gitOpen, setGitOpen] = useState(true);
30
+ const [gitHistOpen, setGitHistOpen] = useState(false);
31
+ const [cursor, setCursor] = useState({ line: 1, column: 1 });
32
+ const refreshGit = async () => {
33
+ if (!node) return;
34
+ try { setGit(await api('git', { node, space: sp })); } catch { /* no git */ }
35
+ };
36
+ useEffect(() => { refreshGit(); }, [node, space]);
37
+ const [recentOpen, setRecentOpen] = useState(true);
38
+ const [treeOpen, setTreeOpen] = useState(true);
39
+ const readTreeFolds = () => cxRead('tree-folds', {});
40
+ const treeFoldsKey = `${node || ''}:${space || ''}`;
41
+ const skipTreeFoldSaveRef = useRef(false);
42
+ useEffect(() => {
43
+ if (!node) return;
44
+ skipTreeFoldSaveRef.current = true;
45
+ const f = readTreeFolds()[treeFoldsKey] || {};
46
+ setRecentOpen(f.recent !== false);
47
+ setTreeOpen(f.tree !== false);
48
+ setGitOpen(f.git !== false);
49
+ setGitHistOpen(f.gitHist === true);
50
+ }, [node, space]);
51
+ useEffect(() => {
52
+ if (skipTreeFoldSaveRef.current) { skipTreeFoldSaveRef.current = false; return; }
53
+ if (!node) return;
54
+ try {
55
+ const all = readTreeFolds();
56
+ all[treeFoldsKey] = { recent: recentOpen, tree: treeOpen, git: gitOpen, gitHist: gitHistOpen };
57
+ cxWrite('tree-folds', all);
58
+ } catch {}
59
+ }, [recentOpen, treeOpen, gitOpen, gitHistOpen, node, space]);
60
+ const [editorErr, setEditorErr] = useState('');
61
+ const [termErr, setTermErr] = useState('');
62
+ useEffect(() => {
63
+ if (!active) return;
64
+ const t = setTimeout(() => {
65
+ Object.values(termMapRef.current).forEach((x) => { try { x.fit && x.fit.fit(); } catch {} });
66
+ const m = monacoRef.current;
67
+ if (m && editorRef.current) { try { editorRef.current.layout(); } catch {} }
68
+ }, 60);
69
+ return () => clearTimeout(t);
70
+ }, [active]);
71
+ useEffect(() => {
72
+ const onResize = () => {
73
+ requestAnimationFrame(() => {
74
+ try { editorRef.current && editorRef.current.layout && editorRef.current.layout(); } catch {}
75
+ Object.values(termMapRef.current).forEach((x) => { try { x.fit && x.fit.fit(); } catch {} });
76
+ });
77
+ };
78
+ window.addEventListener('resize', onResize);
79
+ return () => window.removeEventListener('resize', onResize);
80
+ }, []);
81
+ const [termTab, setTermTab] = useState('term');
82
+ const [termIds, setTermIds] = useState([1]);
83
+ const [termActive, setTermActive] = useState(1);
84
+ const [termOut, setTermOut] = useState('');
85
+ const [reqDetail, setReqDetail] = useState(null);
86
+ const termActiveRef = useRef(termActive); termActiveRef.current = termActive;
87
+ useEffect(() => {
88
+ if (!onActiveFile) return;
89
+ const timer = setTimeout(() => {
90
+ const t = tabs.find((x) => x.path === activePath);
91
+ onActiveFile(t ? {
92
+ node, space, path: String(t.path || '').replace(/^\/+/, ''), name: t.name, dirty: !!t.dirty,
93
+ language: (() => { const e = (t.path.split('.').pop() || '').toLowerCase(); return ({ go: 'go', js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', json: 'json', css: 'css', html: 'html', md: 'markdown', shy: 'shy', sh: 'shell', yml: 'yaml', yaml: 'yaml' })[e] || 'plaintext'; })(),
94
+ content: String(t.content || '').slice(0, 1500),
95
+ } : (node ? { node, space, path: '', name: '', dirty: false, language: '', content: '' } : null));
96
+ }, 400);
97
+ return () => clearTimeout(timer);
98
+ }, [activePath, tabs, node, space]);
99
+ const restoreTextRef = useRef({});
100
+ const termPendingRef = useRef(false);
101
+ const saveTimerRef = useRef(null);
102
+ const readTermBuf = () => cxRead('term-buf', {});
103
+ const readRemoteState = async () => { try { return (await api('stateGet')) || {}; } catch { return {}; } };
104
+ const writeRemoteState = (name, value) => { try { api('stateSet', { name, value }).catch(() => {}); } catch {} };
105
+ const termIdsRef = useRef(termIds); termIdsRef.current = termIds;
106
+ const termKeyRef = useRef('');
107
+ const persistTermBuf = (key) => {
108
+ const k = key || termKeyRef.current;
109
+ if (!k) return;
110
+ try {
111
+ const ids = []; const texts = {};
112
+ Object.keys(termMapRef.current).forEach((id) => {
113
+ const x = termMapRef.current[id];
114
+ if (x.key !== k) return;
115
+ ids.push(Number(id));
116
+ texts[id] = String(x.text || '').slice(-60000);
117
+ });
118
+ if (!ids.length) return;
119
+ ids.sort((a, b) => a - b);
120
+ const all = readTermBuf();
121
+ all[k] = { ids, texts };
122
+ cxWrite('term-buf', all);
123
+ writeRemoteState('termBuf', all);
124
+ } catch {}
125
+ };
126
+ const scheduleSave = () => { try { clearTimeout(saveTimerRef.current); } catch {} const k = termKeyRef.current; saveTimerRef.current = setTimeout(() => persistTermBuf(k), 800); };
127
+ const editorHostRef = useRef(null);
128
+ const taRef = useRef(null);
129
+ const termHostsRef = useRef({});
130
+ const editorRef = useRef(null);
131
+ const monacoRef = useRef(null);
132
+ const termMapRef = useRef({});
133
+ const tabsRef = useRef(tabs); tabsRef.current = tabs;
134
+ const activeRef = useRef(activePath); activeRef.current = activePath;
135
+ const restorePendingRef = useRef(false);
136
+ const saveRef = useRef(() => {});
137
+ const activeTab = tabs.find((t) => t.path === activePath) || null;
138
+
139
+ const ensureScript = (src) => new Promise((res, rej) => {
140
+ const hit = document.querySelector(`script[src="${src}"]`);
141
+ if (hit) return res();
142
+ const el = document.createElement('script'); el.src = src; el.async = true;
143
+ el.onload = () => { activity('加载脚本 ' + src, 'ok'); res(); }; el.onerror = () => { activity('脚本加载失败 ' + src, 'err'); rej(new Error('load failed: ' + src)); };
144
+ document.head.appendChild(el);
145
+ });
146
+ const ensureCss = (href) => {
147
+ if (document.querySelector(`link[href="${href}"]`)) return;
148
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l);
149
+ };
150
+ const registerShyLang = (monaco) => {
151
+ try {
152
+ if (monaco.languages.getLanguages().some((l) => l.id === 'shy')) return;
153
+ monaco.languages.register({ id: 'shy', extensions: ['.shy'], aliases: ['Shy', 'shy'] });
154
+ monaco.languages.setMonarchTokensProvider('shy', {
155
+ ignoreCase: false,
156
+ tokenizer: {
157
+ root: [
158
+ [/#.*$/, 'comment'],
159
+ [/^[ \t]*~[\w\-.]*/, 'keyword'],
160
+ [/^[ \t]*[\w\-./]+/, 'keyword'],
161
+ [/"/, { token: 'string.quote', next: '@dstr' }],
162
+ [/`/, { token: 'string.quote', next: '@bstr' }],
163
+ [/[a-zA-Z_][\w\-.]*/, 'identifier'],
164
+ [/\d[\w\-.:%/]*/, 'number'],
165
+ [/[=,;:(){}[\]]/, 'delimiter'],
166
+ ],
167
+ dstr: [
168
+ [/[^\\"]+/, 'string'],
169
+ [/\\./, 'string.escape'],
170
+ [/"/, { token: 'string.quote', next: '@pop' }],
171
+ ],
172
+ bstr: [
173
+ [/[^`]+/, 'string'],
174
+ [/`/, { token: 'string.quote', next: '@pop' }],
175
+ ],
176
+ },
177
+ });
178
+ monaco.languages.setLanguageConfiguration('shy', {
179
+ comments: { lineComment: '#' },
180
+ autoClosingPairs: [{ open: '"', close: '"' }, { open: '`', close: '`' }],
181
+ surroundingPairs: [{ open: '"', close: '"' }, { open: '`', close: '`' }],
182
+ });
183
+ } catch {}
184
+ };
185
+ let monacoPromise = null;
186
+ const loadMonaco = () => {
187
+ if (window.monaco && window.monaco.editor) return Promise.resolve(window.monaco);
188
+ if (monacoPromise) return monacoPromise;
189
+ monacoPromise = (async () => {
190
+ if (!(window.require && window.require.config)) {
191
+ try { if (window.require) delete window.require; } catch {}
192
+ await ensureScript('/dsh-contextos-devops/monaco/vs/loader.js');
193
+ }
194
+ return new Promise((res, rej) => {
195
+ const req = window.require;
196
+ if (!req || !req.config) return rej(new Error('monaco loader missing'));
197
+ req.config({ paths: { vs: '/dsh-contextos-devops/monaco/vs' } });
198
+ req(['vs/editor/editor.main'], () => {
199
+ if (window.monaco && window.monaco.editor) { activity('加载 Monaco 编辑器', 'ok'); res(window.monaco); }
200
+ else { monacoPromise = null; rej(new Error('monaco editor missing')); }
201
+ }, (e) => { monacoPromise = null; rej(e || new Error('monaco load failed')); });
202
+ });
203
+ })();
204
+ return monacoPromise;
205
+ };
206
+
207
+ const loadDir = async (p) => {
208
+ if (!node) return;
209
+ try {
210
+ const list = await api('tree', { node, space: sp, path: p });
211
+ setDirs((d) => ({ ...d, [p]: list }));
212
+ setExpanded((e) => ({ ...e, [p]: true }));
213
+ } catch (e) { setStatus(String(e?.message || e)); }
214
+ };
215
+ const toggleDir = (p) => {
216
+ setExpanded((e) => {
217
+ const on = !e[p];
218
+ if (on && !dirs[p]) loadDir(p);
219
+ return { ...e, [p]: on };
220
+ });
221
+ };
222
+ const openFile = async (rawPath) => {
223
+ const p = normPath(rawPath);
224
+ setStatus('');
225
+ if (!tabsRef.current.some((t) => t.path === p)) {
226
+ try {
227
+ const r = await api('readFile', { node, space: sp, path: p });
228
+ setTabs((ts) => [...ts, { path: p, name: p.split('/').pop(), content: r.content, dirty: false }]);
229
+ activity(`打开文件 ${p}(${String(r?.content || '').length}B)`, 'ok');
230
+ } catch (e) { setStatus(String(e?.message || e)); return; }
231
+ }
232
+ setActivePath(p);
233
+ };
234
+ const closeTab = (p) => {
235
+ setTabs((ts) => ts.filter((t) => t.path !== p));
236
+ if (activePath === p) setActivePath('');
237
+ };
238
+ const save = async () => {
239
+ const t = tabsRef.current.find((x) => x.path === activeRef.current);
240
+ const ed = editorRef.current;
241
+ if (!t) return;
242
+ const value = ed ? ed.getValue() : (taRef.current ? taRef.current.value : t.content);
243
+ try {
244
+ await api('writeFile', { node, space: sp, path: t.path, content: value });
245
+ activity(`保存文件 ${t.path}(${String(value || '').length}B)`, 'ok');
246
+ setTabs((ts) => ts.map((x) => x.path === t.path ? { ...x, content: value, dirty: false } : x));
247
+ setStatus(zh ? `已保存 ${t.path}` : `saved ${t.path}`);
248
+ refreshGit();
249
+ } catch (e) { setStatus(String(e?.message || e)); }
250
+ };
251
+ saveRef.current = save;
252
+
253
+ const ideKey = `${node || ''}:${space || ''}`;
254
+ const [ideVersion, setIdeVersion] = useState(0);
255
+ useEffect(() => {
256
+ (async () => {
257
+ try {
258
+ const st = await api('stateGet');
259
+ const remote = st && st.ideOpen;
260
+ if (!remote || typeof remote !== 'object') return;
261
+ const local = readIdeOpen();
262
+ let changed = false;
263
+ for (const k of Object.keys(remote)) { if (!local[k]) { local[k] = remote[k]; changed = true; } }
264
+ if (changed) { cxWrite('ide-open', local); setIdeVersion((v) => v + 1); }
265
+ } catch {}
266
+ })();
267
+ }, []);
268
+ const readIdeOpen = () => cxRead('ide-open', {});
269
+ useEffect(() => {
270
+ if (!node) return;
271
+ restorePendingRef.current = true;
272
+ setStatus('');
273
+ (async () => {
274
+ try {
275
+ const list = await api('tree', { node, space, path: '' });
276
+ setDirs({ '': list }); setExpanded({ '': true });
277
+ } catch {}
278
+ })();
279
+ const openDefault = async () => {
280
+ for (const p of ['src/main.shy', 'main.shy', 'src/main.go']) {
281
+ try {
282
+ const r = await api('readFile', { node, space: sp, path: p });
283
+ setTabs([{ path: p, name: p.split('/').pop(), content: r.content, dirty: false }]);
284
+ setActivePath(p);
285
+ return;
286
+ } catch { /* try next */ }
287
+ }
288
+ setTabs([]); setActivePath('');
289
+ };
290
+ const saved = readIdeOpen()[ideKey];
291
+ if (!saved || !Array.isArray(saved.tabs) || !saved.tabs.length) { openDefault(); restorePendingRef.current = false; return; }
292
+ let disposed = false;
293
+ (async () => {
294
+ const out = [];
295
+ for (const raw of saved.tabs.slice(0, 12)) {
296
+ const p = String(raw || '').replace(/^\/+/, '');
297
+ if (!p) continue;
298
+ try { const r = await api('readFile', { node, space: sp, path: p }); out.push({ path: p, name: p.split('/').pop(), content: r.content, dirty: false }); } catch { /* skip missing */ }
299
+ }
300
+ if (disposed) { restorePendingRef.current = false; return; }
301
+ if (!out.length) { await openDefault(); restorePendingRef.current = false; return; }
302
+ setTabs(out);
303
+ setActivePath(out.some((t) => t.path === String(saved.active || '').replace(/^\/+/, '')) ? String(saved.active || '').replace(/^\/+/, '') : (out[0] ? out[0].path : ''));
304
+ restorePendingRef.current = false;
305
+ })();
306
+ return () => { disposed = true; };
307
+ }, [node, space, ideVersion]);
308
+ useEffect(() => {
309
+ if (!node || restorePendingRef.current) return;
310
+ try {
311
+ const all = readIdeOpen();
312
+ all[ideKey] = { tabs: tabs.map((t) => t.path), active: activePath };
313
+ cxWrite('ide-open', all);
314
+ try { api('stateSet', { name: 'ideOpen', value: all }).catch(() => {}); } catch {}
315
+ } catch {}
316
+ }, [tabs, activePath, node, space]);
317
+
318
+ useEffect(() => {
319
+ let disposed = false; let ed = null;
320
+ (async () => {
321
+ try {
322
+ const monaco = await loadMonaco();
323
+ if (disposed || !editorHostRef.current) return;
324
+ monacoRef.current = monaco;
325
+ registerShyLang(monaco);
326
+ ed = monaco.editor.create(editorHostRef.current, { value: '', theme: theme === 'light' ? 'vs' : 'vs-dark', automaticLayout: true, fontSize: 12, minimap: { enabled: false }, scrollBeyondLastLine: false, tabSize: 4 });
327
+ editorRef.current = ed;
328
+ ed.onDidChangeModelContent(() => {
329
+ const p = activeRef.current;
330
+ if (!p) return;
331
+ const v = ed.getValue();
332
+ setTabs((ts) => ts.map((t) => (t.path === p && t.content !== v ? { ...t, content: v, dirty: true } : t)));
333
+ });
334
+ ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveRef.current());
335
+ ed.onDidChangeCursorPosition((ev) => { try { setCursor({ line: ev.position.lineNumber, column: ev.position.column }); } catch {} });
336
+ const cur = tabsRef.current.find((t) => t.path === activeRef.current);
337
+ if (cur) {
338
+ ed.setValue(cur.content);
339
+ try { if (ed.getModel()) monaco.editor.setModelLanguage(ed.getModel(), 'plaintext'); } catch {}
340
+ }
341
+ setEditorErr('');
342
+ } catch (e) { setEditorErr(String(e?.message || e)); }
343
+ })();
344
+ return () => { disposed = true; try { ed && ed.dispose(); } catch {} };
345
+ }, []);
346
+
347
+ useEffect(() => {
348
+ const m = monacoRef.current;
349
+ if (!m || !m.editor) return;
350
+ try { m.editor.setTheme(theme === 'light' ? 'vs' : 'vs-dark'); } catch {}
351
+ }, [theme]);
352
+ useEffect(() => {
353
+ const ed = editorRef.current, monaco = monacoRef.current;
354
+ if (!ed || !activeTab) return;
355
+ const lang = (() => {
356
+ const ext = (activeTab.path.split('.').pop() || '').toLowerCase();
357
+ return ({ go: 'go', js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', json: 'json', css: 'css', html: 'html', md: 'markdown', sh: 'shell', yml: 'yaml', yaml: 'yaml', sql: 'sql', py: 'python', shy: 'shy' })[ext] || 'plaintext';
358
+ })();
359
+ if (ed.getValue() !== activeTab.content) ed.setValue(activeTab.content);
360
+ try { if (monaco && ed.getModel()) monaco.editor.setModelLanguage(ed.getModel(), lang); } catch {}
361
+ }, [activePath, activeTab && activeTab.path]);
362
+
363
+ useEffect(() => {
364
+ const nextKey = `${node}:${space}`;
365
+ if (termKeyRef.current === nextKey && Object.keys(termMapRef.current).length) return;
366
+ const prevKey = termKeyRef.current;
367
+ if (prevKey && prevKey !== nextKey) persistTermBuf(prevKey);
368
+ termPendingRef.current = true;
369
+ let cancelled = false;
370
+ (async () => {
371
+ const remote = await readRemoteState();
372
+ if (cancelled) return;
373
+ const saved = (remote && remote.termBuf && remote.termBuf[nextKey]) || readTermBuf()[nextKey] || {};
374
+ const ids = Array.isArray(saved.ids) && saved.ids.length ? saved.ids : [1];
375
+ const texts = saved.texts || {};
376
+ const cwd = (await api('spaceDir', { node, space: sp }).catch(() => ({ dir: '' }))).dir || '';
377
+ if (cancelled) return;
378
+ Object.keys(termMapRef.current).forEach((id) => {
379
+ if (ids.map((x) => String(x)).includes(String(id))) return;
380
+ const x = termMapRef.current[id];
381
+ x.alive = false; try { x.timer && clearTimeout(x.timer); } catch {}
382
+ try { x.ws && x.ws.close(); } catch {}
383
+ try { x.term && x.term.dispose(); } catch {}
384
+ delete termMapRef.current[id];
385
+ });
386
+ ids.forEach((id) => {
387
+ const x = termMapRef.current[id];
388
+ if (!x) return;
389
+ x.key = nextKey; x.cwd = cwd;
390
+ try { x.term.reset(); } catch {}
391
+ const t = texts[id] || '';
392
+ try { if (t) x.term.write(t); } catch {}
393
+ x.text = t;
394
+ try { x.openWs && x.openWs(cwd); } catch {}
395
+ });
396
+ termKeyRef.current = nextKey;
397
+ restoreTextRef.current = texts;
398
+ termPendingRef.current = false;
399
+ setTermIds(ids); setTermActive(ids[0]); setTermErr(''); setTermTab('term');
400
+ })();
401
+ return () => { cancelled = true; termPendingRef.current = false; };
402
+ }, [node, space]);
403
+ useEffect(() => {
404
+ if (termPendingRef.current) return;
405
+ let cancelled = false;
406
+ (async () => {
407
+ try {
408
+ ensureCss('/dsh-contextos-devops/xterm/xterm.css');
409
+ const evalScript = async (src) => {
410
+ const code = await (await fetch(src)).text();
411
+ const fn = new Function('module', 'exports', 'define', 'require', 'window', 'self', 'globalThis', code);
412
+ fn(undefined, undefined, undefined, undefined, window, window, window);
413
+ };
414
+ if (!window.Terminal) { activity('加载终端 xterm', 'info'); await evalScript('/dsh-contextos-devops/xterm/xterm.js'); activity('终端 xterm 就绪', 'ok'); }
415
+ if (!window.Terminal) throw new Error('xterm missing');
416
+ if (!(window.FitAddon && window.FitAddon.FitAddon)) await evalScript('/dsh-contextos-devops/xterm/addon-fit.js').catch(() => {});
417
+ const info = await api('spaceDir', { node, space: sp }).catch(() => ({ dir: '' }));
418
+ for (const id of termIds) {
419
+ if (termMapRef.current[id]) continue;
420
+ const host = termHostsRef.current[id];
421
+ if (!host) continue;
422
+ const lightNow = (document.querySelector('.cx-shell') || {}).dataset?.theme === 'light';
423
+ const termTheme = lightNow
424
+ ? { background: '#ffffff', foreground: '#1d3a4a', cursor: '#0b7f9f', selectionBackground: 'rgba(11,127,159,.22)' }
425
+ : { background: '#04101a', foreground: '#cfeff9', cursor: '#42f5a7' };
426
+ const term = new window.Terminal({ fontSize: 12, fontFamily: 'Menlo, monospace', cursorBlink: true, convertEol: true, theme: termTheme });
427
+ const fit = window.FitAddon && window.FitAddon.FitAddon ? new window.FitAddon.FitAddon() : null;
428
+ if (fit) term.loadAddon(fit);
429
+ term.open(host);
430
+ const restored = restoreTextRef.current && restoreTextRef.current[id];
431
+ if (restored) { try { term.write(restored); } catch {} }
432
+ if (host.offsetParent) { try { fit && fit.fit(); } catch {} }
433
+ let wsHost = location.host;
434
+ let wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
435
+ if (!/^https?:$/.test(location.protocol)) {
436
+ try { const o = await api('origin'); if (o && o.host) { wsHost = o.host; wsProto = 'ws'; } } catch {}
437
+ }
438
+ const state = { term, ws: null, fit, alive: true, timer: null, key: `${node}:${space}`, cwd: info.dir || '' };
439
+ if (restored) state.text = restored;
440
+ const openWs = (cwd) => {
441
+ if (!state.alive) return;
442
+ const url = `${wsProto}://${wsHost}/dsh-contextos-devops/terminal?cwd=${encodeURIComponent(cwd || '')}`;
443
+ state.switching = true;
444
+ try { state.ws && state.ws.close(); } catch {}
445
+ state.switching = false;
446
+ const ws = new WebSocket(url);
447
+ state.ws = ws;
448
+ ws.onmessage = (ev) => { if (ws !== state.ws) return; try { term.write(ev.data); } catch {}; state.text = String(state.text || '') + String(ev.data); if (state.text.length > 400000) state.text = state.text.slice(-200000); scheduleSave(); };
449
+ ws.onclose = () => {
450
+ if (!state.alive || state.switching || ws !== state.ws) return;
451
+ state.ws = null;
452
+ state.fail = (state.fail || 0) + 1;
453
+ try {
454
+ term.write('\r\n[reconnecting…]\r\n');
455
+ if (state.fail === 3) term.write(`[terminal ${url}]\r\n`);
456
+ } catch {}
457
+ state.timer = setTimeout(() => openWs(state.cwd), 1000);
458
+ };
459
+ ws.onopen = () => { if (ws !== state.ws) return; state.fail = 0; };
460
+ ws.onerror = () => {};
461
+ };
462
+ state.openWs = openWs;
463
+ term.onData((d) => { const ws = state.ws; if (ws && ws.readyState === 1) ws.send(d); });
464
+ openWs(state.cwd);
465
+ termMapRef.current[id] = state;
466
+ setTimeout(() => { try { fit && fit.fit(); } catch {} }, 150);
467
+ }
468
+ if (!cancelled) setTermErr('');
469
+ } catch (e) { if (!cancelled) setTermErr(String(e?.message || e)); }
470
+ })();
471
+ const onResize = () => {
472
+ const active = termMapRef.current[termActiveRef.current];
473
+ if (active && active.term && active.term.element && active.term.element.offsetParent) { try { active.fit && active.fit.fit(); } catch {} }
474
+ };
475
+ window.addEventListener('resize', onResize);
476
+ return () => { cancelled = true; window.removeEventListener('resize', onResize); };
477
+ }, [termIds, node, space]);
478
+ useEffect(() => {
479
+ if (!showBottom || termTab !== 'term') return;
480
+ const t = setTimeout(() => {
481
+ const x = termMapRef.current[termActive];
482
+ if (!x) return;
483
+ try { x.fit && x.fit.fit(); } catch {}
484
+ try { x.term && x.term.refresh(0, Math.max(0, x.term.rows - 1)); } catch {}
485
+ }, 80);
486
+ return () => clearTimeout(t);
487
+ }, [showBottom, termTab, termActive, termIds]);
488
+ useEffect(() => () => {
489
+ persistTermBuf(termKeyRef.current);
490
+ Object.values(termMapRef.current).forEach((x) => { x.alive = false; try { x.timer && clearTimeout(x.timer); } catch {}; try { x.ws && x.ws.close(); } catch {}; try { x.term && x.term.dispose(); } catch {} });
491
+ termMapRef.current = {};
492
+ }, []);
493
+
494
+ const askName = (title, value = '') => new Promise((resolve) => setNameDlg({ title, value, resolve }));
495
+ const fileColor = (name) => {
496
+ const ext = (String(name).split('.').pop() || '').toLowerCase();
497
+ return ({
498
+ go: '#00d3d3', js: '#f7df1e', jsx: '#f7df1e', ts: '#3b9cff', tsx: '#3b9cff', json: '#f5c542',
499
+ md: '#b07cff', css: '#42a5f5', scss: '#42a5f5', html: '#ff7043', htm: '#ff7043', sh: '#8bc34a',
500
+ yml: '#7cb342', yaml: '#7cb342', py: '#4fc3f7', sql: '#f06292', png: '#ef5350', jpg: '#ef5350',
501
+ jpeg: '#ef5350', gif: '#ef5350', svg: '#ffb74d', mod: '#00d3d3', sum: '#00d3d3', shy: '#b07cff',
502
+ })[ext] || 'var(--cx-text-faint)';
503
+ };
504
+ const recentFiles = (() => { try { const m = readIdeOpen()[ideKey]; return (m && m.tabs) || []; } catch { return []; } })();
505
+ const fileGlyph = (name) => {
506
+ const ext = (String(name).split('.').pop() || '').toLowerCase();
507
+ return ({ go: 'GO', js: 'JS', ts: 'TS', json: '{}', css: 'CS', html: '<>', md: 'MD', sh: '$', yml: 'YM', yaml: 'YM', sql: 'DB', py: 'PY' })[ext] || '·';
508
+ };
509
+ const dirOf = (entry) => (entry.type === 'dir' ? entry.path : entry.path.split('/').slice(0, -1).join('/'));
510
+ const reloadDir = async (p) => {
511
+ try { const list = await api('tree', { node, space: sp, path: p }); setDirs((d) => ({ ...d, [p]: list })); setExpanded((e) => ({ ...e, [p]: true })); } catch (e) { setStatus(String(e?.message || e)); }
512
+ };
513
+ const newEntry = async (entry, isDir) => {
514
+ const dir = dirOf(entry);
515
+ const name = await askName(zh ? (isDir ? '新文件夹名' : '新文件名') : (isDir ? 'Folder name' : 'File name'), '');
516
+ if (!name) return;
517
+ const target = [dir, name].filter(Boolean).join('/');
518
+ try {
519
+ if (isDir) await api('mkdir', { node, space: sp, path: target });
520
+ else await api('writeFile', { node, space: sp, path: target, content: '' });
521
+ await reloadDir(dir); refreshGit();
522
+ if (!isDir) openFile(target);
523
+ } catch (e) { setStatus(String(e?.message || e)); }
524
+ };
525
+ const renameEntry = async (entry) => {
526
+ const dir = dirOf(entry);
527
+ const name = await askName(zh ? '重命名为' : 'Rename to', entry.name);
528
+ if (!name || name === entry.name) return;
529
+ const target = [dir, name].filter(Boolean).join('/');
530
+ try {
531
+ await api('renamePath', { node, space: sp, from: entry.path, to: target });
532
+ closeTab(entry.path);
533
+ await reloadDir(dir); refreshGit();
534
+ } catch (e) { setStatus(String(e?.message || e)); }
535
+ };
536
+ const removeEntry = async (entry) => {
537
+ if (!window.confirm(zh ? `删除「${entry.name}」?` : `Delete "${entry.name}"?`)) return;
538
+ const dir = dirOf(entry);
539
+ try { await api('removePath', { node, space: sp, path: entry.path }); closeTab(entry.path); await reloadDir(dir); refreshGit(); }
540
+ catch (e) { setStatus(String(e?.message || e)); }
541
+ };
542
+ const readPanes = () => cxRead('ide-panes', {});
543
+ useEffect(() => {
544
+ if (!node) return;
545
+ const p = readPanes()[`${node}:${space}`];
546
+ setShowLeft(p ? !!p.left : true);
547
+ setShowRight(p ? !!p.right : false);
548
+ setShowBottom(p ? !!p.bottom : false);
549
+ }, [node, space]);
550
+ useEffect(() => {
551
+ if (!node) return;
552
+ try { const all = readPanes(); all[`${node}:${space}`] = { left: showLeft, right: showRight, bottom: showBottom }; cxWrite('ide-panes', all); } catch {}
553
+ }, [showLeft, showRight, showBottom, node, space]);
554
+ const jumpLine = (line) => { const ed = editorRef.current; if (ed) { try { ed.revealLineInCenter(line); ed.setPosition({ lineNumber: line, column: 1 }); ed.focus(); } catch {} } };
555
+ const gitMark = (p) => (git && git.files ? git.files[p] : '');
556
+ const gitClass = (x) => (x === 'A' || x === '?' || x === '??' ? 'add' : x === 'D' ? 'del' : x === 'U' || x === 'UU' ? 'unt' : 'mod');
557
+ const langOfPath = (p) => {
558
+ const e = String(p || '').split('.').pop().toLowerCase();
559
+ return ({ go: 'go', js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', json: 'json', md: 'markdown', css: 'css', scss: 'scss', html: 'html', htm: 'html', sh: 'shell', yml: 'yaml', yaml: 'yaml', py: 'python', sql: 'sql', mod: 'go', sum: 'plaintext' })[e] || 'plaintext';
560
+ };
561
+ const symbolsOf = (text) => {
562
+ const out = [];
563
+ String(text || '').split('\n').forEach((ln, i) => {
564
+ const m = ln.match(/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/) || ln.match(/^\s*(?:function|class|def|struct|interface|type)\s+([A-Za-z_]\w*)/);
565
+ if (m) out.push({ label: m[1], hint: String(i + 1), icon: `<svg width='12' height='12' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round'><path d='M6 4.2c-1.2 0-1.6.7-1.6 1.7v1.3c0 .6-.3 1-.9 1.1.6.1.9.5.9 1.1v1.3c0 1 .4 1.7 1.6 1.7'/><path d='M10 4.2c1.2 0 1.6.7 1.6 1.7v1.3c0 .6.3 1 .9 1.1-.6.1-.9.5-.9 1.1v1.3c0 1-.4 1.7-1.6 1.7'/></svg>`, action: () => jumpLine(i + 1) });
566
+ });
567
+ return out;
568
+ };
569
+ const menuAt = (el) => { const r = el.getBoundingClientRect(); return { x: Math.max(8, r.left), y: r.bottom + 4 }; };
570
+ const menuStyle = (m) => ({ left: m.x, top: m.y, maxHeight: Math.max(120, (typeof window !== 'undefined' ? window.innerHeight : 800) - m.y - 18), overflowY: 'auto', overscrollBehavior: 'contain' });
571
+ const listDir = async (dirPath) => { try { return await api('tree', { node, space, path: dirPath }); } catch { return []; } };
572
+ const dirItems = (dirPath, current) => ev => {
573
+ const pos = menuAt(ev.currentTarget || ev.target);
574
+ listDir(dirPath).then((list) => setCrumbMenu({ ...pos, items: (list || []).map((e) => ({
575
+ label: e.name, hint: e.type === 'dir' ? 'dir' : '', icon: e.type === 'dir' ? dirIconHtml(false) : fileIconHtml(e.name),
576
+ on: e.path === current || (e.type !== 'dir' && e.path === activePath),
577
+ action: () => { if (e.type === 'dir') { setExpanded((m) => ({ ...m, [([dirPath, e.name].filter(Boolean).join('/'))]: true })); } else openFile(e.path); },
578
+ })) }));
579
+ };
580
+ const addTerm = () => { const id = Math.max(0, ...termIds) + 1; setTermIds([...termIds, id]); setTermActive(id); setTermTab('term'); setTimeout(() => scheduleSave(), 0); };
581
+ const termAct = (kind) => {
582
+ const st = termMapRef.current[termActive];
583
+ if (!st) return;
584
+ if (kind === 'clear') { try { st.term.clear(); } catch {} }
585
+ if (kind === 'refresh') { try { st.ws && st.ws.readyState === 1 && st.ws.send('clear\r'); } catch {} }
586
+ if (kind === 'reconnect') { try { st.ws && st.ws.close(); } catch {}; try { st.openWs && st.openWs(st.cwd); } catch {} }
587
+ if (kind === 'split') addTerm();
588
+ };
589
+ const closeTerm = (id) => {
590
+ const x = termMapRef.current[id];
591
+ if (x) { x.alive = false; try { x.timer && clearTimeout(x.timer); } catch {} }
592
+ try { x && x.ws && x.ws.close(); } catch {}
593
+ try { x && x.term && x.term.dispose(); } catch {}
594
+ delete termMapRef.current[id];
595
+ const rest = termIds.filter((t) => t !== id);
596
+ const next = rest.length ? rest : [1];
597
+ setTermIds(next);
598
+ if (termActive === id) setTermActive(next[0]);
599
+ setTimeout(() => scheduleSave(), 0);
600
+ };
601
+ const compile = async () => {
602
+ if (!node || !space) return;
603
+ setStatus(zh ? '编译中…' : 'compiling…');
604
+ try {
605
+ const r = await api('compile', { node, space });
606
+ const out = String(r?.out || '');
607
+ setStatus(r?.status === 200 ? (zh ? '编译完成' : 'compiled') : (zh ? '编译失败' : 'compile failed'));
608
+ setTermOut((prev) => `${prev}$ compile\n${out}\n\n`);
609
+ setTermTab('out');
610
+ } catch (e) { setStatus(String(e?.message || e)); }
611
+ };
612
+ useEffect(() => {
613
+ if (!treeMenu && !crumbMenu) return;
614
+ const close = () => { setTreeMenu(null); setCrumbMenu(null); };
615
+ window.addEventListener('click', close);
616
+ window.addEventListener('scroll', close, true);
617
+ return () => { window.removeEventListener('click', close); window.removeEventListener('scroll', close, true); };
618
+ }, [treeMenu, crumbMenu]);
619
+ const sortEntries = (list) => {
620
+ const ORDER = ['src', 'etc', 'bin', 'var', 'usr'];
621
+ const rank = (e) => { const i = ORDER.indexOf(e.name); return i < 0 ? 99 : i; };
622
+ return (list || []).filter((e) => !String(e.name || '').startsWith('.')).slice().sort((a, b) => {
623
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
624
+ if (a.type === 'dir') { const ra = rank(a), rb = rank(b); if (ra !== rb) return ra - rb; }
625
+ return String(a.name).localeCompare(String(b.name));
626
+ });
627
+ };
628
+ const renderDir = (p, depth) => sortEntries(dirs[p]).map((e) => {
629
+ const isDir = e.type === 'dir';
630
+ const key = (p ? p + '/' : '') + e.name;
631
+ const open = !!expanded[key];
632
+ return h(React.Fragment, { key },
633
+ h('div', { className: `cx-tree-item${activePath === e.path ? ' on' : ''}`, style: { paddingLeft: 6 + depth * 12 }, onClick: () => (isDir ? toggleDir(key) : openFile(e.path)), onContextMenu: (ev) => { ev.preventDefault(); ev.stopPropagation(); setTreeMenu({ x: ev.clientX, y: ev.clientY, entry: e }); }, title: e.path },
634
+ isDir ? h('span', { className: 'chev', dangerouslySetInnerHTML: chevHtml(open) }) : h('span', { className: 'chev' }),
635
+ h('span', { className: 'fic', dangerouslySetInnerHTML: isDir ? dirIconHtml(open) : fileIconHtml(e.name) }),
636
+ h('span', { className: 'nm' }, e.name),
637
+ gitMark(e.path) ? h('span', { className: `cx-git-badge ${gitClass(gitMark(e.path))}` }, gitMark(e.path) === '??' ? 'U' : gitMark(e.path)) : null),
638
+ isDir && open ? renderDir(key, depth + 1) : null);
639
+ });
640
+
641
+ const previewUrl = node && space ? `http://127.0.0.1:${node}/s/${space}/` : '';
642
+ const previewKindOf = (p) => {
643
+ const e = String(p || '').split('.').pop().toLowerCase();
644
+ if (['html', 'htm', 'xhtml'].includes(e)) return 'html';
645
+ if (['md', 'markdown', 'mdx'].includes(e)) return 'md';
646
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(e)) return 'image';
647
+ if (['txt', 'log', 'csv', 'json', 'yml', 'yaml', 'ini', 'conf', 'makefile', 'license'].includes(e)) return 'text';
648
+ return 'page';
649
+ };
650
+ const pvKind = activePath ? previewKindOf(activePath) : 'page';
651
+
652
+ return h('div', { className: 'cx-dev-wrap' },
653
+ bg && bg.image ? h('div', { className: 'cx-chat-bg photo' }, h('img', { src: bg.image, alt: '' })) : null,
654
+ h('div', { className: `cx-dev${showLeft ? '' : ' hide-left'}${showRight ? '' : ' hide-right'}${showBottom ? '' : ' hide-bottom'}`, style: { '--cx-preview-w': `${previewWidth}px` } },
655
+ h('section', { className: 'cx-dev-col cx-dev-explorer' },
656
+ h('div', { className: 'cx-dev-b' },
657
+ !node
658
+ ? h(EmptyState, { text: zh ? '先在服务节点/项目空间上点「编程」打开项目。' : 'Pick a node/space and click CODE.' })
659
+ : h('div', { className: 'cx-tree' },
660
+ h('div', { className: 'cx-tree-recent' },
661
+ h('div', { className: 'cx-tree-sect', onClick: () => setRecentOpen((v) => !v) }, h('span', { className: 'chev', dangerouslySetInnerHTML: chevHtml(recentOpen) }), zh ? '最近打开的文件' : 'RECENT'),
662
+ recentOpen && recentFiles.length ? recentFiles.slice(0, 8).map((p) => h('div', { key: 'r:' + p, className: `cx-tree-item${activePath === p ? ' on' : ''}`, style: { paddingLeft: 6 }, onClick: () => openFile(p) },
663
+ h('span', { className: 'chev' }),
664
+ h('span', { className: 'fic', dangerouslySetInnerHTML: fileIconHtml(p) }),
665
+ h('span', { className: 'nm' }, p.split('/').pop()))) : null),
666
+
667
+ h('div', { className: 'cx-tree-sect', onClick: () => setTreeOpen((v) => !v) }, h('span', { className: 'chev', dangerouslySetInnerHTML: chevHtml(treeOpen) }), zh ? '资源管理器' : 'WORKSPACE',
668
+ h(IconButton, { name: 'refresh', className: 'cx-pm-refresh', style: { marginLeft: 'auto' }, onClick: (ev) => { ev.stopPropagation(); reloadDir(''); } }),
669
+ h(IconButton, { name: 'plus', className: 'cx-pm-op', onClick: (ev) => { ev.stopPropagation(); newEntry({ type: 'dir', path: '', name: '' }, false); }, title: zh ? '新建文件' : 'New file' })),
670
+ treeOpen ? renderDir('', 0) : null,
671
+ h(GitSections, { git, gitOpen, setGitOpen, gitHistOpen, setGitHistOpen, activePath, openFile, setStatus, zh }),
672
+ status ? h('div', { className: 'cx-dev-status' }, status) : null))),
673
+ h('section', { className: 'cx-dev-col cx-dev-editor' },
674
+ h(EditorTabs, { tabs, activePath, setActivePath, closeTab }),
675
+ h('div', { className: 'cx-crumb' },
676
+ h('span', { className: 'seg', title: 'node', onClick: (ev) => { ev.stopPropagation(); setCrumbMenu({ ...menuAt(ev.currentTarget), items: (nodes || []).map((n) => ({ label: n.name || `:${n.port}`, hint: ':' + n.port, icon: nodeIconHtml(true), on: n.port === node, action: () => onSelectNode && onSelectNode(n.port) })) }); } }, h('span', { className: 'ci', dangerouslySetInnerHTML: nodeIconHtml(true) }), String(node || '')),
677
+ h('span', { className: 'sep' }, '›'),
678
+ h('span', { className: 'seg', title: 'space', onClick: (ev) => { ev.stopPropagation(); setCrumbMenu({ ...menuAt(ev.currentTarget), items: (spaces || []).map((x) => ({ label: x.name, hint: '', icon: spaceIconHtml(), on: x.name === space, action: () => onSelectSpace && onSelectSpace(x.name) })) }); } }, h('span', { className: 'ci', dangerouslySetInnerHTML: spaceIconHtml() }), space || ''),
679
+ (activePath || '').split('/').filter(Boolean).map((seg, i, arr) => {
680
+ const p = arr.slice(0, i + 1).join('/');
681
+ const isFile = i === arr.length - 1;
682
+ const dir = isFile ? arr.slice(0, -1).join('/') : p;
683
+ return h(React.Fragment, { key: p },
684
+ h('span', { className: 'sep' }, '›'),
685
+ h('span', { className: `seg${isFile ? ' file' : ''}`, title: p, onClick: (ev) => { ev.stopPropagation(); const parent = p.split('/').slice(0, -1).join('/'); dirItems(parent, isFile ? activePath : p)(ev); } },
686
+ h('span', { className: 'ci', dangerouslySetInnerHTML: isFile ? fileIconHtml(seg) : dirIconHtml(true) }), seg));
687
+ }),
688
+ activeTab ? h(React.Fragment, null,
689
+ h('span', { className: 'sep' }, '›'),
690
+ h('span', { className: 'seg', title: 'symbols', onClick: (ev) => { ev.stopPropagation(); const items = symbolsOf(activeTab.content); if (items.length) setCrumbMenu({ ...menuAt(ev.currentTarget), items }); } },
691
+ h('span', { className: 'ci' }, 'ƒ'), (symbolsOf(activeTab.content)[0] || {}).label || (zh ? '函数' : 'fn'))) : null,
692
+ h('div', { className: 'cx-crumb-actions' },
693
+ h('button', { className: 'cx-pm-op cx-pm-txt', onClick: compile, title: zh ? '编译当前项目' : 'Compile' }, zh ? '编译' : 'BUILD'),
694
+ h(IconButton, { name: 'save', className: 'cx-pm-op', onClick: () => save(), title: zh ? '保存' : 'Save' }),
695
+ h(IconButton, { name: 'refresh', className: 'cx-pm-op', onClick: () => { if (activePath) reloadDir(''); }, title: zh ? '重新加载目录' : 'Reload' }),
696
+ h(IconButton, { name: 'copy', className: 'cx-pm-op', onClick: () => { try { navigator.clipboard.writeText([node, space, activePath].filter(Boolean).join(' / ')); } catch {} setStatus(zh ? '路径已复制' : 'path copied'); }, title: zh ? '复制路径' : 'Copy path' }),
697
+ h('span', { className: 'cx-crumb-sep' }),
698
+ h(IconButton, { name: 'panelLeft', className: `cx-pm-op${showLeft ? ' on' : ''}`, onClick: () => setShowLeft((v) => !v), title: zh ? '左侧栏' : 'Left panel' }),
699
+ h(IconButton, { name: 'panelBottom', className: `cx-pm-op${showBottom ? ' on' : ''}`, onClick: () => setShowBottom((v) => !v), title: zh ? '输出栏' : 'Bottom panel' }),
700
+ h(IconButton, { name: 'panelRight', className: `cx-pm-op${showRight ? ' on' : ''}`, onClick: () => setShowRight((v) => !v), title: zh ? '右侧栏' : 'Right panel' }))),
701
+ h('div', { className: 'cx-dev-b cx-editor-wrap' },
702
+ h('div', { className: 'cx-editor-host', ref: editorHostRef, style: editorErr ? { display: 'none' } : null }),
703
+ editorErr ? h('textarea', {
704
+ className: 'cx-editor-ta', ref: taRef, value: activeTab ? activeTab.content : '', spellCheck: false,
705
+ onChange: (e) => { const v = e.target.value; setTabs((ts) => ts.map((t) => (t.path === activePath ? { ...t, content: v, dirty: true } : t))); },
706
+ }) : null,
707
+ editorErr ? h('div', { className: 'cx-dev-status err' }, `${editorErr} · ${zh ? '已回退到基础编辑器' : 'fallback editor'}`) : null)),
708
+ h('section', { className: 'cx-dev-col cx-dev-preview' },
709
+ h('div', { className: 'cx-preview-grip', onMouseDown: startResize, title: zh ? '拖动调整宽度' : 'Drag to resize' }),
710
+ h(IconButton, { name: 'refresh', className: 'cx-pane-refresh', onClick: () => setPreviewNonce((v) => v + 1), title: T('刷新') }),
711
+ h('div', { className: 'cx-dev-b cx-preview-wrap' },
712
+ pvKind === 'md'
713
+ ? h('div', { className: 'cx-preview-md' }, activeTab ? h(RichText, { text: activeTab.content }) : null)
714
+ : pvKind === 'text'
715
+ ? h('pre', { className: 'cx-preview-text' }, activeTab ? activeTab.content : '')
716
+ : pvKind === 'html'
717
+ ? (activeTab ? h('iframe', { key: 'pv' + previewNonce + activePath, className: 'cx-preview-frame', srcDoc: activeTab.content, title: 'preview' }) : null)
718
+ : previewUrl && activeTab
719
+ ? h('iframe', { key: 'pv' + previewNonce, className: 'cx-preview-frame', src: previewUrl, title: 'preview' })
720
+ : h('div', { className: 'cx-empty cx-preview-empty' }, zh ? (activeTab ? '选择节点/项目空间后显示预览' : '打开文件后显示预览') : 'Open a file to preview'))),
721
+ h('section', { className: 'cx-dev-col cx-dev-term' },
722
+ h('div', { className: 'cx-dev-h cx-term-h' },
723
+ h('div', { className: 'cx-term-tabs' },
724
+ h('button', { className: `cx-term-tab${termTab === 'term' ? ' on' : ''}`, onClick: () => setTermTab('term') }, 'TERMINAL'),
725
+ h('button', { className: `cx-term-tab${termTab === 'out' ? ' on' : ''}`, onClick: () => setTermTab('out') }, 'OUTPUT')),
726
+ h('span', { className: 'cx-dev-hint' }, `${node || ''} · ${space || ''}`),
727
+ h('div', { className: 'cx-term-sessions' },
728
+ termIds.map((id) => h('span', { key: id, className: `cx-term-sess${id === termActive && termTab === 'term' ? ' on' : ''}`,
729
+ onClick: () => { setTermActive(id); setTermTab('term'); setTimeout(() => { const x = termMapRef.current[id]; if (!x) return; try { x.fit && x.fit.fit(); } catch {}; try { x.term && x.term.refresh(0, Math.max(0, x.term.rows - 1)); } catch {}; try { x.term && x.term.focus(); } catch {} }, 80); } },
730
+ h('span', { className: 'n' }, String(id)),
731
+ termIds.length > 1 ? h(IconButton, { name: 'close', className: 'x', onClick: (ev) => { ev.stopPropagation(); closeTerm(id); } }) : null)),
732
+ h(IconButton, { name: 'plus', className: 'cx-term-add', onClick: addTerm, title: zh ? '新建终端' : 'New terminal' })),
733
+ h('div', { className: 'cx-term-acts' },
734
+ h('button', { className: 'cx-term-act', onClick: () => termAct('refresh'), title: T('刷新') }, T('刷新')),
735
+ h('button', { className: 'cx-term-act', onClick: () => termAct('clear'), title: T('清屏') }, T('清屏')),
736
+ h('button', { className: 'cx-term-act', onClick: () => termAct('reconnect'), title: T('重连') }, T('重连')),
737
+ h('button', { className: 'cx-term-act', onClick: () => termAct('split'), title: T('分屏') }, T('分屏')))),
738
+ h('div', { className: 'cx-dev-b cx-term-wrap' },
739
+ termTab === 'out'
740
+ ? h('pre', { className: 'cx-term-out' }, termOut || (zh ? '(暂无输出,点上方「编译」生成)' : 'no output'))
741
+ : termIds.map((id) => h('div', { key: id, className: 'cx-term-host', style: id === termActive ? null : { display: 'none' }, ref: (el) => { if (el) termHostsRef.current[id] = el; } })),
742
+ termErr ? h('div', { className: 'cx-dev-status err' }, termErr) : null))),
743
+ h('div', { className: 'cx-statusbar' },
744
+ h('span', { className: 'sb-branch' }, `⎇ ${git && git.branch ? git.branch : '-'}`),
745
+ git && git.ahead ? h('span', null, `↑${git.ahead}`) : null,
746
+ h('span', null, `${git && git.changed ? git.changed : 0} 变更`),
747
+ h('span', { className: 'sep' }),
748
+ h('span', { className: 'sb-path' }, activePath || (zh ? '未打开文件' : 'no file')),
749
+ h('span', null, activeTab && activeTab.dirty ? (zh ? '未保存' : 'unsaved') : (zh ? '已保存' : 'saved')),
750
+ h('span', { className: 'spacer' }),
751
+ h('span', null, `行 ${cursor.line}, 列 ${cursor.column}`),
752
+ h('span', null, langOfPath(activePath)),
753
+ h('span', null, 'UTF-8')),
754
+ reqDetail ? h(Modal, { title: (zh ? '请求详情 / REQUEST' : 'REQUEST'), desc: '', onClose: () => setReqDetail(null) },
755
+ h('div', { className: 'cx-req-detail' },
756
+ h('div', { className: 'kv' },
757
+ [['请求时间', new Date(reqDetail.at).toLocaleString()],
758
+ ['耗时', `${reqDetail.ms} ms`],
759
+ ['报文大小', `↑ ${reqDetail.reqBytes || 0} B · ↓ ${reqDetail.resBytes || 0} B`],
760
+ ['Token', `${reqDetail.tokens || 0}${reqDetail.est ? '(估算)' : ''}`],
761
+ ['金额', (() => { const u = reqDetail.usage || {}; const c = u.cost ?? u.total_cost ?? u.total_price; if (c !== undefined && c !== null) return String(c); const i = Number(u.input_tokens || u.prompt_tokens || 0); const o = Number(u.output_tokens || u.completion_tokens || 0); return (i || o) ? `≈¥${((i * 0.000002) + (o * 0.000008)).toFixed(6)}` : '—'; })()],
762
+ ['模式 / 视图', `${reqDetail.mode || '-'} / ${(reqDetail.context && reqDetail.context.view) || '-'}`]].map(([k, v]) => h('div', { key: k, className: 'row' }, h('span', { className: 'k' }, k), h('span', { className: 'v' }, v)))),
763
+ h('div', { className: 'cx-req-sect' }, zh ? '请求内容' : 'REQUEST'),
764
+ h('pre', { className: 'cx-req-pre' }, String(reqDetail.message || '')),
765
+ h('div', { className: 'cx-req-sect' }, zh ? '请求参数' : 'PARAMS'),
766
+ h('pre', { className: 'cx-req-pre' }, (() => { try { return JSON.stringify(reqDetail.context || {}, null, 2); } catch { return ''; } })()),
767
+ h('div', { className: 'cx-req-sect' }, zh ? '返回内容' : 'RESPONSE'),
768
+ h('pre', { className: 'cx-req-pre' }, String(reqDetail.reply || '') + (reqDetail.action ? `\n\nACTION:\n${JSON.stringify(reqDetail.action, null, 2)}` : '')))) : null,
769
+ nameDlg ? h('div', { className: 'cx-namedlg' },
770
+ h('div', { className: 't' }, nameDlg.title),
771
+ h('input', { className: 'cx-input', value: nameDlg.value, autoFocus: true,
772
+ onChange: (e) => setNameDlg((d) => ({ ...d, value: e.target.value })),
773
+ onKeyDown: (e) => { if (e.key === 'Enter') { const v = nameDlg.value; const r = nameDlg.resolve; setNameDlg(null); r(v); } if (e.key === 'Escape') { const r = nameDlg.resolve; setNameDlg(null); r(''); } } }),
774
+ h('div', { className: 'b' },
775
+ h('button', { className: 'cx-btn', onClick: () => { const r = nameDlg.resolve; setNameDlg(null); r(''); } }, T('取消')),
776
+ h('button', { className: 'cx-btn pri', onClick: () => { const v = nameDlg.value; const r = nameDlg.resolve; setNameDlg(null); r(v); } }, T('保存')))) : null,
777
+ crumbMenu ? h('div', { className: 'cx-tree-menu', style: menuStyle(crumbMenu), onClick: (e) => e.stopPropagation() },
778
+ crumbMenu.items.map((it, i) => h('button', { key: i, className: `cx-menu-item${it.on ? ' on' : ''}`, onClick: () => { setCrumbMenu(null); it.action(); } },
779
+ h('span', { className: 'mi', dangerouslySetInnerHTML: it.icon ? (typeof it.icon === 'string' ? { __html: it.icon } : it.icon) : { __html: '' } }),
780
+ h('span', { className: 'lb' }, it.label), it.hint ? h('span', { className: 'hint' }, it.hint) : null)),
781
+ crumbMenu.items.length > 8 ? h('div', { className: 'cx-menu-more' }, '↓ 更多内容,滚动查看') : null) : null,
782
+ treeMenu ? h('div', { className: 'cx-tree-menu', style: menuStyle(treeMenu), onClick: (e) => e.stopPropagation() },
783
+ h('button', { className: 'cx-menu-item', onClick: () => { const t = treeMenu.entry; setTreeMenu(null); newEntry(t, false); } }, zh ? '新建文件' : 'New file'),
784
+ h('button', { className: 'cx-menu-item', onClick: () => { const t = treeMenu.entry; setTreeMenu(null); newEntry(t, true); } }, zh ? '新建文件夹' : 'New folder'),
785
+ h('button', { className: 'cx-menu-item', onClick: () => { const t = treeMenu.entry; setTreeMenu(null); renameEntry(t); } }, zh ? '重命名' : 'Rename'),
786
+ h('button', { className: 'cx-menu-item danger', onClick: () => { const t = treeMenu.entry; setTreeMenu(null); removeEntry(t); } }, zh ? '删除' : 'Delete')) : null);
787
+ }
788
+
789
+ // 统一下拉:按钮 + 固定定位弹层,所有选择器共用一套样式