@xcanwin/manyoyo 6.2.15 → 6.2.19

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.
@@ -1082,7 +1082,7 @@ body.workspace-switcher-open .workspace-switcher-panel {
1082
1082
  display: flex;
1083
1083
  align-items: center;
1084
1084
  gap: 8px;
1085
- flex-wrap: wrap;
1085
+ flex-wrap: nowrap;
1086
1086
  padding: 12px 14px;
1087
1087
  border-bottom: 1px solid var(--line);
1088
1088
  background: rgba(255, 251, 244, 0.96);
@@ -2175,6 +2175,20 @@ body.composer-options-open .composer-options-panel {
2175
2175
  border-bottom: 1px solid var(--line);
2176
2176
  }
2177
2177
 
2178
+ .files-toolbar {
2179
+ flex-wrap: wrap;
2180
+ }
2181
+
2182
+ .files-toolbar-path-group {
2183
+ flex-basis: 100%;
2184
+ }
2185
+
2186
+ .files-toolbar-meta {
2187
+ flex-basis: 100%;
2188
+ flex-wrap: wrap;
2189
+ margin-left: 0;
2190
+ }
2191
+
2178
2192
  .files-browser[data-mobile-pane="list"] .files-preview {
2179
2193
  display: none;
2180
2194
  }
@@ -9,6 +9,18 @@
9
9
  .replace(/'/g, ''');
10
10
  }
11
11
 
