@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.
- package/lib/web/frontend/app.css +15 -1
- package/lib/web/frontend/file-browser.js +109 -13
- package/lib/web/server.js +77 -1
- package/package.json +1 -1
package/lib/web/frontend/app.css
CHANGED
|
@@ -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:
|
|
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 =
|
|
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$/),
|