@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.
Files changed (4) hide show
  1. package/README.md +29 -7
  2. package/client.js +542 -29
  3. package/index.js +166 -3
  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.6.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.6.0.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,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. `download` is a GET
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 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,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.viewFile', { name: entry.name }),
1621
- title: t('files.view'),
1622
- }, svgIcon(eyeIcon, 14)),
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 ? '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',
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' }, 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),
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
- React.createElement('div', {
1715
- className: preview.kind === 'image' ? 'dsh-fe-modal-body dsh-fe-modal-body-image' : 'dsh-fe-modal-body',
1716
- onWheel: preview.kind === 'image' && preview.dataUrl ? handleImageWheel : undefined,
1717
- onPointerDown: preview.kind === 'image' && preview.dataUrl ? startImageDrag : undefined,
1718
- onPointerMove: preview.kind === 'image' && preview.dataUrl ? moveImageDrag : undefined,
1719
- onPointerUp: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1720
- onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1721
- },
1722
- previewLoading
1723
- ? t('files.loading')
1724
- : preview.error
1725
- ? preview.error
1726
- : preview.kind === 'image'
1727
- ? 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
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
- : preview.text !== undefined && preview.text !== null
1739
- ? preview.text
1740
- : preview.tooLarge
1741
- ? t('files.fileTooLarge')
1742
- : preview.binary ? t('files.binaryNoPreview') : t('files.noPreview'),
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), download
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.6.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
  }