@xcanwin/manyoyo 6.2.14 → 6.2.17

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);
@@ -1218,7 +1218,7 @@ body.workspace-switcher-open .workspace-switcher-panel {
1218
1218
  align-items: flex-start;
1219
1219
  justify-content: space-between;
1220
1220
  gap: 12px;
1221
- flex-wrap: wrap;
1221
+ flex-wrap: nowrap;
1222
1222
  padding: 10px 12px;
1223
1223
  border-bottom: 1px solid var(--line);
1224
1224
  background: rgba(255, 251, 244, 0.96);
@@ -1240,6 +1240,11 @@ body.workspace-switcher-open .workspace-switcher-panel {
1240
1240
  margin-left: auto;
1241
1241
  }
1242
1242
 
1243
+ .files-back-btn {
1244
+ display: none;
1245
+ flex-shrink: 0;
1246
+ }
1247
+
1243
1248
  .files-preview-title {
1244
1249
  font-family: var(--font-display);
1245
1250
  font-size: 14px;
@@ -2162,7 +2167,7 @@ body.composer-options-open .composer-options-panel {
2162
2167
 
2163
2168
  .files-layout {
2164
2169
  grid-template-columns: minmax(0, 1fr);
2165
- grid-template-rows: minmax(180px, 38%) minmax(0, 1fr);
2170
+ grid-template-rows: minmax(0, 1fr);
2166
2171
  }
2167
2172
 
2168
2173
  .files-sidebar {
@@ -2170,6 +2175,55 @@ body.composer-options-open .composer-options-panel {
2170
2175
  border-bottom: 1px solid var(--line);
2171
2176
  }
2172
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
+
2192
+ .files-browser[data-mobile-pane="list"] .files-preview {
2193
+ display: none;
2194
+ }
2195
+
2196
+ .files-browser[data-mobile-pane="preview"] .files-sidebar {
2197
+ display: none;
2198
+ }
2199
+
2200
+ .files-browser[data-mobile-pane="preview"] .files-toolbar {
2201
+ display: none;
2202
+ }
2203
+
2204
+ .files-browser[data-mobile-pane="preview"] .files-back-btn {
2205
+ display: inline-flex;
2206
+ order: 1;
2207
+ }
2208
+
2209
+ .files-preview-head {
2210
+ flex-wrap: wrap;
2211
+ align-items: center;
2212
+ }
2213
+
2214
+ .files-preview-actions {
2215
+ order: 2;
2216
+ }
2217
+
2218
+ .files-preview-head-main {
2219
+ order: 3;
2220
+ flex-basis: 100%;
2221
+ }
2222
+
2223
+ .files-preview-meta {
2224
+ display: none;
2225
+ }
2226
+
2173
2227
  .composer-inner {
2174
2228
  align-items: stretch;
2175
2229
  }
@@ -99,6 +99,7 @@
99
99
  <div class="files-toolbar-meta">
100
100
  <div class="files-toolbar-status" data-role="status">未加载</div>
101
101
  <button type="button" class="secondary" data-action="mkdir">新建目录</button>
102
+ <button type="button" class="secondary" data-action="new-file">新建文件</button>
102
103
  </div>
103
104
  </header>
104
105
  <div class="files-layout">
@@ -107,6 +108,7 @@
107
108
  </aside>
108
109
  <section class="files-preview">
109
110
  <header class="files-preview-head">
111
+ <button type="button" class="secondary files-back-btn" data-action="back-to-list">← 返回列表</button>
110
112
  <div class="files-preview-head-main">
111
113
  <div class="files-preview-title" data-role="preview-title">未选择文件</div>
112
114
  <div class="files-preview-meta" data-role="preview-meta">请选择左侧文件或目录</div>
@@ -128,10 +130,13 @@
128
130
  const previewTitleNode = root.querySelector('[data-role="preview-title"]');
129
131
  const previewMetaNode = root.querySelector('[data-role="preview-meta"]');
130
132
  const previewBodyNode = root.querySelector('[data-role="preview-body"]');
133
+ const filesBrowserNode = root.querySelector('.files-browser');
131
134
  const visitBtn = root.querySelector('[data-action="visit"]');
132
135
  const mkdirBtn = root.querySelector('[data-action="mkdir"]');
136
+ const newFileBtn = root.querySelector('[data-action="new-file"]');
133
137
  const saveBtn = root.querySelector('[data-action="save"]');
134
138
  const toggleMarkdownViewBtn = root.querySelector('[data-action="toggle-markdown-view"]');
139
+ const backToListBtn = root.querySelector('[data-action="back-to-list"]');
135
140
 
136
141
  const state = {
137
142
  visible: false,
@@ -155,9 +160,17 @@
155
160
  editorHost: null,
156
161
  previewReadOnly: true,
157
162
  previewDirty: false,
158
- markdownViewMode: 'rendered'
163
+ markdownViewMode: 'rendered',
164
+ mobileActivePane: 'list'
159
165
  };
160
166
 
167
+ function setMobilePane(pane) {
168
+ state.mobileActivePane = pane === 'preview' ? 'preview' : 'list';
169
+ if (filesBrowserNode) {
170
+ filesBrowserNode.setAttribute('data-mobile-pane', state.mobileActivePane);
171
+ }
172
+ }
173
+
161
174
  function setStatus(text) {
162
175
  if (statusNode) {
163
176
  statusNode.textContent = String(text || '').trim() || '就绪';
@@ -249,6 +262,17 @@
249
262
  return host;
250
263
  }
251
264
 
265
+ function updatePreviewMeta() {
266
+ const payload = state.selectedFile;
267
+ if (!previewMetaNode || !payload) {
268
+ return;
269
+ }
270
+ const modeLabel = payload.kind === 'text'
271
+ ? (payload.editable === true ? '可编辑' : '只读预览')
272
+ : '只读预览';
273
+ previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
274
+ }
275
+
252
276
  function renderPreviewPayload(payload) {
253
277
  state.selectedFile = payload || null;
254
278
  state.previewReadOnly = !(payload && payload.editable === true);
@@ -260,12 +284,7 @@
260
284
  if (previewTitleNode) {
261
285
  previewTitleNode.textContent = payload.path || '未命名文件';
262
286
  }
263
- if (previewMetaNode) {
264
- const modeLabel = payload.kind === 'text'
265
- ? (payload.editable === true ? '可编辑' : '只读预览')
266
- : '只读预览';
267
- previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
268
- }
287
+ updatePreviewMeta();
269
288
  if (!previewBodyNode) {
270
289
  syncMarkdownToggleButton();
271
290
  syncSaveButton();
@@ -304,8 +323,12 @@
304
323
  doc: String(payload.content || ''),
305
324
  language,
306
325
  readOnly: state.previewReadOnly,
307
- onChange: function () {
326
+ onChange: function (nextValue) {
308
327
  state.previewDirty = true;
328
+ if (state.selectedFile) {
329
+ state.selectedFile.size = new TextEncoder().encode(nextValue).length;
330
+ }
331
+ updatePreviewMeta();
309
332
  syncSaveButton();
310
333
  }
311
334
  });
@@ -353,18 +376,23 @@
353
376
  if (mkdirBtn) {
354
377
  mkdirBtn.disabled = state.loadingList || state.loadingFile || !state.sessionName || state.historyOnly === true;
355
378
  }
379
+ if (newFileBtn) {
380
+ newFileBtn.disabled = state.loadingList || state.loadingFile || !state.sessionName || state.historyOnly === true;
381
+ }
356
382
  if (!listNode) {
357
383
  return;
358
384
  }
359
385
  listNode.innerHTML = '';
360
386
 
361
387
  if (!state.sessionName) {
388
+ setMobilePane('list');
362
389
  listNode.innerHTML = '<div class="files-empty">请选择左侧会话后再浏览容器文件。</div>';
363
390
  renderPreviewEmpty('未选择会话', '请选择左侧会话后再浏览容器文件。');
364
391
  setStatus('未选择会话');
365
392
  return;
366
393
  }
367
394
  if (state.historyOnly) {
395
+ setMobilePane('list');
368
396
  listNode.innerHTML = '<div class="files-empty">当前会话只有历史记录,没有可访问的运行中容器。</div>';
369
397
  renderPreviewEmpty('容器不可用', '当前会话只有历史记录,没有可访问的运行中容器。');
370
398
  setStatus('容器不可用');
@@ -429,6 +457,7 @@
429
457
  state.pathDraft = pathText;
430
458
  state.selectedPath = '';
431
459
  state.selectedEntry = null;
460
+ setMobilePane('list');
432
461
  renderList();
433
462
  try {
434
463
  const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/list?path=' + encodeURIComponent(pathText));
@@ -474,6 +503,7 @@
474
503
  state.selectedPath = pathText;
475
504
  state.selectedEntry = selectedEntry;
476
505
  state.markdownViewMode = 'rendered';
506
+ setMobilePane('preview');
477
507
  renderList();
478
508
  renderPreviewEmpty(pathText, '正在读取文件内容...');
479
509
  try {
@@ -581,6 +611,30 @@
581
611
  }
582
612
  }
583
613
 
614
+ async function createFile() {
615
+ if (!state.sessionName || state.historyOnly === true || state.loadingList || state.loadingFile) {
616
+ return;
617
+ }
618
+ const input = await promptFn('请输入新文件名称');
619
+ const name = String(input || '').trim();
620
+ if (!name) {
621
+ return;
622
+ }
623
+ const targetPath = joinDirectoryPath(state.currentPath || state.containerPath || '/', name);
624
+ setStatus('创建文件中');
625
+ try {
626
+ await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/create', {
627
+ method: 'POST',
628
+ body: JSON.stringify({ path: targetPath })
629
+ });
630
+ await loadDirectory(state.currentPath || state.containerPath || '/');
631
+ setStatus('已创建文件');
632
+ } catch (e) {
633
+ setStatus('创建文件失败');
634
+ onError(e && e.message ? e.message : '创建文件失败');
635
+ }
636
+ }
637
+
584
638
  function sync(context) {
585
639
  const session = context && context.session;
586
640
  const detail = context && context.detail;
@@ -609,6 +663,7 @@
609
663
  state.entries = [];
610
664
  state.selectedPath = '';
611
665
  state.selectedEntry = null;
666
+ setMobilePane('list');
612
667
  renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
613
668
  } else if (containerPathChanged) {
614
669
  state.containerPath = nextContainerPath;
@@ -653,6 +708,14 @@
653
708
  });
654
709
  }
655
710
 
711
+ if (newFileBtn) {
712
+ newFileBtn.addEventListener('click', function () {
713
+ createFile().catch(function (e) {
714
+ onError(e && e.message ? e.message : '创建文件失败');
715
+ });
716
+ });
717
+ }
718
+
656
719
  if (saveBtn) {
657
720
  saveBtn.addEventListener('click', function () {
658
721
  saveCurrentFile().catch(function (e) {
@@ -663,11 +726,21 @@
663
726
 
664
727
  if (toggleMarkdownViewBtn) {
665
728
  toggleMarkdownViewBtn.addEventListener('click', function () {
729
+ if (state.editor && typeof state.editor.getValue === 'function' && state.selectedFile) {
730
+ state.selectedFile.content = state.editor.getValue();
731
+ }
666
732
  state.markdownViewMode = state.markdownViewMode === 'source' ? 'rendered' : 'source';
667
733
  renderPreviewPayload(state.selectedFile);
668
734
  });
669
735
  }
670
736
 
737
+ if (backToListBtn) {
738
+ backToListBtn.addEventListener('click', function () {
739
+ setMobilePane('list');
740
+ });
741
+ }
742
+
743
+ setMobilePane('list');
671
744
  renderList();
672
745
 
673
746
  return {
package/lib/web/server.js CHANGED
@@ -3520,6 +3520,41 @@ try {
3520
3520
  `);
3521
3521
  }
3522
3522
 
3523
+ function buildContainerFileCreateCommand(requestedPath) {
3524
+ return buildWebContainerNodeCommand(`
3525
+ // __MANYOYO_FS_CREATE__
3526
+ const fs = require('fs');
3527
+ const path = require('path');
3528
+
3529
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3530
+
3531
+ try {
3532
+ const resolvedPath = path.resolve(requestedPath);
3533
+ const parentPath = path.dirname(resolvedPath);
3534
+ const realParentPath = fs.realpathSync(parentPath);
3535
+ const targetPath = path.join(realParentPath, path.basename(resolvedPath));
3536
+ if (fs.existsSync(targetPath)) {
3537
+ throw new Error('文件已存在: ' + targetPath);
3538
+ }
3539
+
3540
+ fs.writeFileSync(targetPath, '', 'utf8');
3541
+ const stat = fs.statSync(targetPath);
3542
+ process.stdout.write(JSON.stringify({
3543
+ path: targetPath,
3544
+ name: path.basename(targetPath),
3545
+ kind: 'file',
3546
+ size: stat.size,
3547
+ mtimeMs: stat.mtimeMs,
3548
+ created: true
3549
+ }));
3550
+ } catch (e) {
3551
+ process.stdout.write(JSON.stringify({
3552
+ error: e && e.message ? e.message : '创建文件失败'
3553
+ }));
3554
+ }
3555
+ `);
3556
+ }
3557
+
3523
3558
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
3524
3559
  const opts = options && typeof options === 'object' ? options : {};
3525
3560
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -4800,6 +4835,34 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4800
4835
  sendJson(res, 200, result);
4801
4836
  }
4802
4837
  },
4838
+ {
4839
+ method: 'POST',
4840
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/create$/),
4841
+ handler: async match => {
4842
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4843
+ if (!sessionRef) {
4844
+ return;
4845
+ }
4846
+ const payload = await readJsonBody(req);
4847
+ const targetPath = String(payload && payload.path ? payload.path : '').trim();
4848
+ if (!targetPath) {
4849
+ sendJson(res, 400, { error: 'path 不能为空' });
4850
+ return;
4851
+ }
4852
+
4853
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
4854
+ const result = await execJsonCommandInWebContainer(
4855
+ ctx,
4856
+ sessionRef.containerName,
4857
+ buildContainerFileCreateCommand(targetPath)
4858
+ );
4859
+ if (result && result.error) {
4860
+ sendJson(res, 400, { error: result.error });
4861
+ return;
4862
+ }
4863
+ sendJson(res, 200, result);
4864
+ }
4865
+ },
4803
4866
  {
4804
4867
  method: 'GET',
4805
4868
  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.14",
3
+ "version": "6.2.17",
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",