@xcanwin/manyoyo 5.8.11 → 5.9.0

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.
@@ -0,0 +1,406 @@
1
+ (function () {
2
+ function escapeHtml(value) {
3
+ return String(value == null ? '' : value)
4
+ .replace(/&/g, '&')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
10
+
11
+ function formatBytes(size) {
12
+ const value = Number(size || 0);
13
+ if (!Number.isFinite(value) || value <= 0) {
14
+ return '0 B';
15
+ }
16
+ if (value < 1024) {
17
+ return `${value} B`;
18
+ }
19
+ if (value < 1024 * 1024) {
20
+ return `${(value / 1024).toFixed(1)} KB`;
21
+ }
22
+ if (value < 1024 * 1024 * 1024) {
23
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
24
+ }
25
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
26
+ }
27
+
28
+ function formatDateTime(value) {
29
+ if (!value) {
30
+ return '未知时间';
31
+ }
32
+ const date = new Date(value);
33
+ if (Number.isNaN(date.getTime())) {
34
+ return '未知时间';
35
+ }
36
+ return date.toLocaleString('zh-CN', {
37
+ month: '2-digit',
38
+ day: '2-digit',
39
+ hour: '2-digit',
40
+ minute: '2-digit'
41
+ });
42
+ }
43
+
44
+ function buildEntryMeta(entry) {
45
+ const parts = [];
46
+ if (entry && entry.kind === 'directory') {
47
+ parts.push('目录');
48
+ } else {
49
+ parts.push(formatBytes(entry && entry.size));
50
+ }
51
+ if (entry && entry.mtimeMs) {
52
+ parts.push(formatDateTime(entry.mtimeMs));
53
+ }
54
+ return parts.join(' · ');
55
+ }
56
+
57
+ function inferLanguageFromPath(filePath) {
58
+ const text = String(filePath || '').toLowerCase();
59
+ if (text.endsWith('.md') || text.endsWith('.markdown')) return 'markdown';
60
+ if (text.endsWith('.json')) return 'json';
61
+ if (text.endsWith('.py')) return 'python';
62
+ if (text.endsWith('.yaml') || text.endsWith('.yml')) return 'yaml';
63
+ if (text.endsWith('.html') || text.endsWith('.htm')) return 'html';
64
+ if (text.endsWith('.css')) return 'css';
65
+ if (text.endsWith('.js') || text.endsWith('.jsx') || text.endsWith('.mjs') || text.endsWith('.cjs') || text.endsWith('.ts') || text.endsWith('.tsx')) {
66
+ return 'javascript';
67
+ }
68
+ return 'text';
69
+ }
70
+
71
+ function create(options) {
72
+ const root = options && options.root;
73
+ const api = options && options.api;
74
+ const onError = options && typeof options.onError === 'function'
75
+ ? options.onError
76
+ : function (message) { window.alert(message); };
77
+ if (!root || typeof api !== 'function') {
78
+ return {
79
+ sync: function () {}
80
+ };
81
+ }
82
+
83
+ root.innerHTML = `
84
+ <section class="files-browser">
85
+ <header class="files-toolbar">
86
+ <button type="button" class="secondary" data-action="up">上一级</button>
87
+ <button type="button" class="secondary" data-action="refresh">刷新</button>
88
+ <div class="files-toolbar-path" data-role="path">/</div>
89
+ <div class="files-toolbar-status" data-role="status">未加载</div>
90
+ </header>
91
+ <div class="files-layout">
92
+ <aside class="files-sidebar">
93
+ <div class="files-list" data-role="list"></div>
94
+ </aside>
95
+ <section class="files-preview">
96
+ <header class="files-preview-head">
97
+ <div class="files-preview-title" data-role="preview-title">未选择文件</div>
98
+ <div class="files-preview-meta" data-role="preview-meta">请选择左侧文件或目录</div>
99
+ </header>
100
+ <div class="files-preview-body" data-role="preview-body"></div>
101
+ </section>
102
+ </div>
103
+ </section>
104
+ `;
105
+
106
+ const pathNode = root.querySelector('[data-role="path"]');
107
+ const statusNode = root.querySelector('[data-role="status"]');
108
+ const listNode = root.querySelector('[data-role="list"]');
109
+ const previewTitleNode = root.querySelector('[data-role="preview-title"]');
110
+ const previewMetaNode = root.querySelector('[data-role="preview-meta"]');
111
+ const previewBodyNode = root.querySelector('[data-role="preview-body"]');
112
+ const upBtn = root.querySelector('[data-action="up"]');
113
+ const refreshBtn = root.querySelector('[data-action="refresh"]');
114
+
115
+ const state = {
116
+ visible: false,
117
+ sessionName: '',
118
+ containerName: '',
119
+ containerPath: '',
120
+ historyOnly: false,
121
+ currentPath: '',
122
+ parentPath: '',
123
+ entries: [],
124
+ selectedPath: '',
125
+ selectedFile: null,
126
+ loadingList: false,
127
+ loadingFile: false,
128
+ listRequestId: 0,
129
+ readRequestId: 0,
130
+ editor: null,
131
+ editorHost: null
132
+ };
133
+
134
+ function setStatus(text) {
135
+ if (statusNode) {
136
+ statusNode.textContent = String(text || '').trim() || '就绪';
137
+ }
138
+ }
139
+
140
+ function destroyEditor() {
141
+ if (state.editor && typeof state.editor.destroy === 'function') {
142
+ state.editor.destroy();
143
+ }
144
+ state.editor = null;
145
+ state.editorHost = null;
146
+ }
147
+
148
+ function renderPreviewEmpty(title, description) {
149
+ if (previewTitleNode) {
150
+ previewTitleNode.textContent = title;
151
+ }
152
+ if (previewMetaNode) {
153
+ previewMetaNode.textContent = description;
154
+ }
155
+ if (previewBodyNode) {
156
+ previewBodyNode.innerHTML = `<div class="files-empty">${escapeHtml(description)}</div>`;
157
+ }
158
+ destroyEditor();
159
+ }
160
+
161
+ function ensureEditorHost() {
162
+ if (!previewBodyNode) {
163
+ return null;
164
+ }
165
+ previewBodyNode.innerHTML = '';
166
+ const host = document.createElement('div');
167
+ host.className = 'files-editor-host';
168
+ previewBodyNode.appendChild(host);
169
+ state.editorHost = host;
170
+ return host;
171
+ }
172
+
173
+ function renderPreviewPayload(payload) {
174
+ state.selectedFile = payload || null;
175
+ if (!payload) {
176
+ renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
177
+ return;
178
+ }
179
+ if (previewTitleNode) {
180
+ previewTitleNode.textContent = payload.path || '未命名文件';
181
+ }
182
+ if (previewMetaNode) {
183
+ previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)}${payload.truncated ? ' · 已截断预览' : ''}`;
184
+ }
185
+ if (!previewBodyNode) {
186
+ return;
187
+ }
188
+
189
+ if (payload.kind === 'text') {
190
+ const language = payload.language || inferLanguageFromPath(payload.path);
191
+ if (window.ManyoyoCodeEditor && typeof window.ManyoyoCodeEditor.create === 'function') {
192
+ if (!state.editor || !state.editorHost || !previewBodyNode.contains(state.editorHost)) {
193
+ destroyEditor();
194
+ const host = ensureEditorHost();
195
+ if (host) {
196
+ state.editor = window.ManyoyoCodeEditor.create(host, {
197
+ doc: String(payload.content || ''),
198
+ language,
199
+ readOnly: true
200
+ });
201
+ }
202
+ } else {
203
+ state.editor.setValue(String(payload.content || ''));
204
+ state.editor.setLanguage(language);
205
+ state.editor.setReadOnly(true);
206
+ }
207
+ return;
208
+ }
209
+
210
+ destroyEditor();
211
+ previewBodyNode.innerHTML = `<pre class="files-pre">${escapeHtml(String(payload.content || ''))}</pre>`;
212
+ return;
213
+ }
214
+
215
+ destroyEditor();
216
+ previewBodyNode.innerHTML = `<div class="files-note">当前文件暂不支持在线预览。文件类型:${escapeHtml(payload.kind || 'unknown')}</div>`;
217
+ }
218
+
219
+ function renderList() {
220
+ if (pathNode) {
221
+ pathNode.textContent = state.currentPath || state.containerPath || '/';
222
+ }
223
+ if (upBtn) {
224
+ upBtn.disabled = state.loadingList || !state.parentPath;
225
+ }
226
+ if (refreshBtn) {
227
+ refreshBtn.disabled = state.loadingList || state.loadingFile;
228
+ }
229
+ if (!listNode) {
230
+ return;
231
+ }
232
+ listNode.innerHTML = '';
233
+
234
+ if (!state.sessionName) {
235
+ listNode.innerHTML = '<div class="files-empty">请选择左侧会话后再浏览容器文件。</div>';
236
+ renderPreviewEmpty('未选择会话', '请选择左侧会话后再浏览容器文件。');
237
+ setStatus('未选择会话');
238
+ return;
239
+ }
240
+ if (state.historyOnly) {
241
+ listNode.innerHTML = '<div class="files-empty">当前会话只有历史记录,没有可访问的运行中容器。</div>';
242
+ renderPreviewEmpty('容器不可用', '当前会话只有历史记录,没有可访问的运行中容器。');
243
+ setStatus('容器不可用');
244
+ return;
245
+ }
246
+ if (state.loadingList) {
247
+ listNode.innerHTML = '<div class="files-empty">正在读取目录...</div>';
248
+ setStatus('读取目录中');
249
+ return;
250
+ }
251
+ if (!state.entries.length) {
252
+ listNode.innerHTML = '<div class="files-empty">当前目录为空。</div>';
253
+ setStatus('目录为空');
254
+ return;
255
+ }
256
+
257
+ state.entries.forEach(function (entry) {
258
+ const button = document.createElement('button');
259
+ button.type = 'button';
260
+ button.className = 'files-entry' + (state.selectedPath === entry.path ? ' is-active' : '');
261
+ button.title = String(entry.path || entry.name || '');
262
+ button.addEventListener('click', function () {
263
+ if (entry.kind === 'directory') {
264
+ loadDirectory(entry.path);
265
+ return;
266
+ }
267
+ loadFile(entry.path);
268
+ });
269
+ button.innerHTML = `
270
+ <span class="files-entry-name">
271
+ <span class="files-entry-title">${escapeHtml(entry.name || entry.path || '未命名')}</span>
272
+ </span>
273
+ <span class="files-entry-meta">${escapeHtml(buildEntryMeta(entry))}</span>
274
+ `;
275
+ listNode.appendChild(button);
276
+ });
277
+ setStatus(state.loadingFile ? '读取文件中' : `共 ${state.entries.length} 项`);
278
+ }
279
+
280
+ async function loadDirectory(targetPath) {
281
+ const pathText = String(targetPath || state.currentPath || state.containerPath || '/').trim() || '/';
282
+ const requestId = state.listRequestId + 1;
283
+ state.listRequestId = requestId;
284
+ state.loadingList = true;
285
+ state.selectedPath = '';
286
+ renderList();
287
+ try {
288
+ const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/list?path=' + encodeURIComponent(pathText));
289
+ if (requestId !== state.listRequestId) {
290
+ return;
291
+ }
292
+ state.currentPath = payload && payload.path ? payload.path : pathText;
293
+ state.parentPath = payload && payload.parentPath ? payload.parentPath : '';
294
+ state.entries = Array.isArray(payload && payload.entries) ? payload.entries : [];
295
+ renderPreviewEmpty(state.currentPath, '请选择左侧文件进行预览。');
296
+ } catch (e) {
297
+ if (requestId !== state.listRequestId) {
298
+ return;
299
+ }
300
+ onError(e && e.message ? e.message : '读取目录失败');
301
+ } finally {
302
+ if (requestId !== state.listRequestId) {
303
+ return;
304
+ }
305
+ state.loadingList = false;
306
+ renderList();
307
+ }
308
+ }
309
+
310
+ async function loadFile(targetPath) {
311
+ const pathText = String(targetPath || '').trim();
312
+ if (!pathText) {
313
+ return;
314
+ }
315
+ const requestId = state.readRequestId + 1;
316
+ state.readRequestId = requestId;
317
+ state.loadingFile = true;
318
+ state.selectedPath = pathText;
319
+ renderList();
320
+ renderPreviewEmpty(pathText, '正在读取文件内容...');
321
+ try {
322
+ const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/read?path=' + encodeURIComponent(pathText));
323
+ if (requestId !== state.readRequestId) {
324
+ return;
325
+ }
326
+ renderPreviewPayload(payload);
327
+ } catch (e) {
328
+ if (requestId !== state.readRequestId) {
329
+ return;
330
+ }
331
+ renderPreviewEmpty(pathText, e && e.message ? e.message : '读取文件失败');
332
+ onError(e && e.message ? e.message : '读取文件失败');
333
+ } finally {
334
+ if (requestId !== state.readRequestId) {
335
+ return;
336
+ }
337
+ state.loadingFile = false;
338
+ renderList();
339
+ }
340
+ }
341
+
342
+ function sync(context) {
343
+ const session = context && context.session;
344
+ const detail = context && context.detail;
345
+ const nextSessionName = String(session && session.name ? session.name : '').trim();
346
+ const nextContainerName = String(session && session.containerName ? session.containerName : '').trim();
347
+ const nextContainerPath = String(
348
+ (detail && detail.containerPath)
349
+ || (session && session.containerPath)
350
+ || '/'
351
+ ).trim() || '/';
352
+ const nextVisible = Boolean(context && context.visible);
353
+ const nextHistoryOnly = context && context.historyOnly === true;
354
+ const sessionChanged = nextSessionName !== state.sessionName;
355
+ const containerPathChanged = nextContainerPath !== state.containerPath;
356
+
357
+ state.visible = nextVisible;
358
+ state.historyOnly = nextHistoryOnly;
359
+
360
+ if (sessionChanged) {
361
+ state.sessionName = nextSessionName;
362
+ state.containerName = nextContainerName;
363
+ state.containerPath = nextContainerPath;
364
+ state.currentPath = '';
365
+ state.parentPath = '';
366
+ state.entries = [];
367
+ state.selectedPath = '';
368
+ renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
369
+ } else if (containerPathChanged) {
370
+ state.containerPath = nextContainerPath;
371
+ }
372
+
373
+ renderList();
374
+ if (!nextVisible || !state.sessionName || state.historyOnly) {
375
+ return;
376
+ }
377
+ if (sessionChanged || containerPathChanged || !state.currentPath) {
378
+ loadDirectory(state.containerPath || '/');
379
+ }
380
+ }
381
+
382
+ if (upBtn) {
383
+ upBtn.addEventListener('click', function () {
384
+ if (state.parentPath) {
385
+ loadDirectory(state.parentPath);
386
+ }
387
+ });
388
+ }
389
+
390
+ if (refreshBtn) {
391
+ refreshBtn.addEventListener('click', function () {
392
+ loadDirectory(state.currentPath || state.containerPath || '/');
393
+ });
394
+ }
395
+
396
+ renderList();
397
+
398
+ return {
399
+ sync
400
+ };
401
+ }
402
+
403
+ window.ManyoyoFileBrowser = {
404
+ create
405
+ };
406
+ }());
package/lib/web/server.js CHANGED
@@ -34,6 +34,7 @@ const WEB_TERMINAL_MIN_ROWS = 12;
34
34
  const WEB_AGENT_CONTEXT_MAX_MESSAGES = 24;
35
35
  const WEB_AGENT_CONTEXT_MAX_CHARS = 6000;
36
36
  const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
37
+ const WEB_FILE_PREVIEW_MAX_BYTES = 256 * 1024;
37
38
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
38
39
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
39
40
  const WEB_SESSION_KEY_SEPARATOR = '~';
@@ -109,6 +110,24 @@ const MIME_TYPES = {
109
110
  '.js': 'application/javascript; charset=utf-8',
110
111
  '.html': 'text/html; charset=utf-8'
111
112
  };
113
+ const FILE_LANGUAGE_MAP = {
114
+ '.cjs': 'javascript',
115
+ '.css': 'css',
116
+ '.htm': 'html',
117
+ '.html': 'html',
118
+ '.java': 'javascript',
119
+ '.js': 'javascript',
120
+ '.json': 'json',
121
+ '.jsx': 'javascript',
122
+ '.md': 'markdown',
123
+ '.markdown': 'markdown',
124
+ '.mjs': 'javascript',
125
+ '.py': 'python',
126
+ '.ts': 'javascript',
127
+ '.tsx': 'javascript',
128
+ '.yaml': 'yaml',
129
+ '.yml': 'yaml'
130
+ };
112
131
 
113
132
  function formatUrlHost(host) {
114
133
  if (typeof host !== 'string' || !host) return '127.0.0.1';
@@ -2537,6 +2556,156 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
2537
2556
  });
2538
2557
  }
2539
2558
 
2559
+ function buildWebContainerNodeCommand(scriptSource) {
2560
+ return `node <<'__MANYOYO_NODE__'
2561
+ ${scriptSource}
2562
+ __MANYOYO_NODE__`;
2563
+ }
2564
+
2565
+ function inferFileLanguage(filePath) {
2566
+ const ext = path.extname(String(filePath || '')).toLowerCase();
2567
+ return FILE_LANGUAGE_MAP[ext] || 'text';
2568
+ }
2569
+
2570
+ async function execJsonCommandInWebContainer(ctx, containerName, command) {
2571
+ const result = await execCommandInWebContainer(ctx, containerName, command);
2572
+ if (result.exitCode !== 0) {
2573
+ throw new Error(result.output || '容器命令执行失败');
2574
+ }
2575
+ try {
2576
+ return JSON.parse(String(result.output || '{}'));
2577
+ } catch (e) {
2578
+ throw new Error('容器返回了无法解析的 JSON');
2579
+ }
2580
+ }
2581
+
2582
+ function buildContainerFileListCommand(requestedPath) {
2583
+ return buildWebContainerNodeCommand(`
2584
+ // __MANYOYO_FS_LIST__
2585
+ const fs = require('fs');
2586
+ const path = require('path');
2587
+
2588
+ const requestedPath = ${JSON.stringify(String(requestedPath || '/'))};
2589
+
2590
+ try {
2591
+ const realPath = fs.realpathSync(requestedPath);
2592
+ const stat = fs.statSync(realPath);
2593
+ if (!stat.isDirectory()) {
2594
+ throw new Error('目标不是目录: ' + realPath);
2595
+ }
2596
+
2597
+ const root = path.parse(realPath).root;
2598
+ const parentPath = realPath === root ? '' : path.dirname(realPath);
2599
+ const entries = fs.readdirSync(realPath, { withFileTypes: true })
2600
+ .map(entry => {
2601
+ const fullPath = path.join(realPath, entry.name);
2602
+ let itemStat = null;
2603
+ try {
2604
+ itemStat = fs.lstatSync(fullPath);
2605
+ } catch (e) {
2606
+ itemStat = null;
2607
+ }
2608
+ let kind = 'other';
2609
+ if (entry.isDirectory()) {
2610
+ kind = 'directory';
2611
+ } else if (entry.isFile()) {
2612
+ kind = 'file';
2613
+ } else if (entry.isSymbolicLink()) {
2614
+ kind = 'symlink';
2615
+ }
2616
+ return {
2617
+ name: entry.name,
2618
+ path: fullPath,
2619
+ kind,
2620
+ size: itemStat && typeof itemStat.size === 'number' ? itemStat.size : 0,
2621
+ mtimeMs: itemStat && typeof itemStat.mtimeMs === 'number' ? Math.floor(itemStat.mtimeMs) : 0
2622
+ };
2623
+ })
2624
+ .sort((a, b) => {
2625
+ if (a.kind !== b.kind) {
2626
+ if (a.kind === 'directory') return -1;
2627
+ if (b.kind === 'directory') return 1;
2628
+ }
2629
+ return a.name.localeCompare(b.name, 'zh-CN');
2630
+ });
2631
+
2632
+ process.stdout.write(JSON.stringify({
2633
+ path: realPath,
2634
+ parentPath,
2635
+ entries
2636
+ }));
2637
+ } catch (e) {
2638
+ process.stdout.write(JSON.stringify({
2639
+ error: e && e.message ? e.message : '读取目录失败'
2640
+ }));
2641
+ }
2642
+ `);
2643
+ }
2644
+
2645
+ function buildContainerFileReadCommand(requestedPath) {
2646
+ return buildWebContainerNodeCommand(`
2647
+ // __MANYOYO_FS_READ__
2648
+ const fs = require('fs');
2649
+
2650
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
2651
+ const maxBytes = ${String(WEB_FILE_PREVIEW_MAX_BYTES)};
2652
+
2653
+ function looksBinary(buffer) {
2654
+ const length = Math.min(buffer.length, 4096);
2655
+ let suspicious = 0;
2656
+ for (let i = 0; i < length; i += 1) {
2657
+ const byte = buffer[i];
2658
+ if (byte === 0) {
2659
+ return true;
2660
+ }
2661
+ if (byte < 7 || (byte > 13 && byte < 32)) {
2662
+ suspicious += 1;
2663
+ }
2664
+ }
2665
+ return length > 0 && (suspicious / length) > 0.12;
2666
+ }
2667
+
2668
+ try {
2669
+ const realPath = fs.realpathSync(requestedPath);
2670
+ const stat = fs.statSync(realPath);
2671
+ if (!stat.isFile()) {
2672
+ throw new Error('目标不是文件: ' + realPath);
2673
+ }
2674
+
2675
+ const size = stat.size;
2676
+ const readBytes = Math.min(size, maxBytes);
2677
+ const buffer = Buffer.alloc(readBytes);
2678
+ const fd = fs.openSync(realPath, 'r');
2679
+ try {
2680
+ fs.readSync(fd, buffer, 0, readBytes, 0);
2681
+ } finally {
2682
+ fs.closeSync(fd);
2683
+ }
2684
+
2685
+ if (looksBinary(buffer)) {
2686
+ process.stdout.write(JSON.stringify({
2687
+ path: realPath,
2688
+ kind: 'binary',
2689
+ size,
2690
+ truncated: size > maxBytes
2691
+ }));
2692
+ } else {
2693
+ process.stdout.write(JSON.stringify({
2694
+ path: realPath,
2695
+ kind: 'text',
2696
+ size,
2697
+ truncated: size > maxBytes,
2698
+ content: buffer.toString('utf8')
2699
+ }));
2700
+ }
2701
+ } catch (e) {
2702
+ process.stdout.write(JSON.stringify({
2703
+ error: e && e.message ? e.message : '读取文件失败'
2704
+ }));
2705
+ }
2706
+ `);
2707
+ }
2708
+
2540
2709
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
2541
2710
  const opts = options && typeof options === 'object' ? options : {};
2542
2711
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -3435,6 +3604,61 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3435
3604
  });
3436
3605
  }
3437
3606
  },
3607
+ {
3608
+ method: 'GET',
3609
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/list$/),
3610
+ handler: async match => {
3611
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
3612
+ if (!sessionRef) {
3613
+ return;
3614
+ }
3615
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/list', 'http://localhost');
3616
+ const targetPath = String(requestUrl.searchParams.get('path') || '/').trim() || '/';
3617
+
3618
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3619
+ const payload = await execJsonCommandInWebContainer(
3620
+ ctx,
3621
+ sessionRef.containerName,
3622
+ buildContainerFileListCommand(targetPath)
3623
+ );
3624
+ if (payload && payload.error) {
3625
+ sendJson(res, 400, { error: payload.error });
3626
+ return;
3627
+ }
3628
+ sendJson(res, 200, payload);
3629
+ }
3630
+ },
3631
+ {
3632
+ method: 'GET',
3633
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/read$/),
3634
+ handler: async match => {
3635
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
3636
+ if (!sessionRef) {
3637
+ return;
3638
+ }
3639
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/read', 'http://localhost');
3640
+ const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
3641
+ if (!targetPath) {
3642
+ sendJson(res, 400, { error: 'path 不能为空' });
3643
+ return;
3644
+ }
3645
+
3646
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3647
+ const payload = await execJsonCommandInWebContainer(
3648
+ ctx,
3649
+ sessionRef.containerName,
3650
+ buildContainerFileReadCommand(targetPath)
3651
+ );
3652
+ if (payload && payload.error) {
3653
+ sendJson(res, 400, { error: payload.error });
3654
+ return;
3655
+ }
3656
+ if (payload && payload.kind === 'text') {
3657
+ payload.language = inferFileLanguage(payload.path);
3658
+ }
3659
+ sendJson(res, 200, payload);
3660
+ }
3661
+ },
3438
3662
  {
3439
3663
  method: 'GET',
3440
3664
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/detail$/),
@@ -3872,7 +4096,12 @@ async function startWebServer(options) {
3872
4096
  const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
3873
4097
  if (req.method === 'GET' && appFrontendMatch) {
3874
4098
  const assetName = appFrontendMatch[1];
3875
- if (!(assetName === 'app.css' || assetName === 'app.js' || assetName === 'markdown.css' || assetName === 'markdown-renderer.js')) {
4099
+ if (!(assetName === 'app.css'
4100
+ || assetName === 'app.js'
4101
+ || assetName === 'markdown.css'
4102
+ || assetName === 'markdown-renderer.js'
4103
+ || assetName === 'file-browser.js'
4104
+ || assetName === 'codemirror.bundle.js')) {
3876
4105
  sendHtml(res, 404, '<h1>404 Not Found</h1>');
3877
4106
  return;
3878
4107
  }