@xcanwin/manyoyo 5.9.3 → 5.9.11
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/README.md +1 -0
- package/bin/manyoyo.js +19 -5
- package/lib/web/frontend/app.css +121 -55
- package/lib/web/frontend/app.html +12 -6
- package/lib/web/frontend/app.js +214 -34
- package/lib/web/frontend/codemirror-entry.js +13 -0
- package/lib/web/frontend/codemirror.bundle.js +13 -0
- package/lib/web/frontend/file-browser.js +220 -29
- package/lib/web/server.js +179 -10
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
(function () {
|
|
2
|
+
const FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
|
|
2
3
|
function escapeHtml(value) {
|
|
3
4
|
return String(value == null ? '' : value)
|
|
4
5
|
.replace(/&/g, '&')
|
|
@@ -83,10 +84,14 @@
|
|
|
83
84
|
root.innerHTML = `
|
|
84
85
|
<section class="files-browser">
|
|
85
86
|
<header class="files-toolbar">
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
<div class="files-toolbar-path-group">
|
|
88
|
+
<input type="text" class="files-toolbar-path-input" data-role="path" value="/" spellcheck="false" />
|
|
89
|
+
<button type="button" class="secondary" data-action="visit">访问</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="files-toolbar-meta">
|
|
92
|
+
<div class="files-toolbar-status" data-role="status">未加载</div>
|
|
93
|
+
<button type="button" class="secondary" data-action="mkdir">新建目录</button>
|
|
94
|
+
</div>
|
|
90
95
|
</header>
|
|
91
96
|
<div class="files-layout">
|
|
92
97
|
<aside class="files-sidebar">
|
|
@@ -94,8 +99,13 @@
|
|
|
94
99
|
</aside>
|
|
95
100
|
<section class="files-preview">
|
|
96
101
|
<header class="files-preview-head">
|
|
97
|
-
<div class="files-preview-
|
|
98
|
-
|
|
102
|
+
<div class="files-preview-head-main">
|
|
103
|
+
<div class="files-preview-title" data-role="preview-title">未选择文件</div>
|
|
104
|
+
<div class="files-preview-meta" data-role="preview-meta">请选择左侧文件或目录</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="files-preview-actions">
|
|
107
|
+
<button type="button" class="secondary" data-action="save" disabled>保存</button>
|
|
108
|
+
</div>
|
|
99
109
|
</header>
|
|
100
110
|
<div class="files-preview-body" data-role="preview-body"></div>
|
|
101
111
|
</section>
|
|
@@ -109,8 +119,9 @@
|
|
|
109
119
|
const previewTitleNode = root.querySelector('[data-role="preview-title"]');
|
|
110
120
|
const previewMetaNode = root.querySelector('[data-role="preview-meta"]');
|
|
111
121
|
const previewBodyNode = root.querySelector('[data-role="preview-body"]');
|
|
112
|
-
const
|
|
113
|
-
const
|
|
122
|
+
const visitBtn = root.querySelector('[data-action="visit"]');
|
|
123
|
+
const mkdirBtn = root.querySelector('[data-action="mkdir"]');
|
|
124
|
+
const saveBtn = root.querySelector('[data-action="save"]');
|
|
114
125
|
|
|
115
126
|
const state = {
|
|
116
127
|
visible: false,
|
|
@@ -119,16 +130,21 @@
|
|
|
119
130
|
containerPath: '',
|
|
120
131
|
historyOnly: false,
|
|
121
132
|
currentPath: '',
|
|
133
|
+
pathDraft: '',
|
|
122
134
|
parentPath: '',
|
|
123
135
|
entries: [],
|
|
124
136
|
selectedPath: '',
|
|
125
137
|
selectedFile: null,
|
|
138
|
+
selectedEntry: null,
|
|
126
139
|
loadingList: false,
|
|
127
140
|
loadingFile: false,
|
|
141
|
+
savingFile: false,
|
|
128
142
|
listRequestId: 0,
|
|
129
143
|
readRequestId: 0,
|
|
130
144
|
editor: null,
|
|
131
|
-
editorHost: null
|
|
145
|
+
editorHost: null,
|
|
146
|
+
previewReadOnly: true,
|
|
147
|
+
previewDirty: false
|
|
132
148
|
};
|
|
133
149
|
|
|
134
150
|
function setStatus(text) {
|
|
@@ -145,7 +161,28 @@
|
|
|
145
161
|
state.editorHost = null;
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
function isEditablePreview() {
|
|
165
|
+
return Boolean(
|
|
166
|
+
state.selectedFile
|
|
167
|
+
&& state.selectedFile.kind === 'text'
|
|
168
|
+
&& state.previewReadOnly === false
|
|
169
|
+
&& state.historyOnly !== true
|
|
170
|
+
&& state.savingFile !== true
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function syncSaveButton() {
|
|
175
|
+
if (!saveBtn) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
saveBtn.disabled = !isEditablePreview();
|
|
179
|
+
saveBtn.textContent = state.savingFile ? '保存中...' : '保存';
|
|
180
|
+
}
|
|
181
|
+
|
|
148
182
|
function renderPreviewEmpty(title, description) {
|
|
183
|
+
state.selectedFile = null;
|
|
184
|
+
state.previewReadOnly = true;
|
|
185
|
+
state.previewDirty = false;
|
|
149
186
|
if (previewTitleNode) {
|
|
150
187
|
previewTitleNode.textContent = title;
|
|
151
188
|
}
|
|
@@ -156,6 +193,7 @@
|
|
|
156
193
|
previewBodyNode.innerHTML = `<div class="files-empty">${escapeHtml(description)}</div>`;
|
|
157
194
|
}
|
|
158
195
|
destroyEditor();
|
|
196
|
+
syncSaveButton();
|
|
159
197
|
}
|
|
160
198
|
|
|
161
199
|
function ensureEditorHost() {
|
|
@@ -172,6 +210,8 @@
|
|
|
172
210
|
|
|
173
211
|
function renderPreviewPayload(payload) {
|
|
174
212
|
state.selectedFile = payload || null;
|
|
213
|
+
state.previewReadOnly = !(payload && payload.editable === true);
|
|
214
|
+
state.previewDirty = false;
|
|
175
215
|
if (!payload) {
|
|
176
216
|
renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
|
|
177
217
|
return;
|
|
@@ -180,9 +220,13 @@
|
|
|
180
220
|
previewTitleNode.textContent = payload.path || '未命名文件';
|
|
181
221
|
}
|
|
182
222
|
if (previewMetaNode) {
|
|
183
|
-
|
|
223
|
+
const modeLabel = payload.kind === 'text'
|
|
224
|
+
? (payload.editable === true ? '可编辑' : '只读预览')
|
|
225
|
+
: '只读预览';
|
|
226
|
+
previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
|
|
184
227
|
}
|
|
185
228
|
if (!previewBodyNode) {
|
|
229
|
+
syncSaveButton();
|
|
186
230
|
return;
|
|
187
231
|
}
|
|
188
232
|
|
|
@@ -196,35 +240,42 @@
|
|
|
196
240
|
state.editor = window.ManyoyoCodeEditor.create(host, {
|
|
197
241
|
doc: String(payload.content || ''),
|
|
198
242
|
language,
|
|
199
|
-
readOnly:
|
|
243
|
+
readOnly: state.previewReadOnly,
|
|
244
|
+
onChange: function () {
|
|
245
|
+
state.previewDirty = true;
|
|
246
|
+
syncSaveButton();
|
|
247
|
+
}
|
|
200
248
|
});
|
|
201
249
|
}
|
|
202
250
|
} else {
|
|
203
251
|
state.editor.setValue(String(payload.content || ''));
|
|
204
252
|
state.editor.setLanguage(language);
|
|
205
|
-
state.editor.setReadOnly(
|
|
253
|
+
state.editor.setReadOnly(state.previewReadOnly);
|
|
206
254
|
}
|
|
255
|
+
syncSaveButton();
|
|
207
256
|
return;
|
|
208
257
|
}
|
|
209
258
|
|
|
210
259
|
destroyEditor();
|
|
211
260
|
previewBodyNode.innerHTML = `<pre class="files-pre">${escapeHtml(String(payload.content || ''))}</pre>`;
|
|
261
|
+
syncSaveButton();
|
|
212
262
|
return;
|
|
213
263
|
}
|
|
214
264
|
|
|
215
265
|
destroyEditor();
|
|
216
266
|
previewBodyNode.innerHTML = `<div class="files-note">当前文件暂不支持在线预览。文件类型:${escapeHtml(payload.kind || 'unknown')}</div>`;
|
|
267
|
+
syncSaveButton();
|
|
217
268
|
}
|
|
218
269
|
|
|
219
270
|
function renderList() {
|
|
220
271
|
if (pathNode) {
|
|
221
|
-
pathNode.
|
|
272
|
+
pathNode.value = state.pathDraft || state.currentPath || state.containerPath || '/';
|
|
222
273
|
}
|
|
223
|
-
if (
|
|
224
|
-
|
|
274
|
+
if (visitBtn) {
|
|
275
|
+
visitBtn.disabled = state.loadingList || state.loadingFile || !(state.pathDraft || '').trim();
|
|
225
276
|
}
|
|
226
|
-
if (
|
|
227
|
-
|
|
277
|
+
if (mkdirBtn) {
|
|
278
|
+
mkdirBtn.disabled = state.loadingList || state.loadingFile || !state.sessionName || state.historyOnly === true;
|
|
228
279
|
}
|
|
229
280
|
if (!listNode) {
|
|
230
281
|
return;
|
|
@@ -248,8 +299,25 @@
|
|
|
248
299
|
setStatus('读取目录中');
|
|
249
300
|
return;
|
|
250
301
|
}
|
|
302
|
+
|
|
303
|
+
if (state.parentPath) {
|
|
304
|
+
const parentButton = document.createElement('button');
|
|
305
|
+
parentButton.type = 'button';
|
|
306
|
+
parentButton.className = 'files-entry files-entry-parent';
|
|
307
|
+
parentButton.title = state.parentPath;
|
|
308
|
+
parentButton.addEventListener('click', function () {
|
|
309
|
+
loadDirectory(state.parentPath);
|
|
310
|
+
});
|
|
311
|
+
parentButton.innerHTML = `
|
|
312
|
+
<span class="files-entry-name">
|
|
313
|
+
<span class="files-entry-title">..</span>
|
|
314
|
+
</span>
|
|
315
|
+
<span class="files-entry-meta">上一级</span>
|
|
316
|
+
`;
|
|
317
|
+
listNode.appendChild(parentButton);
|
|
318
|
+
}
|
|
319
|
+
|
|
251
320
|
if (!state.entries.length) {
|
|
252
|
-
listNode.innerHTML = '<div class="files-empty">当前目录为空。</div>';
|
|
253
321
|
setStatus('目录为空');
|
|
254
322
|
return;
|
|
255
323
|
}
|
|
@@ -264,7 +332,7 @@
|
|
|
264
332
|
loadDirectory(entry.path);
|
|
265
333
|
return;
|
|
266
334
|
}
|
|
267
|
-
loadFile(entry.path);
|
|
335
|
+
loadFile(entry.path, entry);
|
|
268
336
|
});
|
|
269
337
|
button.innerHTML = `
|
|
270
338
|
<span class="files-entry-name">
|
|
@@ -282,7 +350,9 @@
|
|
|
282
350
|
const requestId = state.listRequestId + 1;
|
|
283
351
|
state.listRequestId = requestId;
|
|
284
352
|
state.loadingList = true;
|
|
353
|
+
state.pathDraft = pathText;
|
|
285
354
|
state.selectedPath = '';
|
|
355
|
+
state.selectedEntry = null;
|
|
286
356
|
renderList();
|
|
287
357
|
try {
|
|
288
358
|
const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/list?path=' + encodeURIComponent(pathText));
|
|
@@ -290,9 +360,10 @@
|
|
|
290
360
|
return;
|
|
291
361
|
}
|
|
292
362
|
state.currentPath = payload && payload.path ? payload.path : pathText;
|
|
363
|
+
state.pathDraft = state.currentPath;
|
|
293
364
|
state.parentPath = payload && payload.parentPath ? payload.parentPath : '';
|
|
294
365
|
state.entries = Array.isArray(payload && payload.entries) ? payload.entries : [];
|
|
295
|
-
renderPreviewEmpty(
|
|
366
|
+
renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
|
|
296
367
|
} catch (e) {
|
|
297
368
|
if (requestId !== state.listRequestId) {
|
|
298
369
|
return;
|
|
@@ -307,22 +378,39 @@
|
|
|
307
378
|
}
|
|
308
379
|
}
|
|
309
380
|
|
|
310
|
-
async function loadFile(targetPath) {
|
|
381
|
+
async function loadFile(targetPath, entry) {
|
|
311
382
|
const pathText = String(targetPath || '').trim();
|
|
312
383
|
if (!pathText) {
|
|
313
384
|
return;
|
|
314
385
|
}
|
|
386
|
+
const selectedEntry = entry && typeof entry === 'object' ? entry : null;
|
|
387
|
+
const fileSize = Number(selectedEntry && selectedEntry.size);
|
|
388
|
+
const requiresReadonlyConfirm = Number.isFinite(fileSize) && fileSize >= FILE_EDIT_MAX_BYTES;
|
|
389
|
+
if (requiresReadonlyConfirm) {
|
|
390
|
+
const yes = window.confirm(`文件较大(${formatBytes(fileSize)}),继续后将以只读方式全量预览,无法保存。是否继续?`);
|
|
391
|
+
if (!yes) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
315
395
|
const requestId = state.readRequestId + 1;
|
|
316
396
|
state.readRequestId = requestId;
|
|
317
397
|
state.loadingFile = true;
|
|
318
398
|
state.selectedPath = pathText;
|
|
399
|
+
state.selectedEntry = selectedEntry;
|
|
319
400
|
renderList();
|
|
320
401
|
renderPreviewEmpty(pathText, '正在读取文件内容...');
|
|
321
402
|
try {
|
|
322
|
-
const payload = await api(
|
|
403
|
+
const payload = await api(
|
|
404
|
+
'/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/read?path='
|
|
405
|
+
+ encodeURIComponent(pathText)
|
|
406
|
+
+ '&full=1'
|
|
407
|
+
);
|
|
323
408
|
if (requestId !== state.readRequestId) {
|
|
324
409
|
return;
|
|
325
410
|
}
|
|
411
|
+
if (requiresReadonlyConfirm && payload && payload.kind === 'text') {
|
|
412
|
+
payload.editable = false;
|
|
413
|
+
}
|
|
326
414
|
renderPreviewPayload(payload);
|
|
327
415
|
} catch (e) {
|
|
328
416
|
if (requestId !== state.readRequestId) {
|
|
@@ -339,6 +427,83 @@
|
|
|
339
427
|
}
|
|
340
428
|
}
|
|
341
429
|
|
|
430
|
+
async function saveCurrentFile() {
|
|
431
|
+
if (!isEditablePreview() || !state.editor || typeof state.editor.getValue !== 'function' || !state.selectedFile || !state.selectedFile.path) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
state.savingFile = true;
|
|
435
|
+
syncSaveButton();
|
|
436
|
+
setStatus('保存中');
|
|
437
|
+
try {
|
|
438
|
+
const nextContent = state.editor.getValue();
|
|
439
|
+
const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/write', {
|
|
440
|
+
method: 'PUT',
|
|
441
|
+
body: JSON.stringify({
|
|
442
|
+
path: state.selectedFile.path,
|
|
443
|
+
content: nextContent
|
|
444
|
+
})
|
|
445
|
+
});
|
|
446
|
+
state.previewDirty = false;
|
|
447
|
+
if (state.selectedFile) {
|
|
448
|
+
state.selectedFile.content = nextContent;
|
|
449
|
+
state.selectedFile.size = payload && typeof payload.size === 'number'
|
|
450
|
+
? payload.size
|
|
451
|
+
: new TextEncoder().encode(nextContent).length;
|
|
452
|
+
}
|
|
453
|
+
const matchedEntry = state.entries.find(function (item) {
|
|
454
|
+
return item && state.selectedFile && item.path === state.selectedFile.path;
|
|
455
|
+
});
|
|
456
|
+
if (matchedEntry && state.selectedFile) {
|
|
457
|
+
matchedEntry.size = state.selectedFile.size;
|
|
458
|
+
}
|
|
459
|
+
renderPreviewPayload(state.selectedFile);
|
|
460
|
+
renderList();
|
|
461
|
+
setStatus('已保存');
|
|
462
|
+
} catch (e) {
|
|
463
|
+
setStatus('保存失败');
|
|
464
|
+
onError(e && e.message ? e.message : '保存文件失败');
|
|
465
|
+
} finally {
|
|
466
|
+
state.savingFile = false;
|
|
467
|
+
syncSaveButton();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function joinDirectoryPath(basePath, childName) {
|
|
472
|
+
const base = String(basePath || '/').trim() || '/';
|
|
473
|
+
const child = String(childName || '').trim();
|
|
474
|
+
if (!child) {
|
|
475
|
+
return base;
|
|
476
|
+
}
|
|
477
|
+
if (base === '/') {
|
|
478
|
+
return '/' + child.replace(/^\/+/, '');
|
|
479
|
+
}
|
|
480
|
+
return base.replace(/\/+$/, '') + '/' + child.replace(/^\/+/, '');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function createDirectory() {
|
|
484
|
+
if (!state.sessionName || state.historyOnly === true || state.loadingList || state.loadingFile) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const input = window.prompt('请输入新目录名称');
|
|
488
|
+
const name = String(input || '').trim();
|
|
489
|
+
if (!name) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const targetPath = joinDirectoryPath(state.currentPath || state.containerPath || '/', name);
|
|
493
|
+
setStatus('创建目录中');
|
|
494
|
+
try {
|
|
495
|
+
await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/mkdir', {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
body: JSON.stringify({ path: targetPath })
|
|
498
|
+
});
|
|
499
|
+
await loadDirectory(state.currentPath || state.containerPath || '/');
|
|
500
|
+
setStatus('已创建目录');
|
|
501
|
+
} catch (e) {
|
|
502
|
+
setStatus('创建目录失败');
|
|
503
|
+
onError(e && e.message ? e.message : '创建目录失败');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
342
507
|
function sync(context) {
|
|
343
508
|
const session = context && context.session;
|
|
344
509
|
const detail = context && context.detail;
|
|
@@ -362,12 +527,17 @@
|
|
|
362
527
|
state.containerName = nextContainerName;
|
|
363
528
|
state.containerPath = nextContainerPath;
|
|
364
529
|
state.currentPath = '';
|
|
530
|
+
state.pathDraft = nextContainerPath;
|
|
365
531
|
state.parentPath = '';
|
|
366
532
|
state.entries = [];
|
|
367
533
|
state.selectedPath = '';
|
|
534
|
+
state.selectedEntry = null;
|
|
368
535
|
renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
|
|
369
536
|
} else if (containerPathChanged) {
|
|
370
537
|
state.containerPath = nextContainerPath;
|
|
538
|
+
if (!state.currentPath) {
|
|
539
|
+
state.pathDraft = nextContainerPath;
|
|
540
|
+
}
|
|
371
541
|
}
|
|
372
542
|
|
|
373
543
|
renderList();
|
|
@@ -379,17 +549,38 @@
|
|
|
379
549
|
}
|
|
380
550
|
}
|
|
381
551
|
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
552
|
+
if (pathNode) {
|
|
553
|
+
pathNode.addEventListener('input', function () {
|
|
554
|
+
state.pathDraft = pathNode.value;
|
|
555
|
+
renderList();
|
|
556
|
+
});
|
|
557
|
+
pathNode.addEventListener('keydown', function (event) {
|
|
558
|
+
if (event.key === 'Enter') {
|
|
559
|
+
event.preventDefault();
|
|
560
|
+
loadDirectory(pathNode.value);
|
|
386
561
|
}
|
|
387
562
|
});
|
|
388
563
|
}
|
|
389
564
|
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
loadDirectory(state.currentPath || state.containerPath || '/');
|
|
565
|
+
if (visitBtn) {
|
|
566
|
+
visitBtn.addEventListener('click', function () {
|
|
567
|
+
loadDirectory(state.pathDraft || state.currentPath || state.containerPath || '/');
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (mkdirBtn) {
|
|
572
|
+
mkdirBtn.addEventListener('click', function () {
|
|
573
|
+
createDirectory().catch(function (e) {
|
|
574
|
+
onError(e && e.message ? e.message : '创建目录失败');
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (saveBtn) {
|
|
580
|
+
saveBtn.addEventListener('click', function () {
|
|
581
|
+
saveCurrentFile().catch(function (e) {
|
|
582
|
+
onError(e && e.message ? e.message : '保存文件失败');
|
|
583
|
+
});
|
|
393
584
|
});
|
|
394
585
|
}
|
|
395
586
|
|