12
+ // 双向控制符(RTLO 等)/ 零宽字符可以让文件名视觉上和真实字节完全不一致
13
+ // (例如 "cod" + U+202E + "exe.txt" 会被浏览器渲染成 "codtxt.exe"),
14
+ // 展示前统一替换成可见的 \uXXXX 记法,破坏其排版效果并让人一眼看出异常。
15
+ // 范围:U+200B-U+200F(零宽字符/LRM/RLM)、U+202A-U+202E(嵌入/覆盖方向控制符)、
16
+ // U+2060-U+2069(零宽连接符/方向隔离符)、U+FEFF(BOM/零宽不换行空格)、U+061C(阿拉伯字母标记)。
17
+ const SUSPICIOUS_UNICODE_PATTERN = /[\u200B-\u200F\u202A-\u202E\u2060-\u2069\uFEFF\u061C]/g;
18
+ function sanitizeDisplayText(value) {
19
+ return String(value == null ? '' : value).replace(SUSPICIOUS_UNICODE_PATTERN, function (ch) {
20
+ return '\\u' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
21
+ });
22
+ }
23
+
12
24
  function formatBytes(size) {
13
25
  const value = Number(size || 0);
14
26
  if (!Number.isFinite(value) || value <= 0) {
@@ -46,6 +58,8 @@
46
58
  const parts = [];
47
59
  if (entry && entry.kind === 'directory') {
48
60
  parts.push('目录');
61
+ } else if (entry && entry.kind === 'symlink') {
62
+ parts.push('符号链接');
49
63
  } else {
50
64
  parts.push(formatBytes(entry && entry.size));
51
65
  }
@@ -55,6 +69,16 @@
55
69
  return parts.join(' · ');
56
70
  }
57
71
 
72
+ function buildEntryTitle(entry) {
73
+ const basePath = sanitizeDisplayText(entry && (entry.path || entry.name) || '');
74
+ if (entry && entry.kind === 'symlink') {
75
+ return entry.symlinkTarget
76
+ ? `${basePath} → ${sanitizeDisplayText(entry.symlinkTarget)}`
77
+ : `${basePath}(符号链接目标无法解析,可能已损坏)`;
78
+ }
79
+ return basePath;
80
+ }
81
+
58
82
  function inferLanguageFromPath(filePath) {
59
83
  const text = String(filePath || '').toLowerCase();
60
84
  if (text.endsWith('.md') || text.endsWith('.markdown')) return 'markdown';
@@ -99,6 +123,7 @@
99
123
  <div class="files-toolbar-meta">
100
124
  <div class="files-toolbar-status" data-role="status">未加载</div>
101
125
  <button type="button" class="secondary" data-action="mkdir">新建目录</button>
126
+ <button type="button" class="secondary" data-action="new-file">新建文件</button>
102
127
  </div>
103
128
  </header>
104
129
  <div class="files-layout">
@@ -132,6 +157,7 @@
132
157
  const filesBrowserNode = root.querySelector('.files-browser');
133
158
  const visitBtn = root.querySelector('[data-action="visit"]');
134
159
  const mkdirBtn = root.querySelector('[data-action="mkdir"]');
160
+ const newFileBtn = root.querySelector('[data-action="new-file"]');
135
161
  const saveBtn = root.querySelector('[data-action="save"]');
136
162
  const toggleMarkdownViewBtn = root.querySelector('[data-action="toggle-markdown-view"]');
137
163
  const backToListBtn = root.querySelector('[data-action="back-to-list"]');
@@ -235,7 +261,7 @@
235
261
  state.previewReadOnly = true;
236
262
  state.previewDirty = false;
237
263
  if (previewTitleNode) {
238
- previewTitleNode.textContent = title;
264
+ previewTitleNode.textContent = sanitizeDisplayText(title);
239
265
  }
240
266
  if (previewMetaNode) {
241
267
  previewMetaNode.textContent = description;
@@ -260,6 +286,17 @@
260
286
  return host;
261
287
  }
262
288
 
289
+ function updatePreviewMeta() {
290
+ const payload = state.selectedFile;
291
+ if (!previewMetaNode || !payload) {
292
+ return;
293
+ }
294
+ const modeLabel = payload.kind === 'text'
295
+ ? (payload.editable === true ? '可编辑' : '只读预览')
296
+ : '只读预览';
297
+ previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
298
+ }
299
+
263
300
  function renderPreviewPayload(payload) {
264
301
  state.selectedFile = payload || null;
265
302
  state.previewReadOnly = !(payload && payload.editable === true);
@@ -269,14 +306,9 @@
269
306
  return;
270
307
  }
271
308
  if (previewTitleNode) {
272
- previewTitleNode.textContent = payload.path || '未命名文件';
273
- }
274
- if (previewMetaNode) {
275
- const modeLabel = payload.kind === 'text'
276
- ? (payload.editable === true ? '可编辑' : '只读预览')
277
- : '只读预览';
278
- previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
309
+ previewTitleNode.textContent = sanitizeDisplayText(payload.path) || '未命名文件';
279
310
  }
311
+ updatePreviewMeta();
280
312
  if (!previewBodyNode) {
281
313
  syncMarkdownToggleButton();
282
314
  syncSaveButton();
@@ -315,8 +347,12 @@
315
347
  doc: String(payload.content || ''),
316
348
  language,
317
349
  readOnly: state.previewReadOnly,
318
- onChange: function () {
350
+ onChange: function (nextValue) {
319
351
  state.previewDirty = true;
352
+ if (state.selectedFile) {
353
+ state.selectedFile.size = new TextEncoder().encode(nextValue).length;
354
+ }
355
+ updatePreviewMeta();
320
356
  syncSaveButton();
321
357
  }
322
358
  });
@@ -354,6 +390,23 @@
354
390
  syncSaveButton();
355
391
  }
356
392
 
393
+ function openSymlinkEntry(entry) {
394
+ const safeName = sanitizeDisplayText(entry.name);
395
+ const message = entry.symlinkTarget
396
+ ? `"${safeName}" 是符号链接,实际指向:\n${sanitizeDisplayText(entry.symlinkTarget)}\n\n是否继续访问?`
397
+ : `"${safeName}" 是一个无法解析的符号链接(可能已损坏),是否仍要尝试访问?`;
398
+ confirmFn(message).then(function (proceed) {
399
+ if (!proceed) {
400
+ return;
401
+ }
402
+ if (entry.symlinkTargetKind === 'directory') {
403
+ loadDirectory(entry.path);
404
+ return;
405
+ }
406
+ loadFile(entry.path, entry);
407
+ });
408
+ }
409
+
357
410
  function renderList() {
358
411
  if (pathNode) {
359
412
  pathNode.value = state.pathDraft || state.currentPath || state.containerPath || '/';
@@ -364,6 +417,9 @@
364
417
  if (mkdirBtn) {
365
418
  mkdirBtn.disabled = state.loadingList || state.loadingFile || !state.sessionName || state.historyOnly === true;
366
419
  }
420
+ if (newFileBtn) {
421
+ newFileBtn.disabled = state.loadingList || state.loadingFile || !state.sessionName || state.historyOnly === true;
422
+ }
367
423
  if (!listNode) {
368
424
  return;
369
425
  }
@@ -393,7 +449,7 @@
393
449
  const parentButton = document.createElement('button');
394
450
  parentButton.type = 'button';
395
451
  parentButton.className = 'files-entry files-entry-parent';
396
- parentButton.title = state.parentPath;
452
+ parentButton.title = sanitizeDisplayText(state.parentPath);
397
453
  parentButton.addEventListener('click', function () {
398
454
  loadDirectory(state.parentPath);
399
455
  });
@@ -415,8 +471,12 @@
415
471
  const button = document.createElement('button');
416
472
  button.type = 'button';
417
473
  button.className = 'files-entry' + (state.selectedPath === entry.path ? ' is-active' : '');
418
- button.title = String(entry.path || entry.name || '');
474
+ button.title = buildEntryTitle(entry);
419
475
  button.addEventListener('click', function () {
476
+ if (entry.kind === 'symlink') {
477
+ openSymlinkEntry(entry);
478
+ return;
479
+ }
420
480
  if (entry.kind === 'directory') {
421
481
  loadDirectory(entry.path);
422
482
  return;
@@ -425,7 +485,7 @@
425
485
  });
426
486
  button.innerHTML = `
427
487
  <span class="files-entry-name">
428
- <span class="files-entry-title">${escapeHtml(entry.name || entry.path || '未命名')}</span>
488
+ <span class="files-entry-title">${escapeHtml(sanitizeDisplayText(entry.name || entry.path || '未命名'))}</span>
429
489
  </span>
430
490
  <span class="files-entry-meta">${escapeHtml(buildEntryMeta(entry))}</span>
431
491
  `;
@@ -596,6 +656,30 @@
596
656
  }
597
657
  }
598
658
 
659
+ async function createFile() {
660
+ if (!state.sessionName || state.historyOnly === true || state.loadingList || state.loadingFile) {
661
+ return;
662
+ }
663
+ const input = await promptFn('请输入新文件名称');
664
+ const name = String(input || '').trim();
665
+ if (!name) {
666
+ return;
667
+ }
668
+ const targetPath = joinDirectoryPath(state.currentPath || state.containerPath || '/', name);
669
+ setStatus('创建文件中');
670
+ try {
671
+ await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/create', {
672
+ method: 'POST',
673
+ body: JSON.stringify({ path: targetPath })
674
+ });
675
+ await loadDirectory(state.currentPath || state.containerPath || '/');
676
+ setStatus('已创建文件');
677
+ } catch (e) {
678
+ setStatus('创建文件失败');
679
+ onError(e && e.message ? e.message : '创建文件失败');
680
+ }
681
+ }
682
+
599
683
  function sync(context) {
600
684
  const session = context && context.session;
601
685
  const detail = context && context.detail;
@@ -669,6 +753,14 @@
669
753
  });
