@yiln-dsh/dsh-plugin-file-explorer 0.6.0 → 0.8.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.
- package/README.md +29 -7
- package/client.js +542 -29
- package/index.js +166 -3
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -9,8 +9,14 @@ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
|
|
|
9
9
|
- **Files** page: lists the active session's workspace directory, with folder
|
|
10
10
|
navigation, editable path input, parent/refresh buttons, file and folder
|
|
11
11
|
icons, and file sizes. File rows reveal view / download / delete buttons on
|
|
12
|
-
hover; delete uses a second-click confirm.
|
|
13
|
-
|
|
12
|
+
hover; delete uses a second-click confirm. Text files open in a
|
|
13
|
+
**monaco-editor** dialog with syntax highlighting, editing, and save (dirty
|
|
14
|
+
indicator + discard confirmation); markdown files (`.md`, `.markdown`,
|
|
15
|
+
`.mdx`) add a **渲染预览 / Rendered preview** toggle in the dialog header
|
|
16
|
+
that switches between the editable source and a rendered HTML view (the
|
|
17
|
+
live text, so the render follows unsaved edits); image files open in a
|
|
18
|
+
viewer with fullscreen, 25%-400% zoom, wheel zoom, and drag-to-pan after
|
|
19
|
+
zooming.
|
|
14
20
|
- **Git Graph** page: a vscode/le-git-graph style commit graph rendered from
|
|
15
21
|
the repository containing the current directory —
|
|
16
22
|
- colored lane graph with commit dots and merge curves (all branches or the
|
|
@@ -30,13 +36,13 @@ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
|
|
|
30
36
|
|
|
31
37
|
| File | Content |
|
|
32
38
|
| --- | --- |
|
|
33
|
-
| `index.js` | Host half: registers exact `/ _dsh/file-explorer/*` routes backed by the Host `fs`/`subprocess` services, plus read-only git routes (`/ _dsh/file-explorer/git-log`, `git-commit`, `git-diff`, `git-status`) that run `git` with machine-readable separators and return JSON only. |
|
|
34
|
-
| `client.js` | Client half: registers independent keyed `right-panel.page` entries for Files and Git Graph. |
|
|
39
|
+
| `index.js` | Host half: registers exact `/ _dsh/file-explorer/*` routes backed by the Host `fs`/`subprocess` services (`list`, `read`, `write`, `download`, `delete`), plus read-only git routes (`/ _dsh/file-explorer/git-log`, `git-commit`, `git-diff`, `git-status`) that run `git` with machine-readable separators and return JSON only. Also serves the monaco-editor AMD tree under `/ _dsh/file-explorer/monaco/vs` and the markdown-it ESM build under `/ _dsh/file-explorer/vendor/markdown-it.mjs`. |
|
|
40
|
+
| `client.js` | Client half: registers independent keyed `right-panel.page` entries for Files and Git Graph, and loads monaco-editor from the host-served AMD tree. |
|
|
35
41
|
| `cordis.patch.yml` | Composition patch that mounts the host row — declared with `inject: [webServer]`, so it activates only after the stock webserver service (`127.0.0.1`) is up. |
|
|
36
42
|
|
|
37
43
|
## Install
|
|
38
44
|
|
|
39
|
-
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.
|
|
45
|
+
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.8.0`.
|
|
40
46
|
|
|
41
47
|
The right-panel package must be installed in the same `web` profile:
|
|
42
48
|
|
|
@@ -53,7 +59,7 @@ pnpm pack
|
|
|
53
59
|
```
|
|
54
60
|
|
|
55
61
|
```bash
|
|
56
|
-
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.
|
|
62
|
+
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.8.0.tgz
|
|
57
63
|
```
|
|
58
64
|
|
|
59
65
|
### npm package
|
|
@@ -76,10 +82,26 @@ apply the new profile composition.
|
|
|
76
82
|
- The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
|
|
77
83
|
and returns `{ ok, path, parent, entries }` JSON — never live objects.
|
|
78
84
|
- `read` previews up to 1 MiB of UTF-8 text or 16 MiB of recognized images;
|
|
79
|
-
image previews return a data URL for inline rendering. `
|
|
85
|
+
image previews return a data URL for inline rendering. `write` saves edited
|
|
86
|
+
text back (also capped at 1 MiB) through `fs.writeText`; `download` is a GET
|
|
80
87
|
route (`/_dsh/file-explorer/download?path=...`) that streams the file
|
|
81
88
|
natively (`Content-Disposition: attachment`) — no base64, no JSON body, no
|
|
82
89
|
size ceiling. The browser triggers it with a transient `<a download>` link.
|
|
90
|
+
- Text previews open in monaco-editor (AMD build served verbatim from the npm
|
|
91
|
+
package under `/ _dsh/file-explorer/monaco/vs`). The editor lazily loads the
|
|
92
|
+
language features for css/html/json/typescript, tracks dirty state, saves via
|
|
93
|
+
the `write` route, and confirms before discarding unsaved changes.
|
|
94
|
+
- Markdown files render with markdown-it's standalone ESM build (the
|
|
95
|
+
`./browser` export) served verbatim by the Host under
|
|
96
|
+
`/ _dsh/file-explorer/vendor/markdown-it.mjs` — no CDN, no bundler step.
|
|
97
|
+
The browser loads it with dynamic `import()`. The ESM build is deliberate:
|
|
98
|
+
the UMD build's AMD branch would be taken whenever a global `define`
|
|
99
|
+
exists (the monaco loader in the same page), registering the module
|
|
100
|
+
anonymously instead of exposing the constructor. Rendering happens in the
|
|
101
|
+
browser with `html: false` (raw HTML inside the file is escaped) and
|
|
102
|
+
markdown-it's built-in `validateLink` rejects
|
|
103
|
+
`javascript:`/`vbscript:`/`file:`/`data:` hrefs, so workspace files never
|
|
104
|
+
inject markup or scripts into the page.
|
|
83
105
|
- `delete` removes regular files (`rm -f`) and directories recursively
|
|
84
106
|
(`rm -rf`); both are called only after a second-click confirm in the UI.
|
|
85
107
|
- Git routes are read-only. They resolve the repository root with
|
package/client.js
CHANGED
|
@@ -65,6 +65,18 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
"files.imageTooLarge": "图片过大,无法在此预览",
|
|
66
66
|
"files.noPreview": "(无预览)",
|
|
67
67
|
"files.binaryNoPreview": "二进制文件无法预览",
|
|
68
|
+
"files.edit": "编辑",
|
|
69
|
+
"files.editFile": "编辑 {name}",
|
|
70
|
+
"files.save": "保存",
|
|
71
|
+
"files.saving": "保存中…",
|
|
72
|
+
"files.saved": "已保存",
|
|
73
|
+
"files.saveFailed": "保存失败",
|
|
74
|
+
"files.dirty": "有未保存的更改",
|
|
75
|
+
"files.markdownRender": "渲染预览",
|
|
76
|
+
"files.markdownSource": "源代码",
|
|
77
|
+
"files.markdownRenderFailed": "Markdown 渲染失败",
|
|
78
|
+
"files.discardConfirm": "有未保存的更改,确定放弃并关闭吗?",
|
|
79
|
+
"files.editorLoadFailed": "编辑器加载失败",
|
|
68
80
|
"git.logFailed": "Git 提交记录加载失败",
|
|
69
81
|
"git.commitFailed": "提交详情加载失败",
|
|
70
82
|
"git.diffFailed": "差异加载失败",
|
|
@@ -142,6 +154,18 @@ window.__ModuleLoader__.load({
|
|
|
142
154
|
"files.imageTooLarge": "Image is too large to preview here",
|
|
143
155
|
"files.noPreview": "(No preview)",
|
|
144
156
|
"files.binaryNoPreview": "Binary file cannot be previewed",
|
|
157
|
+
"files.edit": "Edit",
|
|
158
|
+
"files.editFile": "Edit {name}",
|
|
159
|
+
"files.save": "Save",
|
|
160
|
+
"files.saving": "Saving…",
|
|
161
|
+
"files.saved": "Saved",
|
|
162
|
+
"files.saveFailed": "Save failed",
|
|
163
|
+
"files.dirty": "Unsaved changes",
|
|
164
|
+
"files.markdownRender": "Rendered preview",
|
|
165
|
+
"files.markdownSource": "Source",
|
|
166
|
+
"files.markdownRenderFailed": "Failed to render markdown",
|
|
167
|
+
"files.discardConfirm": "You have unsaved changes. Discard them and close?",
|
|
168
|
+
"files.editorLoadFailed": "Editor failed to load",
|
|
145
169
|
"git.logFailed": "Failed to load git log",
|
|
146
170
|
"git.commitFailed": "Failed to load commit",
|
|
147
171
|
"git.diffFailed": "Failed to load diff",
|
|
@@ -437,6 +461,11 @@ window.__ModuleLoader__.load({
|
|
|
437
461
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
|
|
438
462
|
overflow: hidden;
|
|
439
463
|
}
|
|
464
|
+
.dsh-fe-modal-editor {
|
|
465
|
+
width: min(920px, calc(100vw - 32px));
|
|
466
|
+
height: min(80vh, 720px);
|
|
467
|
+
max-height: min(80vh, 720px);
|
|
468
|
+
}
|
|
440
469
|
.dsh-fe-modal-fullscreen {
|
|
441
470
|
width: 100vw;
|
|
442
471
|
height: 100vh;
|
|
@@ -461,6 +490,13 @@ window.__ModuleLoader__.load({
|
|
|
461
490
|
font-size: 13px;
|
|
462
491
|
font-weight: 600;
|
|
463
492
|
}
|
|
493
|
+
.dsh-fe-editor-title-dot {
|
|
494
|
+
display: inline-block;
|
|
495
|
+
margin-left: 6px;
|
|
496
|
+
color: var(--dsw-alias-state-warning-primary, #d97706);
|
|
497
|
+
font-size: 10px;
|
|
498
|
+
vertical-align: 1px;
|
|
499
|
+
}
|
|
464
500
|
.dsh-fe-modal-meta {
|
|
465
501
|
flex: 0 0 auto;
|
|
466
502
|
color: var(--dsw-alias-label-secondary, #6b7280);
|
|
@@ -511,6 +547,185 @@ window.__ModuleLoader__.load({
|
|
|
511
547
|
background: var(--dsw-alias-bg-base, #f5f5f4);
|
|
512
548
|
white-space: normal;
|
|
513
549
|
}
|
|
550
|
+
.dsh-fe-modal-body-editor {
|
|
551
|
+
display: flex;
|
|
552
|
+
flex-direction: column;
|
|
553
|
+
min-height: 0;
|
|
554
|
+
padding: 0;
|
|
555
|
+
overflow: hidden;
|
|
556
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff);
|
|
557
|
+
}
|
|
558
|
+
.dsh-fe-editor-host {
|
|
559
|
+
flex: 1 1 auto;
|
|
560
|
+
min-height: 0;
|
|
561
|
+
width: 100%;
|
|
562
|
+
}
|
|
563
|
+
.dsh-fe-editor-host .monaco-editor,
|
|
564
|
+
.dsh-fe-editor-host .monaco-editor-background,
|
|
565
|
+
.dsh-fe-editor-host .monaco-editor .margin {
|
|
566
|
+
background-color: transparent;
|
|
567
|
+
}
|
|
568
|
+
.dsh-fe-editor-status {
|
|
569
|
+
flex: 0 0 auto;
|
|
570
|
+
display: flex;
|
|
571
|
+
align-items: center;
|
|
572
|
+
gap: 8px;
|
|
573
|
+
min-height: 28px;
|
|
574
|
+
padding: 2px 14px;
|
|
575
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
|
|
576
|
+
color: var(--dsw-alias-label-secondary, #78716c);
|
|
577
|
+
font-size: 11px;
|
|
578
|
+
}
|
|
579
|
+
.dsh-fe-editor-status-dirty {
|
|
580
|
+
color: var(--dsw-alias-state-warning-primary, #d97706);
|
|
581
|
+
}
|
|
582
|
+
.dsh-fe-editor-status-error {
|
|
583
|
+
color: var(--dsw-alias-state-error-primary, #b91c1c);
|
|
584
|
+
}
|
|
585
|
+
.dsh-fe-editor-save {
|
|
586
|
+
margin-left: auto;
|
|
587
|
+
height: 24px;
|
|
588
|
+
padding: 0 12px;
|
|
589
|
+
border: 0;
|
|
590
|
+
border-radius: 6px;
|
|
591
|
+
background: var(--dsw-alias-brand-primary, #4f46e5);
|
|
592
|
+
color: #ffffff;
|
|
593
|
+
font: inherit;
|
|
594
|
+
font-size: 12px;
|
|
595
|
+
cursor: pointer;
|
|
596
|
+
}
|
|
597
|
+
.dsh-fe-editor-save:hover:not(:disabled) {
|
|
598
|
+
filter: brightness(1.08);
|
|
599
|
+
}
|
|
600
|
+
.dsh-fe-editor-save:disabled {
|
|
601
|
+
opacity: 0.55;
|
|
602
|
+
cursor: default;
|
|
603
|
+
}
|
|
604
|
+
.dsh-fe-md-toggle {
|
|
605
|
+
flex: 0 0 auto;
|
|
606
|
+
height: 24px;
|
|
607
|
+
padding: 0 10px;
|
|
608
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.2));
|
|
609
|
+
border-radius: 999px;
|
|
610
|
+
background: transparent;
|
|
611
|
+
color: var(--dsw-alias-label-secondary, #57534e);
|
|
612
|
+
font: inherit;
|
|
613
|
+
font-size: 11px;
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
white-space: nowrap;
|
|
616
|
+
}
|
|
617
|
+
.dsh-fe-md-toggle:hover {
|
|
618
|
+
background: var(--dsw-alias-bg-layer-2, #e7e5e4);
|
|
619
|
+
color: var(--dsw-alias-label-primary, #111827);
|
|
620
|
+
}
|
|
621
|
+
.dsh-fe-md-toggle[aria-pressed='true'] {
|
|
622
|
+
background: var(--dsw-alias-brand-primary, #4f46e5);
|
|
623
|
+
border-color: transparent;
|
|
624
|
+
color: #fff;
|
|
625
|
+
}
|
|
626
|
+
.dsh-fe-markdown {
|
|
627
|
+
flex: 1 1 auto;
|
|
628
|
+
min-height: 0;
|
|
629
|
+
overflow: auto;
|
|
630
|
+
padding: 14px 18px;
|
|
631
|
+
box-sizing: border-box;
|
|
632
|
+
color: var(--dsw-alias-label-primary, #111827);
|
|
633
|
+
font-size: 13px;
|
|
634
|
+
line-height: 20px;
|
|
635
|
+
overflow-wrap: anywhere;
|
|
636
|
+
}
|
|
637
|
+
.dsh-fe-markdown > :first-child {
|
|
638
|
+
margin-top: 0;
|
|
639
|
+
}
|
|
640
|
+
.dsh-fe-markdown > :last-child {
|
|
641
|
+
margin-bottom: 0;
|
|
642
|
+
}
|
|
643
|
+
.dsh-fe-markdown h1,
|
|
644
|
+
.dsh-fe-markdown h2,
|
|
645
|
+
.dsh-fe-markdown h3,
|
|
646
|
+
.dsh-fe-markdown h4 {
|
|
647
|
+
margin: 14px 0 8px;
|
|
648
|
+
font-weight: 700;
|
|
649
|
+
line-height: 1.35;
|
|
650
|
+
}
|
|
651
|
+
.dsh-fe-markdown h1 {
|
|
652
|
+
font-size: 17px;
|
|
653
|
+
}
|
|
654
|
+
.dsh-fe-markdown h2 {
|
|
655
|
+
font-size: 15px;
|
|
656
|
+
}
|
|
657
|
+
.dsh-fe-markdown h3,
|
|
658
|
+
.dsh-fe-markdown h4 {
|
|
659
|
+
font-size: 13px;
|
|
660
|
+
}
|
|
661
|
+
.dsh-fe-markdown p {
|
|
662
|
+
margin: 0 0 10px;
|
|
663
|
+
}
|
|
664
|
+
.dsh-fe-markdown a {
|
|
665
|
+
color: var(--dsw-alias-brand-primary, #2563eb);
|
|
666
|
+
text-decoration: underline;
|
|
667
|
+
}
|
|
668
|
+
.dsh-fe-markdown ul,
|
|
669
|
+
.dsh-fe-markdown ol {
|
|
670
|
+
margin: 0 0 10px;
|
|
671
|
+
padding-left: 22px;
|
|
672
|
+
}
|
|
673
|
+
.dsh-fe-markdown li {
|
|
674
|
+
margin: 2px 0;
|
|
675
|
+
}
|
|
676
|
+
.dsh-fe-markdown blockquote {
|
|
677
|
+
margin: 0 0 10px;
|
|
678
|
+
padding: 2px 12px;
|
|
679
|
+
border-left: 3px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.2));
|
|
680
|
+
color: var(--dsw-alias-label-secondary, #57534e);
|
|
681
|
+
}
|
|
682
|
+
.dsh-fe-markdown code {
|
|
683
|
+
box-sizing: border-box;
|
|
684
|
+
padding: 1px 5px;
|
|
685
|
+
border-radius: 4px;
|
|
686
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff);
|
|
687
|
+
font-family: var(--ds-font-family-code, ui-monospace, monospace);
|
|
688
|
+
font-size: 12px;
|
|
689
|
+
}
|
|
690
|
+
.dsh-fe-markdown pre {
|
|
691
|
+
margin: 0 0 10px;
|
|
692
|
+
padding: 10px 12px;
|
|
693
|
+
border-radius: 6px;
|
|
694
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff);
|
|
695
|
+
overflow: auto;
|
|
696
|
+
}
|
|
697
|
+
.dsh-fe-markdown pre code {
|
|
698
|
+
padding: 0;
|
|
699
|
+
background: none;
|
|
700
|
+
white-space: pre;
|
|
701
|
+
}
|
|
702
|
+
.dsh-fe-markdown table {
|
|
703
|
+
border-collapse: collapse;
|
|
704
|
+
margin: 0 0 10px;
|
|
705
|
+
max-width: 100%;
|
|
706
|
+
display: block;
|
|
707
|
+
overflow: auto;
|
|
708
|
+
}
|
|
709
|
+
.dsh-fe-markdown th,
|
|
710
|
+
.dsh-fe-markdown td {
|
|
711
|
+
padding: 5px 10px;
|
|
712
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
713
|
+
font-size: 12px;
|
|
714
|
+
}
|
|
715
|
+
.dsh-fe-markdown th {
|
|
716
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff);
|
|
717
|
+
font-weight: 700;
|
|
718
|
+
}
|
|
719
|
+
.dsh-fe-markdown hr {
|
|
720
|
+
margin: 12px 0;
|
|
721
|
+
border: 0;
|
|
722
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
723
|
+
}
|
|
724
|
+
.dsh-fe-markdown img {
|
|
725
|
+
max-width: 100%;
|
|
726
|
+
height: auto;
|
|
727
|
+
border-radius: 4px;
|
|
728
|
+
}
|
|
514
729
|
.dsh-fe-image-stage {
|
|
515
730
|
flex: 1 1 auto;
|
|
516
731
|
min-width: 0;
|
|
@@ -756,6 +971,188 @@ window.__ModuleLoader__.load({
|
|
|
756
971
|
return data;
|
|
757
972
|
}
|
|
758
973
|
|
|
974
|
+
// --- monaco editor loading --------------------------------------------
|
|
975
|
+
// monaco-editor ships as AMD chunks served verbatim from the host at
|
|
976
|
+
// /_dsh/file-explorer/monaco/vs. We load the AMD loader as a classic
|
|
977
|
+
// script (it installs global require/define — the DSH client module
|
|
978
|
+
// system does not install either), configure its baseUrl to the served
|
|
979
|
+
// tree, and resolve 'vs/editor/editor.main' once. Language features for
|
|
980
|
+
// css/html/json/typescript register on demand when a model with that
|
|
981
|
+
// language id is created.
|
|
982
|
+
const MONACO_BASE = '/_dsh/file-explorer/monaco/vs'
|
|
983
|
+
let monacoPromise = null
|
|
984
|
+
function loadMonaco() {
|
|
985
|
+
if (monacoPromise) return monacoPromise
|
|
986
|
+
monacoPromise = new Promise((resolve, reject) => {
|
|
987
|
+
if (window.monaco && window.monaco.editor) {
|
|
988
|
+
resolve(window.monaco)
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const script = document.createElement('script')
|
|
992
|
+
script.src = `${MONACO_BASE}/loader.js`
|
|
993
|
+
script.async = true
|
|
994
|
+
script.onload = () => {
|
|
995
|
+
const loader = window.require
|
|
996
|
+
if (typeof loader !== 'function') {
|
|
997
|
+
reject(new Error('monaco AMD loader did not install require'))
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
loader.config({ baseUrl: MONACO_BASE, paths: { vs: MONACO_BASE } })
|
|
1001
|
+
loader(['vs/editor/editor.main'], () => {
|
|
1002
|
+
resolve(window.monaco)
|
|
1003
|
+
}, (error) => {
|
|
1004
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
1005
|
+
})
|
|
1006
|
+
}
|
|
1007
|
+
script.onerror = () => reject(new Error('could not load monaco loader'))
|
|
1008
|
+
document.head.append(script)
|
|
1009
|
+
})
|
|
1010
|
+
return monacoPromise
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// --- markdown rendering ------------------------------------------------
|
|
1014
|
+
// markdown-it's standalone ESM build is served verbatim by the Host
|
|
1015
|
+
// (no CDN) and loaded with dynamic `import()`. The ESM build is used
|
|
1016
|
+
// deliberately: the UMD build picks the AMD branch whenever a global
|
|
1017
|
+
// `define` exists (the monaco AMD loader above installs one), which
|
|
1018
|
+
// registers the module anonymously instead of exposing a constructor.
|
|
1019
|
+
// `html: false` escapes any embedded HTML in the workspace file, and
|
|
1020
|
+
// markdown-it's built-in validateLink rejects javascript:/vbscript:/
|
|
1021
|
+
// file:/data: hrefs, so the rendered HTML can be injected as-is.
|
|
1022
|
+
const MARKDOWN_IT_URL = '/_dsh/file-explorer/vendor/markdown-it.mjs'
|
|
1023
|
+
let markdownItPromise = null
|
|
1024
|
+
function loadMarkdownIt() {
|
|
1025
|
+
if (markdownItPromise) return markdownItPromise
|
|
1026
|
+
markdownItPromise = import(MARKDOWN_IT_URL).then((mod) => {
|
|
1027
|
+
if (mod === null || typeof mod.default !== 'function') {
|
|
1028
|
+
throw new Error('markdown-it module did not export a constructor')
|
|
1029
|
+
}
|
|
1030
|
+
return mod.default
|
|
1031
|
+
})
|
|
1032
|
+
return markdownItPromise
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
let markdownRenderer = null
|
|
1036
|
+
function renderMarkdown(text) {
|
|
1037
|
+
return loadMarkdownIt().then((markdownit) => {
|
|
1038
|
+
if (markdownRenderer === null) markdownRenderer = markdownit({ html: false, linkify: true })
|
|
1039
|
+
return markdownRenderer.render(text)
|
|
1040
|
+
})
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const MARKDOWN_NAME_RE = /\.(md|markdown|mdx)$/i
|
|
1044
|
+
|
|
1045
|
+
// Map a file name to a monaco language id. Mirrors the host's mime map
|
|
1046
|
+
// so text files open with the right syntax highlighting.
|
|
1047
|
+
const EXT_TO_LANG = {
|
|
1048
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
1049
|
+
ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
|
|
1050
|
+
json: 'json', jsonc: 'json',
|
|
1051
|
+
html: 'html', htm: 'html', svg: 'html',
|
|
1052
|
+
css: 'css', scss: 'scss', less: 'less',
|
|
1053
|
+
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
|
|
1054
|
+
py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
|
|
1055
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
|
|
1056
|
+
cs: 'csharp', php: 'php', sh: 'shell', bash: 'shell', zsh: 'shell',
|
|
1057
|
+
yml: 'yaml', yaml: 'yaml', xml: 'xml', sql: 'sql',
|
|
1058
|
+
txt: 'plaintext', text: 'plaintext', log: 'plaintext',
|
|
1059
|
+
}
|
|
1060
|
+
function languageForName(name) {
|
|
1061
|
+
if (typeof name !== 'string') return 'plaintext'
|
|
1062
|
+
const dot = name.lastIndexOf('.')
|
|
1063
|
+
if (dot === -1) return 'plaintext'
|
|
1064
|
+
const ext = name.slice(dot + 1).toLowerCase()
|
|
1065
|
+
return EXT_TO_LANG[ext] || 'plaintext'
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Monaco container component: owns the editor lifecycle (create on
|
|
1069
|
+
// mount, dispose on unmount, update model/content when props change).
|
|
1070
|
+
// The text lives in props.value, so the parent's React state is the
|
|
1071
|
+
// single source of truth and the save flow just reads it.
|
|
1072
|
+
function MonacoEditor(props) {
|
|
1073
|
+
const hostRef = React.useRef(null)
|
|
1074
|
+
const editorRef = React.useRef(null)
|
|
1075
|
+
const modelRef = React.useRef(null)
|
|
1076
|
+
const valueRef = React.useRef(props.value || '')
|
|
1077
|
+
const onChangeRef = React.useRef(props.onChange)
|
|
1078
|
+
onChangeRef.current = props.onChange
|
|
1079
|
+
|
|
1080
|
+
React.useEffect(() => {
|
|
1081
|
+
let disposed = false
|
|
1082
|
+
loadMonaco()
|
|
1083
|
+
.then((monaco) => {
|
|
1084
|
+
if (disposed || !hostRef.current) return
|
|
1085
|
+
const editor = monaco.editor.create(hostRef.current, {
|
|
1086
|
+
value: valueRef.current,
|
|
1087
|
+
language: languageForName(props.name || ''),
|
|
1088
|
+
automaticLayout: true,
|
|
1089
|
+
minimap: { enabled: false },
|
|
1090
|
+
scrollBeyondLastLine: false,
|
|
1091
|
+
renderWhitespace: 'selection',
|
|
1092
|
+
fontSize: 12,
|
|
1093
|
+
lineHeight: 18,
|
|
1094
|
+
tabSize: 2,
|
|
1095
|
+
wordWrap: 'off',
|
|
1096
|
+
padding: { top: 10, bottom: 10 },
|
|
1097
|
+
theme: document.body.hasAttribute('data-ds-dark-theme') ? 'vs-dark' : 'vs',
|
|
1098
|
+
})
|
|
1099
|
+
editorRef.current = editor
|
|
1100
|
+
modelRef.current = editor.getModel()
|
|
1101
|
+
const sub = editor.onDidChangeModelContent(() => {
|
|
1102
|
+
const value = editor.getValue()
|
|
1103
|
+
valueRef.current = value
|
|
1104
|
+
if (onChangeRef.current) onChangeRef.current(value)
|
|
1105
|
+
})
|
|
1106
|
+
editor.__dshDispose = () => {
|
|
1107
|
+
sub.dispose()
|
|
1108
|
+
editor.dispose()
|
|
1109
|
+
}
|
|
1110
|
+
// The editor measures its container on mount; the modal
|
|
1111
|
+
// may still be sizing, so re-layout once after a frame.
|
|
1112
|
+
window.requestAnimationFrame(() => {
|
|
1113
|
+
if (!disposed && editorRef.current) editorRef.current.layout()
|
|
1114
|
+
})
|
|
1115
|
+
})
|
|
1116
|
+
.catch((error) => {
|
|
1117
|
+
if (!disposed) {
|
|
1118
|
+
const el = hostRef.current
|
|
1119
|
+
if (el) {
|
|
1120
|
+
el.textContent = t('files.editorLoadFailed') + ': ' + (error && typeof error.message === 'string' ? error.message : String(error))
|
|
1121
|
+
el.style.display = 'flex'
|
|
1122
|
+
el.style.alignItems = 'center'
|
|
1123
|
+
el.style.justifyContent = 'center'
|
|
1124
|
+
el.style.color = 'var(--dsw-alias-label-secondary, #78716c)'
|
|
1125
|
+
el.style.font = '13px/1.5 sans-serif'
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
return () => {
|
|
1130
|
+
disposed = true
|
|
1131
|
+
if (editorRef.current) {
|
|
1132
|
+
if (editorRef.current.__dshDispose) editorRef.current.__dshDispose()
|
|
1133
|
+
editorRef.current = null
|
|
1134
|
+
modelRef.current = null
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}, [])
|
|
1138
|
+
|
|
1139
|
+
// Sync external value changes (reload/undo after save) without
|
|
1140
|
+
// clobbering the user's typing: only replace when the model still
|
|
1141
|
+
// holds the previous external value.
|
|
1142
|
+
React.useEffect(() => {
|
|
1143
|
+
const editor = editorRef.current
|
|
1144
|
+
const next = props.value || ''
|
|
1145
|
+
if (!editor || valueRef.current === next) return
|
|
1146
|
+
valueRef.current = next
|
|
1147
|
+
editor.setValue(next)
|
|
1148
|
+
}, [props.value])
|
|
1149
|
+
|
|
1150
|
+
return React.createElement('div', {
|
|
1151
|
+
ref: hostRef,
|
|
1152
|
+
className: 'dsh-fe-editor-host',
|
|
1153
|
+
})
|
|
1154
|
+
}
|
|
1155
|
+
|
|
759
1156
|
function svgIcon(icon, size) {
|
|
760
1157
|
return React.createElement('svg', {
|
|
761
1158
|
viewBox: '0 0 24 24',
|
|
@@ -782,9 +1179,9 @@ window.__ModuleLoader__.load({
|
|
|
782
1179
|
React.createElement('path', { d: 'M3 12a9 9 0 1 0 2.64-6.36' }),
|
|
783
1180
|
React.createElement('polyline', { points: '21 3 21 9 15 9' }),
|
|
784
1181
|
)
|
|
785
|
-
const
|
|
786
|
-
React.createElement('path', { d: '
|
|
787
|
-
React.createElement('
|
|
1182
|
+
const editIcon = React.createElement(React.Fragment, null,
|
|
1183
|
+
React.createElement('path', { d: 'M12 20h9' }),
|
|
1184
|
+
React.createElement('path', { d: 'M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z' }),
|
|
788
1185
|
)
|
|
789
1186
|
const downloadIcon = React.createElement(React.Fragment, null,
|
|
790
1187
|
React.createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
|
|
@@ -1365,12 +1762,17 @@ window.__ModuleLoader__.load({
|
|
|
1365
1762
|
const [draftPath, setDraftPath] = React.useState('')
|
|
1366
1763
|
const [preview, setPreview] = React.useState(null)
|
|
1367
1764
|
const [previewLoading, setPreviewLoading] = React.useState(false)
|
|
1765
|
+
const [dirty, setDirty] = React.useState(false)
|
|
1766
|
+
const [saveState, setSaveState] = React.useState(null) // 'saving' | 'saved' | { error }
|
|
1767
|
+
const dirtyRef = React.useRef(false)
|
|
1368
1768
|
const [imageScale, setImageScale] = React.useState(1)
|
|
1369
1769
|
const [imageOffset, setImageOffset] = React.useState({ x: 0, y: 0 })
|
|
1370
1770
|
const [imageFullscreen, setImageFullscreen] = React.useState(false)
|
|
1371
1771
|
const [imageDragging, setImageDragging] = React.useState(false)
|
|
1372
1772
|
const imageDrag = React.useRef(null)
|
|
1373
1773
|
const [pendingDelete, setPendingDelete] = React.useState(null)
|
|
1774
|
+
const [mdView, setMdView] = React.useState('editor') // 'editor' | 'render' for markdown files
|
|
1775
|
+
const [mdHtml, setMdHtml] = React.useState(null) // {html} | {error} while render view is active
|
|
1374
1776
|
|
|
1375
1777
|
React.useEffect(() => {
|
|
1376
1778
|
if (sessionId === undefined) {
|
|
@@ -1451,7 +1853,12 @@ window.__ModuleLoader__.load({
|
|
|
1451
1853
|
setImageFullscreen(false)
|
|
1452
1854
|
}
|
|
1453
1855
|
const closePreview = () => {
|
|
1856
|
+
if (preview && preview.text !== undefined && preview.text !== null && dirtyRef.current) {
|
|
1857
|
+
if (!window.confirm(t('files.discardConfirm'))) return
|
|
1858
|
+
}
|
|
1454
1859
|
resetImageView()
|
|
1860
|
+
dirtyRef.current = false
|
|
1861
|
+
setDirty(false)
|
|
1455
1862
|
setPreview(null)
|
|
1456
1863
|
}
|
|
1457
1864
|
const setImageZoom = (value) => {
|
|
@@ -1498,11 +1905,35 @@ window.__ModuleLoader__.load({
|
|
|
1498
1905
|
else closePreview()
|
|
1499
1906
|
}
|
|
1500
1907
|
|
|
1908
|
+
const saveFile = () => {
|
|
1909
|
+
if (!preview || preview.kind === 'image' || preview.text === undefined || preview.text === null) return
|
|
1910
|
+
if (saveState === 'saving') return
|
|
1911
|
+
setSaveState('saving')
|
|
1912
|
+
api('write', { path: preview.path, content: preview.text })
|
|
1913
|
+
.then((raw) => {
|
|
1914
|
+
if (raw && raw.ok === true) {
|
|
1915
|
+
dirtyRef.current = false
|
|
1916
|
+
setDirty(false)
|
|
1917
|
+
setSaveState('saved')
|
|
1918
|
+
} else {
|
|
1919
|
+
setSaveState({ error: raw && typeof raw.error === 'string' ? raw.error : t('files.saveFailed') })
|
|
1920
|
+
}
|
|
1921
|
+
})
|
|
1922
|
+
.catch((err) => {
|
|
1923
|
+
setSaveState({ error: err && typeof err.message === 'string' ? err.message : String(err) })
|
|
1924
|
+
})
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1501
1927
|
const openPreview = (entry) => {
|
|
1502
1928
|
setPendingDelete(null)
|
|
1503
1929
|
resetImageView()
|
|
1504
1930
|
setPreviewLoading(true)
|
|
1931
|
+
dirtyRef.current = false
|
|
1932
|
+
setDirty(false)
|
|
1933
|
+
setSaveState(null)
|
|
1505
1934
|
setPreview(null)
|
|
1935
|
+
setMdView('editor')
|
|
1936
|
+
setMdHtml(null)
|
|
1506
1937
|
api('read', { path: entry.path })
|
|
1507
1938
|
.then((raw) => {
|
|
1508
1939
|
setPreview(raw && raw.ok === true ? raw : { ok: false, error: raw && typeof raw.error === 'string' ? raw.error : t('files.readFailed') })
|
|
@@ -1513,6 +1944,21 @@ window.__ModuleLoader__.load({
|
|
|
1513
1944
|
.finally(() => setPreviewLoading(false))
|
|
1514
1945
|
}
|
|
1515
1946
|
|
|
1947
|
+
// Render the markdown source to HTML only while the render view is
|
|
1948
|
+
// active; re-renders when the (edited) text changes so the two
|
|
1949
|
+
// views never drift apart.
|
|
1950
|
+
React.useEffect(() => {
|
|
1951
|
+
if (mdView !== 'render' || !preview || preview.text === undefined || preview.text === null) return undefined
|
|
1952
|
+
let cancelled = false
|
|
1953
|
+
setMdHtml(null)
|
|
1954
|
+
renderMarkdown(preview.text)
|
|
1955
|
+
.then((html) => { if (!cancelled) setMdHtml({ html }) })
|
|
1956
|
+
.catch((error) => {
|
|
1957
|
+
if (!cancelled) setMdHtml({ error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
1958
|
+
})
|
|
1959
|
+
return () => { cancelled = true }
|
|
1960
|
+
}, [mdView, preview])
|
|
1961
|
+
|
|
1516
1962
|
const downloadFile = (entry) => {
|
|
1517
1963
|
setPendingDelete(null)
|
|
1518
1964
|
// Native streaming download: navigate to the Host route, which
|
|
@@ -1549,6 +1995,7 @@ window.__ModuleLoader__.load({
|
|
|
1549
1995
|
.catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
|
|
1550
1996
|
}
|
|
1551
1997
|
|
|
1998
|
+
const isMarkdownFile = preview !== null && MARKDOWN_NAME_RE.test(String(preview.name || ''))
|
|
1552
1999
|
const pathControl = editingPath
|
|
1553
2000
|
? React.createElement('input', {
|
|
1554
2001
|
key: 'path-input',
|
|
@@ -1617,9 +2064,9 @@ window.__ModuleLoader__.load({
|
|
|
1617
2064
|
type: 'button',
|
|
1618
2065
|
className: 'dsh-fe-action',
|
|
1619
2066
|
onClick: (event) => { event.stopPropagation(); openPreview(entry) },
|
|
1620
|
-
'aria-label': t('files.
|
|
1621
|
-
title: t('files.
|
|
1622
|
-
}, svgIcon(
|
|
2067
|
+
'aria-label': t('files.editFile', { name: entry.name }),
|
|
2068
|
+
title: t('files.edit'),
|
|
2069
|
+
}, svgIcon(editIcon, 14)),
|
|
1623
2070
|
!isDir && React.createElement('button', {
|
|
1624
2071
|
type: 'button',
|
|
1625
2072
|
className: 'dsh-fe-action',
|
|
@@ -1655,7 +2102,11 @@ window.__ModuleLoader__.load({
|
|
|
1655
2102
|
onClick: closePreview,
|
|
1656
2103
|
},
|
|
1657
2104
|
React.createElement('div', {
|
|
1658
|
-
className: preview.kind === 'image' && imageFullscreen
|
|
2105
|
+
className: preview.kind === 'image' && imageFullscreen
|
|
2106
|
+
? 'dsh-fe-modal dsh-fe-modal-fullscreen'
|
|
2107
|
+
: preview.text !== undefined && preview.text !== null && !preview.tooLarge
|
|
2108
|
+
? 'dsh-fe-modal dsh-fe-modal-editor'
|
|
2109
|
+
: 'dsh-fe-modal',
|
|
1659
2110
|
role: 'dialog',
|
|
1660
2111
|
'aria-label': t('files.previewDialog'),
|
|
1661
2112
|
'aria-modal': true,
|
|
@@ -1664,7 +2115,11 @@ window.__ModuleLoader__.load({
|
|
|
1664
2115
|
onClick: (event) => event.stopPropagation(),
|
|
1665
2116
|
},
|
|
1666
2117
|
React.createElement('div', { className: 'dsh-fe-modal-head' },
|
|
1667
|
-
React.createElement('div', { className: 'dsh-fe-modal-title' },
|
|
2118
|
+
React.createElement('div', { className: 'dsh-fe-modal-title' },
|
|
2119
|
+
preview.name || t('files.preview'),
|
|
2120
|
+
dirty
|
|
2121
|
+
? React.createElement('span', { className: 'dsh-fe-editor-title-dot', title: t('files.dirty') }, '●')
|
|
2122
|
+
: null),
|
|
1668
2123
|
React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? t('files.imageMeta', { size: formatSize(preview.size) }) : preview.binary ? t('files.binary') : formatSize(preview.size)),
|
|
1669
2124
|
preview.kind === 'image' && preview.dataUrl
|
|
1670
2125
|
? React.createElement('div', {
|
|
@@ -1704,6 +2159,15 @@ window.__ModuleLoader__.load({
|
|
|
1704
2159
|
}, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
|
|
1705
2160
|
)
|
|
1706
2161
|
: null,
|
|
2162
|
+
isMarkdownFile && preview.text !== undefined && preview.text !== null && !preview.tooLarge && !preview.binary
|
|
2163
|
+
? React.createElement('button', {
|
|
2164
|
+
type: 'button',
|
|
2165
|
+
className: 'dsh-fe-md-toggle',
|
|
2166
|
+
onClick: () => setMdView((value) => (value === 'render' ? 'editor' : 'render')),
|
|
2167
|
+
'aria-pressed': mdView === 'render',
|
|
2168
|
+
title: mdView === 'render' ? t('files.markdownSource') : t('files.markdownRender'),
|
|
2169
|
+
}, mdView === 'render' ? t('files.markdownSource') : t('files.markdownRender'))
|
|
2170
|
+
: null,
|
|
1707
2171
|
React.createElement('button', {
|
|
1708
2172
|
type: 'button',
|
|
1709
2173
|
className: 'dsh-fe-icon',
|
|
@@ -1711,20 +2175,20 @@ window.__ModuleLoader__.load({
|
|
|
1711
2175
|
'aria-label': t('files.closePreview'),
|
|
1712
2176
|
}, svgIcon(closeIcon, 14)),
|
|
1713
2177
|
),
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
2178
|
+
preview.kind === 'image'
|
|
2179
|
+
? React.createElement('div', {
|
|
2180
|
+
className: 'dsh-fe-modal-body dsh-fe-modal-body-image',
|
|
2181
|
+
onWheel: preview.dataUrl ? handleImageWheel : undefined,
|
|
2182
|
+
onPointerDown: preview.dataUrl ? startImageDrag : undefined,
|
|
2183
|
+
onPointerMove: preview.dataUrl ? moveImageDrag : undefined,
|
|
2184
|
+
onPointerUp: preview.dataUrl ? endImageDrag : undefined,
|
|
2185
|
+
onPointerCancel: preview.dataUrl ? endImageDrag : undefined,
|
|
2186
|
+
},
|
|
2187
|
+
previewLoading
|
|
2188
|
+
? t('files.loading')
|
|
2189
|
+
: preview.error
|
|
2190
|
+
? preview.error
|
|
2191
|
+
: preview.dataUrl
|
|
1728
2192
|
? React.createElement('div', { className: 'dsh-fe-image-stage' },
|
|
1729
2193
|
React.createElement('img', {
|
|
1730
2194
|
className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
|
|
@@ -1734,13 +2198,62 @@ window.__ModuleLoader__.load({
|
|
|
1734
2198
|
draggable: false,
|
|
1735
2199
|
})
|
|
1736
2200
|
)
|
|
1737
|
-
: preview.tooLarge ? t('files.imageTooLarge') : t('files.noPreview')
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
2201
|
+
: preview.tooLarge ? t('files.imageTooLarge') : t('files.noPreview'),
|
|
2202
|
+
)
|
|
2203
|
+
: mdView === 'render' && preview.text !== undefined && preview.text !== null && !preview.tooLarge
|
|
2204
|
+
? React.createElement('div', { className: 'dsh-fe-markdown' },
|
|
2205
|
+
mdHtml === null
|
|
2206
|
+
? t('files.loading')
|
|
2207
|
+
: mdHtml.error
|
|
2208
|
+
? React.createElement('div', { className: 'dsh-fe-status dsh-fe-status-error' }, mdHtml.error)
|
|
2209
|
+
: React.createElement('div', {
|
|
2210
|
+
role: 'region',
|
|
2211
|
+
'aria-label': t('files.markdownRender'),
|
|
2212
|
+
dangerouslySetInnerHTML: { __html: mdHtml.html },
|
|
2213
|
+
}),
|
|
2214
|
+
)
|
|
2215
|
+
: preview.text !== undefined && preview.text !== null && !preview.tooLarge && mdView !== 'render'
|
|
2216
|
+
? React.createElement('div', { className: 'dsh-fe-modal-body dsh-fe-modal-body-editor' },
|
|
2217
|
+
React.createElement(MonacoEditor, {
|
|
2218
|
+
name: preview.name || '',
|
|
2219
|
+
value: preview.text,
|
|
2220
|
+
onChange: (value) => {
|
|
2221
|
+
dirtyRef.current = true
|
|
2222
|
+
setDirty(true)
|
|
2223
|
+
setSaveState(null)
|
|
2224
|
+
setPreview((prev) => (prev ? { ...prev, text: value } : prev))
|
|
2225
|
+
},
|
|
2226
|
+
}),
|
|
2227
|
+
React.createElement('div', { className: 'dsh-fe-editor-status' },
|
|
2228
|
+
dirty
|
|
2229
|
+
? React.createElement('span', { className: 'dsh-fe-editor-status-dirty' }, t('files.dirty'))
|
|
2230
|
+
: saveState === 'saving'
|
|
2231
|
+
? React.createElement('span', null, t('files.saving'))
|
|
2232
|
+
: saveState === 'saved'
|
|
2233
|
+
? React.createElement('span', null, t('files.saved'))
|
|
2234
|
+
: saveState && saveState.error
|
|
2235
|
+
? React.createElement('span', { className: 'dsh-fe-editor-status-error' }, saveState.error)
|
|
2236
|
+
: React.createElement('span', null, preview.size != null ? formatSize(preview.size) : ''),
|
|
2237
|
+
React.createElement('button', {
|
|
2238
|
+
type: 'button',
|
|
2239
|
+
className: 'dsh-fe-editor-save',
|
|
2240
|
+
disabled: !dirty || saveState === 'saving',
|
|
2241
|
+
onClick: saveFile,
|
|
2242
|
+
'aria-label': t('files.save'),
|
|
2243
|
+
}, t('files.save')),
|
|
2244
|
+
),
|
|
2245
|
+
)
|
|
2246
|
+
: React.createElement('div', {
|
|
2247
|
+
className: 'dsh-fe-modal-body',
|
|
2248
|
+
},
|
|
2249
|
+
previewLoading
|
|
2250
|
+
? t('files.loading')
|
|
2251
|
+
: preview.error
|
|
2252
|
+
? preview.error
|
|
2253
|
+
: preview.tooLarge
|
|
2254
|
+
? t('files.fileTooLarge')
|
|
2255
|
+
: preview.binary ? t('files.binaryNoPreview') : t('files.noPreview'),
|
|
2256
|
+
),
|
|
1744
2257
|
),
|
|
1745
2258
|
),
|
|
1746
2259
|
)
|
package/index.js
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs'
|
|
1
|
+
import { createReadStream, readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* dsh-plugin-file-explorer — Host half (static DSH bundle).
|
|
5
7
|
*
|
|
6
8
|
* Registers exact HTTP routes under /_dsh/file-explorer for the browser
|
|
7
|
-
* bundle: list (with parent path), read (text/image preview),
|
|
8
|
-
* (native streaming), and delete.
|
|
9
|
+
* bundle: list (with parent path), read (text/image preview), write
|
|
10
|
+
* (edit save), download (native streaming), and delete. Also serves the
|
|
11
|
+
* monaco-editor AMD assets under /_dsh/file-explorer/monaco so the browser
|
|
12
|
+
* bundle can load a full editing experience without a bundler step.
|
|
9
13
|
*/
|
|
10
14
|
|
|
15
|
+
// --- monaco-editor static assets ------------------------------------------
|
|
16
|
+
// The npm package ships pre-built AMD chunks under min/vs: the loader plus
|
|
17
|
+
// hashed editor/language/worker files that reference each other with module
|
|
18
|
+
// ids rooted at "vs/". Serving the whole tree verbatim under a stable URL
|
|
19
|
+
// prefix lets the browser-side AMD loader (baseUrl = that prefix) resolve
|
|
20
|
+
// every chunk and worker with zero rewriting.
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url)
|
|
23
|
+
// monaco-editor >= 0.56 maps every subpath export to esm/vs/*, which makes
|
|
24
|
+
// require.resolve('monaco-editor/package.json') resolve to a non-existent
|
|
25
|
+
// esm/vs/package.json.js. Resolve the package root via the main entry instead.
|
|
26
|
+
const MONACO_DIR = join(dirname(dirname(dirname(require.resolve('monaco-editor')))), 'min', 'vs')
|
|
27
|
+
const MONACO_URL = '/_dsh/file-explorer/monaco/vs'
|
|
28
|
+
const MONACO_MIME = {
|
|
29
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.map': 'application/json; charset=utf-8',
|
|
32
|
+
}
|
|
33
|
+
const MONACO_CACHE = new Map()
|
|
34
|
+
|
|
35
|
+
// markdown-it standalone ESM build (the package's "./browser" import export)
|
|
36
|
+
// served verbatim under a plugin-local route — same self-contained pattern as
|
|
37
|
+
// the monaco tree: no CDN, no bundler step. The ESM build is used
|
|
38
|
+
// deliberately: unlike the UMD build it has no AMD wrapper, so the monaco
|
|
39
|
+
// loader's global `define` can never hijack it. The file is read once at
|
|
40
|
+
// first request.
|
|
41
|
+
const MARKDOWN_IT_URL = '/_dsh/file-explorer/vendor/markdown-it.mjs'
|
|
42
|
+
const MARKDOWN_IT_PATH = join(dirname(require.resolve('markdown-it/package.json')), 'dist', 'browser', 'markdown-it.esm.min.mjs')
|
|
43
|
+
let markdownItBody = null
|
|
44
|
+
|
|
11
45
|
function parentOf(path) {
|
|
12
46
|
if (!path) return null
|
|
13
47
|
const cleaned = path.replace(/[/\\]+$/, '')
|
|
@@ -154,6 +188,7 @@ export function apply(ctx) {
|
|
|
154
188
|
if (size !== null && size > limit) {
|
|
155
189
|
sendJson(res, 200, {
|
|
156
190
|
ok: true,
|
|
191
|
+
path: target.displayPath,
|
|
157
192
|
name,
|
|
158
193
|
size,
|
|
159
194
|
tooLarge: true,
|
|
@@ -165,6 +200,7 @@ export function apply(ctx) {
|
|
|
165
200
|
if (isImage) {
|
|
166
201
|
sendJson(res, 200, {
|
|
167
202
|
ok: true,
|
|
203
|
+
path: target.displayPath,
|
|
168
204
|
name,
|
|
169
205
|
size,
|
|
170
206
|
kind: 'image',
|
|
@@ -181,6 +217,7 @@ export function apply(ctx) {
|
|
|
181
217
|
if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
|
|
182
218
|
sendJson(res, 200, {
|
|
183
219
|
ok: true,
|
|
220
|
+
path: target.displayPath,
|
|
184
221
|
name,
|
|
185
222
|
size,
|
|
186
223
|
text: binary ? null : text,
|
|
@@ -288,6 +325,132 @@ export function apply(ctx) {
|
|
|
288
325
|
}
|
|
289
326
|
})
|
|
290
327
|
|
|
328
|
+
addRoute('/_dsh/file-explorer/write', async (req, res) => {
|
|
329
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
330
|
+
const fs = ctx.get('fs')
|
|
331
|
+
if (fs === undefined) {
|
|
332
|
+
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (typeof req.path !== 'string' || typeof req.content !== 'string') {
|
|
336
|
+
sendJson(res, 200, { ok: false, error: 'missing file path or content' })
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
// Mirror the read route's 1 MiB ceiling so the editor never writes a file
|
|
340
|
+
// it could not have previewed.
|
|
341
|
+
if (req.content.length > 1024 * 1024) {
|
|
342
|
+
sendJson(res, 200, { ok: false, error: 'file too large to save' })
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const target = await fs.resolve(req.path)
|
|
347
|
+
const outcome = await fs.writeText(target, req.content)
|
|
348
|
+
sendJson(res, 200, {
|
|
349
|
+
ok: true,
|
|
350
|
+
version: outcome && typeof outcome.version === 'string' ? outcome.version : null,
|
|
351
|
+
})
|
|
352
|
+
} catch (error) {
|
|
353
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// --- monaco-editor static tree -------------------------------------------
|
|
358
|
+
// Longest-prefix route: the browser's AMD loader fetches any file under
|
|
359
|
+
// /_dsh/file-explorer/monaco/vs/<module path>. Only ever serves files from
|
|
360
|
+
// inside the installed monaco-editor package.
|
|
361
|
+
// Must register as a prefix route (kind: "prefix"), not "exact": the exact
|
|
362
|
+
// table only matches the bare path, so every asset request would 404.
|
|
363
|
+
const loadMonacoAsset = (file) => {
|
|
364
|
+
let record = MONACO_CACHE.get(file)
|
|
365
|
+
if (record === undefined) {
|
|
366
|
+
const path = join(MONACO_DIR, file)
|
|
367
|
+
const rel = relative(MONACO_DIR, path)
|
|
368
|
+
if (rel.startsWith('..') || rel.startsWith(sep) || path.split(sep).includes('..')) {
|
|
369
|
+
record = { error: 'invalid monaco asset path' }
|
|
370
|
+
} else {
|
|
371
|
+
try {
|
|
372
|
+
const body = readFileSync(path)
|
|
373
|
+
const ext = file.slice(file.lastIndexOf('.'))
|
|
374
|
+
record = {
|
|
375
|
+
body,
|
|
376
|
+
type: MONACO_MIME[ext] || 'application/octet-stream',
|
|
377
|
+
}
|
|
378
|
+
} catch (error) {
|
|
379
|
+
record = { error: error && typeof error.message === 'string' ? error.message : String(error) }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
MONACO_CACHE.set(file, record)
|
|
383
|
+
}
|
|
384
|
+
return record
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
disposers.push(webServer.register({
|
|
388
|
+
kind: 'prefix',
|
|
389
|
+
path: '/_dsh/file-explorer/monaco',
|
|
390
|
+
handler: async (req, res) => {
|
|
391
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
392
|
+
const pathname = url.pathname
|
|
393
|
+
const file = pathname.startsWith(`${MONACO_URL}/`)
|
|
394
|
+
? pathname.slice(MONACO_URL.length + 1)
|
|
395
|
+
: null
|
|
396
|
+
if (file === null || file === '') {
|
|
397
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
398
|
+
res.end('not found')
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
402
|
+
res.writeHead(405)
|
|
403
|
+
res.end()
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
const asset = loadMonacoAsset(file)
|
|
407
|
+
if (asset.error) {
|
|
408
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
409
|
+
res.end(asset.error)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
res.writeHead(200, {
|
|
413
|
+
'content-type': asset.type,
|
|
414
|
+
'content-length': asset.body.length,
|
|
415
|
+
// Hashed chunk names are immutable per release; the loader.js and
|
|
416
|
+
// nls/lang files are release-stable too, so a long cache is safe.
|
|
417
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
418
|
+
})
|
|
419
|
+
if (req.method === 'HEAD') res.end()
|
|
420
|
+
else res.end(asset.body)
|
|
421
|
+
},
|
|
422
|
+
}))
|
|
423
|
+
|
|
424
|
+
disposers.push(webServer.register({
|
|
425
|
+
kind: 'exact',
|
|
426
|
+
path: MARKDOWN_IT_URL,
|
|
427
|
+
handler: (req, res) => {
|
|
428
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
429
|
+
res.writeHead(405)
|
|
430
|
+
res.end()
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (markdownItBody === null) {
|
|
434
|
+
try {
|
|
435
|
+
markdownItBody = readFileSync(MARKDOWN_IT_PATH)
|
|
436
|
+
} catch (error) {
|
|
437
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
438
|
+
res.end(error && typeof error.message === 'string' ? error.message : String(error))
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
res.writeHead(200, {
|
|
443
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
444
|
+
'content-length': markdownItBody.length,
|
|
445
|
+
// Bundled file name is stable per plugin version; same long-cache
|
|
446
|
+
// policy as the monaco tree.
|
|
447
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
448
|
+
})
|
|
449
|
+
if (req.method === 'HEAD') res.end()
|
|
450
|
+
else res.end(markdownItBody)
|
|
451
|
+
},
|
|
452
|
+
}))
|
|
453
|
+
|
|
291
454
|
// --- git graph API -------------------------------------------------------
|
|
292
455
|
// Read-only git plumbing behind the browser's graph view. Every route
|
|
293
456
|
// resolves the repository root from the requested directory first, then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yiln-dsh/dsh-plugin-file-explorer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -42,5 +42,9 @@
|
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=22"
|
|
44
44
|
},
|
|
45
|
-
"license": "MIT"
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"markdown-it": "^15.0.1",
|
|
48
|
+
"monaco-editor": "^0.56.0"
|
|
49
|
+
}
|
|
46
50
|
}
|