@yiln-dsh/dsh-plugin-file-explorer 0.5.1 → 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.
Files changed (4) hide show
  1. package/README.md +32 -8
  2. package/client.js +557 -48
  3. package/index.js +200 -12
  4. 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. Image View supports fullscreen,
13
- 25%-400% zoom, wheel zoom, and drag-to-pan after zooming.
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.5.1`.
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.5.1.tgz
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,8 +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. `download` returns a
80
- base64 data URL (64 MiB cap) that the browser triggers with `<a download>`.
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
87
+ route (`/_dsh/file-explorer/download?path=...`) that streams the file
88
+ natively (`Content-Disposition: attachment`) — no base64, no JSON body, no
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.
81
105
  - `delete` removes regular files (`rm -f`) and directories recursively
82
106
  (`rm -rf`); both are called only after a second-click confirm in the UI.
83
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 eyeIcon = React.createElement(React.Fragment, null,
786
- React.createElement('path', { d: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z' }),
787
- React.createElement('circle', { cx: '12', cy: '12', r: '3' }),
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,27 +1944,38 @@ 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
- api('download', { path: entry.path })
1519
- .then((raw) => {
1520
- if (!raw || raw.ok !== true) {
1521
- const message = raw && typeof raw.error === 'string' ? raw.error : t('files.downloadFailed')
1522
- setError(message)
1523
- return
1524
- }
1525
- try {
1526
- const link = document.createElement('a')
1527
- link.href = raw.dataUrl
1528
- link.download = entry.name
1529
- document.body.appendChild(link)
1530
- link.click()
1531
- link.remove()
1532
- } catch (err) {
1533
- setError(err && typeof err.message === 'string' ? err.message : String(err))
1534
- }
1535
- })
1536
- .catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
1964
+ // Native streaming download: navigate to the Host route, which
1965
+ // streams the file with `Content-Disposition: attachment`. The
1966
+ // browser handles the download natively — no JSON data URL, no
1967
+ // base64, no full-file buffering in page memory.
1968
+ try {
1969
+ const query = new URLSearchParams({ path: entry.path })
1970
+ const link = document.createElement('a')
1971
+ link.href = `/_dsh/file-explorer/download?${query.toString()}`
1972
+ link.download = entry.name
1973
+ document.body.appendChild(link)
1974
+ link.click()
1975
+ link.remove()
1976
+ } catch (err) {
1977
+ setError(err && typeof err.message === 'string' ? err.message : String(err))
1978
+ }
1537
1979
  }
1538
1980
 
1539
1981
  const requestDelete = (entry) => {
@@ -1553,6 +1995,7 @@ window.__ModuleLoader__.load({
1553
1995
  .catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
1554
1996
  }
1555
1997
 
1998
+ const isMarkdownFile = preview !== null && MARKDOWN_NAME_RE.test(String(preview.name || ''))
1556
1999
  const pathControl = editingPath
1557
2000
  ? React.createElement('input', {
1558
2001
  key: 'path-input',
@@ -1621,9 +2064,9 @@ window.__ModuleLoader__.load({
1621
2064
  type: 'button',
1622
2065
  className: 'dsh-fe-action',
1623
2066
  onClick: (event) => { event.stopPropagation(); openPreview(entry) },
1624
- 'aria-label': t('files.viewFile', { name: entry.name }),
1625
- title: t('files.view'),
1626
- }, svgIcon(eyeIcon, 14)),
2067
+ 'aria-label': t('files.editFile', { name: entry.name }),
2068
+ title: t('files.edit'),
2069
+ }, svgIcon(editIcon, 14)),
1627
2070
  !isDir && React.createElement('button', {
1628
2071
  type: 'button',
1629
2072
  className: 'dsh-fe-action',
@@ -1659,7 +2102,11 @@ window.__ModuleLoader__.load({
1659
2102
  onClick: closePreview,
1660
2103
  },
1661
2104
  React.createElement('div', {
1662
- className: preview.kind === 'image' && imageFullscreen ? 'dsh-fe-modal dsh-fe-modal-fullscreen' : 'dsh-fe-modal',
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',
1663
2110
  role: 'dialog',
1664
2111
  'aria-label': t('files.previewDialog'),
1665
2112
  'aria-modal': true,
@@ -1668,7 +2115,11 @@ window.__ModuleLoader__.load({
1668
2115
  onClick: (event) => event.stopPropagation(),
1669
2116
  },
1670
2117
  React.createElement('div', { className: 'dsh-fe-modal-head' },
1671
- React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || t('files.preview')),
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),
1672
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)),
1673
2124
  preview.kind === 'image' && preview.dataUrl
1674
2125
  ? React.createElement('div', {
@@ -1708,6 +2159,15 @@ window.__ModuleLoader__.load({
1708
2159
  }, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
1709
2160
  )
1710
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,
1711
2171
  React.createElement('button', {
1712
2172
  type: 'button',
1713
2173
  className: 'dsh-fe-icon',
@@ -1715,20 +2175,20 @@ window.__ModuleLoader__.load({
1715
2175
  'aria-label': t('files.closePreview'),
1716
2176
  }, svgIcon(closeIcon, 14)),
1717
2177
  ),
1718
- React.createElement('div', {
1719
- className: preview.kind === 'image' ? 'dsh-fe-modal-body dsh-fe-modal-body-image' : 'dsh-fe-modal-body',
1720
- onWheel: preview.kind === 'image' && preview.dataUrl ? handleImageWheel : undefined,
1721
- onPointerDown: preview.kind === 'image' && preview.dataUrl ? startImageDrag : undefined,
1722
- onPointerMove: preview.kind === 'image' && preview.dataUrl ? moveImageDrag : undefined,
1723
- onPointerUp: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1724
- onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1725
- },
1726
- previewLoading
1727
- ? t('files.loading')
1728
- : preview.error
1729
- ? preview.error
1730
- : preview.kind === 'image'
1731
- ? preview.dataUrl
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
1732
2192
  ? React.createElement('div', { className: 'dsh-fe-image-stage' },
1733
2193
  React.createElement('img', {
1734
2194
  className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
@@ -1738,13 +2198,62 @@ window.__ModuleLoader__.load({
1738
2198
  draggable: false,
1739
2199
  })
1740
2200
  )
1741
- : preview.tooLarge ? t('files.imageTooLarge') : t('files.noPreview')
1742
- : preview.text !== undefined && preview.text !== null
1743
- ? preview.text
1744
- : preview.tooLarge
1745
- ? t('files.fileTooLarge')
1746
- : preview.binary ? t('files.binaryNoPreview') : t('files.noPreview'),
1747
- ),
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
+ ),
1748
2257
  ),
1749
2258
  ),
1750
2259
  )
package/index.js CHANGED
@@ -1,11 +1,47 @@
1
+ import { createReadStream, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, relative, sep } from 'node:path'
4
+
1
5
  /**
2
6
  * dsh-plugin-file-explorer — Host half (static DSH bundle).
3
7
  *
4
8
  * Registers exact HTTP routes under /_dsh/file-explorer for the browser
5
- * bundle: list (with parent path), read (text/image preview), download (data URL),
6
- * 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.
7
13
  */
8
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
+
9
45
  function parentOf(path) {
10
46
  if (!path) return null
11
47
  const cleaned = path.replace(/[/\\]+$/, '')
@@ -152,6 +188,7 @@ export function apply(ctx) {
152
188
  if (size !== null && size > limit) {
153
189
  sendJson(res, 200, {
154
190
  ok: true,
191
+ path: target.displayPath,
155
192
  name,
156
193
  size,
157
194
  tooLarge: true,
@@ -163,6 +200,7 @@ export function apply(ctx) {
163
200
  if (isImage) {
164
201
  sendJson(res, 200, {
165
202
  ok: true,
203
+ path: target.displayPath,
166
204
  name,
167
205
  size,
168
206
  kind: 'image',
@@ -179,6 +217,7 @@ export function apply(ctx) {
179
217
  if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
180
218
  sendJson(res, 200, {
181
219
  ok: true,
220
+ path: target.displayPath,
182
221
  name,
183
222
  size,
184
223
  text: binary ? null : text,
@@ -190,29 +229,52 @@ export function apply(ctx) {
190
229
  })
191
230
 
192
231
  addRoute('/_dsh/file-explorer/download', async (req, res) => {
193
- if (req.method === 'POST') req = await readJson(req)
232
+ const url = new URL(req.url || '/', 'http://dsh.local')
233
+ const requestedPath = url.searchParams.get('path')
194
234
  const fs = ctx.get('fs')
195
235
  if (fs === undefined) {
196
236
  sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
197
237
  return
198
238
  }
199
- if (typeof req.path !== 'string') {
239
+ if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
200
240
  sendJson(res, 200, { ok: false, error: 'missing file path' })
201
241
  return
202
242
  }
203
243
 
204
244
  try {
205
- const target = await fs.resolve(req.path)
245
+ const target = await fs.resolve(requestedPath)
206
246
  const info = await fs.stat(target)
207
- const size = info && typeof info.size === 'number' ? info.size : null
208
- const limit = 64 * 1024 * 1024
209
- if (size !== null && size > limit) {
210
- sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
247
+ if (info === undefined || info.type !== 'file') {
248
+ sendJson(res, 200, { ok: false, error: 'file does not exist' })
211
249
  return
212
250
  }
213
- const bytes = await fs.readBytes(target, undefined, limit)
214
- const name = target.displayPath.split('/').pop()
215
- sendJson(res, 200, { ok: true, name, size, dataUrl: `data:${guessMime(name)};base64,${bytesToBase64(bytes)}` })
251
+ const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : 0
252
+ const name = target.displayPath.split('/').pop() || 'download'
253
+ const safeName = String(name).replace(/[\r\n"\\]/gu, '_').replace(/[^\x20-\x7e]/gu, '_') || 'download'
254
+ const encodedName = encodeURIComponent(String(name)).replace(/['()]/gu, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)
255
+
256
+ res.writeHead(200, {
257
+ 'content-type': guessMime(name),
258
+ 'content-disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
259
+ 'content-length': size,
260
+ 'x-content-type-options': 'nosniff',
261
+ 'cache-control': 'no-store',
262
+ })
263
+
264
+ // Native streaming download: pipe the resolved host path straight to the
265
+ // HTTP response. No full-file buffering, no base64, no 64 MiB ceiling.
266
+ const stream = createReadStream(fs.processPath(target))
267
+ stream.on('error', (error) => {
268
+ if (res.headersSent) res.destroy(error)
269
+ else {
270
+ try {
271
+ sendJson(res, 500, { ok: false, error: `failed to stream file: ${error && typeof error.message === 'string' ? error.message : String(error)}` })
272
+ } catch {
273
+ res.destroy(error)
274
+ }
275
+ }
276
+ })
277
+ stream.pipe(res)
216
278
  } catch (error) {
217
279
  sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
218
280
  }
@@ -263,6 +325,132 @@ export function apply(ctx) {
263
325
  }
264
326
  })
265
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
+
266
454
  // --- git graph API -------------------------------------------------------
267
455
  // Read-only git plumbing behind the browser's graph view. Every route
268
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.5.1",
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
  }