670
754
  }
671
755
 
756
+ if (newFileBtn) {
757
+ newFileBtn.addEventListener('click', function () {
758
+ createFile().catch(function (e) {
759
+ onError(e && e.message ? e.message : '创建文件失败');
760
+ });
761
+ });
762
+ }
763
+
672
764
  if (saveBtn) {
673
765
  saveBtn.addEventListener('click', function () {
674
766
  saveCurrentFile().catch(function (e) {
@@ -679,6 +771,9 @@
679
771
 
680
772
  if (toggleMarkdownViewBtn) {
681
773
  toggleMarkdownViewBtn.addEventListener('click', function () {
774
+ if (state.editor && typeof state.editor.getValue === 'function' && state.selectedFile) {
775
+ state.selectedFile.content = state.editor.getValue();
776
+ }
682
777
  state.markdownViewMode = state.markdownViewMode === 'source' ? 'rendered' : 'source';
683
778
  renderPreviewPayload(state.selectedFile);
684
779
  });
@@ -699,6 +794,7 @@
699
794
  }
700
795
 
701
796
  window.ManyoyoFileBrowser = {
702
- create
797
+ create,
798
+ sanitizeDisplayText
703
799
  };
704
800
  }());
package/lib/web/server.js CHANGED
@@ -3315,19 +3315,32 @@ try {
3315
3315
  itemStat = null;
3316
3316
  }
3317
3317
  let kind = 'other';
3318
+ let symlinkTarget = null;
3319
+ let symlinkTargetKind = null;
3318
3320
  if (entry.isDirectory()) {
3319
3321
  kind = 'directory';
3320
3322
  } else if (entry.isFile()) {
3321
3323
  kind = 'file';
3322
3324
  } else if (entry.isSymbolicLink()) {
3323
3325
  kind = 'symlink';
3326
+ try {
3327
+ const resolvedPath = fs.realpathSync(fullPath);
3328
+ const resolvedStat = fs.statSync(resolvedPath);
3329
+ symlinkTarget = resolvedPath;
3330
+ symlinkTargetKind = resolvedStat.isDirectory() ? 'directory' : 'file';
3331
+ } catch (e) {
3332
+ symlinkTarget = null;
3333
+ symlinkTargetKind = null;
3334
+ }
3324
3335
  }
3325
3336
  return {
3326
3337
  name: entry.name,
3327
3338
  path: fullPath,
3328
3339
  kind,
3329
3340
  size: itemStat && typeof itemStat.size === 'number' ? itemStat.size : 0,
3330
- mtimeMs: itemStat && typeof itemStat.mtimeMs === 'number' ? Math.floor(itemStat.mtimeMs) : 0
3341
+ mtimeMs: itemStat && typeof itemStat.mtimeMs === 'number' ? Math.floor(itemStat.mtimeMs) : 0,
3342
+ symlinkTarget,
3343
+ symlinkTargetKind
3331
3344
  };
3332
3345
  })
3333
3346
  .sort((a, b) => {
@@ -3520,6 +3533,41 @@ try {
3520
3533
  `);
3521
3534
  }
3522
3535
 
3536
+ function buildContainerFileCreateCommand(requestedPath) {
3537
+ return buildWebContainerNodeCommand(`
3538
+ // __MANYOYO_FS_CREATE__
3539
+ const fs = require('fs');
3540
+ const path = require('path');
3541
+
3542
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3543
+
3544
+ try {
3545
+ const resolvedPath = path.resolve(requestedPath);
3546
+ const parentPath = path.dirname(resolvedPath);
3547
+ const realParentPath = fs.realpathSync(parentPath);
3548
+ const targetPath = path.join(realParentPath, path.basename(resolvedPath));
3549
+ if (fs.existsSync(targetPath)) {
3550
+ throw new Error('文件已存在: ' + targetPath);
3551
+ }
3552
+
3553
+ fs.writeFileSync(targetPath, '', 'utf8');
3554
+ const stat = fs.statSync(targetPath);
3555
+ process.stdout.write(JSON.stringify({
3556
+ path: targetPath,
3557
+ name: path.basename(targetPath),
3558
+ kind: 'file',
3559
+ size: stat.size,
3560
+ mtimeMs: stat.mtimeMs,
3561
+ created: true
3562
+ }));
3563
+ } catch (e) {
3564
+ process.stdout.write(JSON.stringify({
3565
+ error: e && e.message ? e.message : '创建文件失败'
3566
+ }));
3567
+ }
3568
+ `);
3569
+ }
3570
+
3523
3571
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
3524
3572
  const opts = options && typeof options === 'object' ? options : {};
3525
3573
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -4800,6 +4848,34 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4800
4848
  sendJson(res, 200, result);
4801
4849
  }
4802
4850
  },
4851
+ {
4852
+ method: 'POST',
4853
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/create$/),
4854
+ handler: async match => {
4855
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4856
+ if (!sessionRef) {
4857
+ return;
4858
+ }
4859
+ const payload = await readJsonBody(req);
4860
+ const targetPath = String(payload && payload.path ? payload.path : '').trim();
4861
+ if (!targetPath) {
4862
+ sendJson(res, 400, { error: 'path 不能为空' });
4863
+ return;
4864
+ }
4865
+
4866
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
4867
+ const result = await execJsonCommandInWebContainer(
4868
+ ctx,
4869
+ sessionRef.containerName,
4870
+ buildContainerFileCreateCommand(targetPath)
4871
+ );
4872
+ if (result && result.error) {
4873
+ sendJson(res, 400, { error: result.error });
4874
+ return;
4875
+ }
4876
+ sendJson(res, 200, result);
4877
+ }
4878
+ },
4803
4879
  {
4804
4880
  method: 'GET',
4805
4881
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/detail$/),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "6.2.15",
3
+ "version": "6.2.19",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",