@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
package/lib/index.js ADDED
@@ -0,0 +1,3207 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { createReadStream } from "node:fs";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
17
+ key = keys[i];
18
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
19
+ get: ((k) => from[k]).bind(null, key),
20
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
21
+ });
22
+ }
23
+ return to;
24
+ };
25
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
26
+ value: mod,
27
+ enumerable: true
28
+ }) : target, mod));
29
+ //#endregion
30
+ //#region node_modules/@deepseek-ai/dsh-settings/lib/index.js
31
+ /**
32
+ * Structural secret redaction for settings values. `role('secret')` fields are
33
+ * removed from a value before it crosses a wire boundary; a sidecar records
34
+ * each schema-declared secret position and whether it currently holds a value,
35
+ * so a configuration surface can render a write-only input without ever
36
+ * receiving the secret itself.
37
+ * @module @deepseek-ai/dsh-settings/redact
38
+ */
39
+ /** Whether a value is a plain data object the walker may recurse into. */
40
+ function isRecord(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+ function walk(node, value, path, secrets) {
44
+ if (node === void 0) return value;
45
+ if (node.meta?.role === "secret") {
46
+ secrets.push({
47
+ path,
48
+ set: value !== void 0
49
+ });
50
+ return;
51
+ }
52
+ switch (node.type) {
53
+ case "object": {
54
+ const properties = node.dict ?? {};
55
+ const source = isRecord(value) ? value : void 0;
56
+ const rebuilt = {};
57
+ if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
58
+ if (key in properties) continue;
59
+ rebuilt[key] = entry;
60
+ }
61
+ for (const [key, child] of Object.entries(properties)) {
62
+ const stripped = walk(child, source?.[key], [...path, key], secrets);
63
+ if (stripped !== void 0) rebuilt[key] = stripped;
64
+ }
65
+ return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
66
+ }
67
+ case "dict": {
68
+ if (!isRecord(value)) return value;
69
+ const rebuilt = {};
70
+ for (const [key, entry] of Object.entries(value)) {
71
+ const stripped = walk(node.inner, entry, [...path, key], secrets);
72
+ if (stripped !== void 0) rebuilt[key] = stripped;
73
+ }
74
+ return rebuilt;
75
+ }
76
+ case "array":
77
+ if (!Array.isArray(value)) return value;
78
+ return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
79
+ default: return value;
80
+ }
81
+ }
82
+ /**
83
+ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
84
+ * per-namespace sections; plugins register a namespace schema and read the
85
+ * resolved value, which layers schema defaults, the registrant's composition
86
+ * `base`, and the user document section, in that order.
87
+ * @module @deepseek-ai/dsh-settings
88
+ */
89
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
90
+ /**
91
+ * Brand a raw string as a {@link SettingsNamespace}.
92
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
93
+ * @returns the branded namespace.
94
+ */
95
+ function settingsNamespace(value) {
96
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
97
+ return value;
98
+ }
99
+ /**
100
+ * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
101
+ * Service Definition's single change-detection predicate, exported so the invariant
102
+ * companion checks exactly the implementation's relation.
103
+ * @param a - one JSON-compatible value.
104
+ * @param b - the other JSON-compatible value.
105
+ * @returns whether the two values are structurally equal.
106
+ */
107
+ function deepEqualJson(a, b) {
108
+ if (a === b) return true;
109
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
110
+ if (Array.isArray(a) || Array.isArray(b)) {
111
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
112
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
113
+ }
114
+ const left = a;
115
+ const right = b;
116
+ const keys = Object.keys(left);
117
+ if (keys.length !== Object.keys(right).length) return false;
118
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
119
+ }
120
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
121
+ function isPlainObject(value) {
122
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
123
+ const proto = Object.getPrototypeOf(value);
124
+ return proto === Object.prototype || proto === null;
125
+ }
126
+ /** Apply one path op to a detached section, returning the next section. */
127
+ function applyPathOp(section, op) {
128
+ const [head, ...rest] = op.path;
129
+ if (head === void 0) {
130
+ if (op.op === "unset") return {};
131
+ if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
132
+ return { ...op.value };
133
+ }
134
+ if (rest.length === 0) {
135
+ if (op.op === "set") return {
136
+ ...section,
137
+ [head]: op.value
138
+ };
139
+ const { [head]: _removed, ...kept } = section;
140
+ return kept;
141
+ }
142
+ const child = section[head];
143
+ if (!isPlainObject(child)) {
144
+ if (op.op === "unset") return section;
145
+ return {
146
+ ...section,
147
+ [head]: applyPathOp({}, {
148
+ ...op,
149
+ path: rest
150
+ })
151
+ };
152
+ }
153
+ return {
154
+ ...section,
155
+ [head]: applyPathOp(child, {
156
+ ...op,
157
+ path: rest
158
+ })
159
+ };
160
+ }
161
+ /**
162
+ * Layer `over` onto `under`: plain objects merge recursively, every other
163
+ * value (arrays included) replaces the lower layer wholesale. `over` never
164
+ * carries `undefined` entries — sections come from parsed documents and write
165
+ * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
166
+ * cannot erase lower keys.
167
+ */
168
+ function mergeLayers(under, over) {
169
+ if (over === void 0) return under;
170
+ if (!isPlainObject(under) || !isPlainObject(over)) return over;
171
+ const merged = { ...under };
172
+ for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
173
+ return merged;
174
+ }
175
+ /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
176
+ function deepFreeze(value) {
177
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
178
+ for (const entry of Object.values(value)) deepFreeze(entry);
179
+ return Object.freeze(value);
180
+ }
181
+ Service.init;
182
+ /**
183
+ * Value mirror of the `FiberState` members {@link isUnloading} compares
184
+ * against: a const enum has no runtime object to import, and the value is
185
+ * needed at runtime (same rationale as the CLI boot driver's mirror).
186
+ */
187
+ const FIBER_DISPOSED = 4;
188
+ const FIBER_UNLOADING = 5;
189
+ /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
190
+ function isUnloading(ctx) {
191
+ const state = ctx.fiber.state;
192
+ return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
193
+ }
194
+ /**
195
+ * Install the canonical optional-settings consumer wiring: while a settings
196
+ * service exists, register `ns` with the consumer's composition entry as the
197
+ * `base` layer and point the source thunk at the resolved scope; when the
198
+ * service goes away (disposal, provider reload), fall back to the entry so
199
+ * the consumer keeps working exactly as composed. The registration rides the
200
+ * scoped fiber, so no settings service ever mounted means none of this runs.
201
+ * @param ctx - consumer plugin context owning the wiring.
202
+ * @param ns - the consumer-owned settings namespace.
203
+ * @param schema - schema resolving the namespace (typically the plugin Config).
204
+ * @param entry - the consumer's composition entry config, used as `base`.
205
+ * @param hooks - source sink and change notification.
206
+ */
207
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
208
+ ctx.inject(["settings"], (sctx) => {
209
+ const scope = sctx.settings.register(ns, schema, {
210
+ base: entry,
211
+ ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
212
+ });
213
+ hooks.setSource(() => scope.get());
214
+ sctx.effect(() => () => {
215
+ if (isUnloading(ctx)) return;
216
+ hooks.setSource(() => entry);
217
+ hooks.onChange();
218
+ });
219
+ hooks.onChange();
220
+ scope.watch(() => {
221
+ if (isUnloading(ctx)) return;
222
+ hooks.onChange();
223
+ });
224
+ });
225
+ }
226
+ //#endregion
227
+ //#region node_modules/cosmokit/lib/index.cjs
228
+ var require_lib$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
229
+ var __defProp = Object.defineProperty;
230
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
231
+ var __getOwnPropNames = Object.getOwnPropertyNames;
232
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
233
+ var __export = (target, all) => {
234
+ for (var name in all) __defProp(target, name, {
235
+ get: all[name],
236
+ enumerable: true
237
+ });
238
+ };
239
+ var __copyProps = (to, from, except, desc) => {
240
+ if (from && typeof from === "object" || typeof from === "function") {
241
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
242
+ get: () => from[key],
243
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
244
+ });
245
+ }
246
+ return to;
247
+ };
248
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
249
+ var index_exports = {};
250
+ __export(index_exports, {
251
+ Binary: () => Binary,
252
+ Time: () => Time,
253
+ arrayBufferToBase64: () => arrayBufferToBase64,
254
+ arrayBufferToHex: () => arrayBufferToHex,
255
+ base64ToArrayBuffer: () => base64ToArrayBuffer,
256
+ camelCase: () => camelCase,
257
+ camelize: () => camelize,
258
+ capitalize: () => capitalize,
259
+ clone: () => clone,
260
+ contain: () => contain,
261
+ deduplicate: () => deduplicate,
262
+ deepEqual: () => deepEqual,
263
+ defineProperty: () => defineProperty,
264
+ difference: () => difference,
265
+ filterKeys: () => filterKeys,
266
+ formatProperty: () => formatProperty,
267
+ hexToArrayBuffer: () => hexToArrayBuffer,
268
+ hyphenate: () => hyphenate,
269
+ intersection: () => intersection,
270
+ is: () => is,
271
+ isNonNullable: () => isNonNullable,
272
+ isNullable: () => isNullable,
273
+ isPlainObject: () => isPlainObject,
274
+ makeArray: () => makeArray,
275
+ mapValues: () => mapValues,
276
+ noop: () => noop,
277
+ omit: () => omit,
278
+ paramCase: () => paramCase,
279
+ pick: () => pick,
280
+ remove: () => remove,
281
+ sanitize: () => sanitize,
282
+ snakeCase: () => snakeCase,
283
+ trimSlash: () => trimSlash,
284
+ uncapitalize: () => uncapitalize,
285
+ union: () => union,
286
+ valueMap: () => mapValues
287
+ });
288
+ module.exports = __toCommonJS(index_exports);
289
+ function noop() {}
290
+ function isNullable(value) {
291
+ return value === null || value === void 0;
292
+ }
293
+ function isNonNullable(value) {
294
+ return !isNullable(value);
295
+ }
296
+ function isPlainObject(data) {
297
+ return data && typeof data === "object" && !Array.isArray(data);
298
+ }
299
+ function filterKeys(object, filter) {
300
+ return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
301
+ }
302
+ function mapValues(object, transform) {
303
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
304
+ }
305
+ function pick(source, keys, forced) {
306
+ if (!keys) return { ...source };
307
+ const result = {};
308
+ for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
309
+ return result;
310
+ }
311
+ function omit(source, keys) {
312
+ if (!keys) return { ...source };
313
+ const result = { ...source };
314
+ for (const key of keys) Reflect.deleteProperty(result, key);
315
+ return result;
316
+ }
317
+ function defineProperty(object, key, value) {
318
+ return Object.defineProperty(object, key, {
319
+ writable: true,
320
+ value,
321
+ enumerable: false
322
+ });
323
+ }
324
+ function contain(array1, array2) {
325
+ return array2.every((item) => array1.includes(item));
326
+ }
327
+ function intersection(array1, array2) {
328
+ return array1.filter((item) => array2.includes(item));
329
+ }
330
+ function difference(array1, array2) {
331
+ return array1.filter((item) => !array2.includes(item));
332
+ }
333
+ function union(array1, array2) {
334
+ return Array.from(/* @__PURE__ */ new Set([...array1, ...array2]));
335
+ }
336
+ function deduplicate(array) {
337
+ return [...new Set(array)];
338
+ }
339
+ function remove(list, item) {
340
+ const index = list?.indexOf(item);
341
+ if (index >= 0) {
342
+ list.splice(index, 1);
343
+ return true;
344
+ } else return false;
345
+ }
346
+ function makeArray(source) {
347
+ return Array.isArray(source) ? source : isNullable(source) ? [] : [source];
348
+ }
349
+ function is(type, value) {
350
+ if (arguments.length === 1) return (value2) => is(type, value2);
351
+ return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
352
+ }
353
+ function isArrayBufferLike(value) {
354
+ return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
355
+ }
356
+ function isArrayBufferSource(value) {
357
+ return isArrayBufferLike(value) || ArrayBuffer.isView(value);
358
+ }
359
+ var Binary;
360
+ ((Binary2) => {
361
+ Binary2.is = isArrayBufferLike;
362
+ Binary2.isSource = isArrayBufferSource;
363
+ function fromSource(source) {
364
+ if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
365
+ else return source;
366
+ }
367
+ Binary2.fromSource = fromSource;
368
+ function toBase64(source) {
369
+ source = fromSource(source);
370
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
371
+ let binary = "";
372
+ const bytes = new Uint8Array(source);
373
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
374
+ return btoa(binary);
375
+ }
376
+ Binary2.toBase64 = toBase64;
377
+ function fromBase64(source) {
378
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
379
+ return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
380
+ }
381
+ Binary2.fromBase64 = fromBase64;
382
+ function toHex(source) {
383
+ source = fromSource(source);
384
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
385
+ return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
386
+ }
387
+ Binary2.toHex = toHex;
388
+ function fromHex(source) {
389
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
390
+ const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
391
+ const buffer = [];
392
+ for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
393
+ return Uint8Array.from(buffer).buffer;
394
+ }
395
+ Binary2.fromHex = fromHex;
396
+ })(Binary || (Binary = {}));
397
+ var base64ToArrayBuffer = Binary.fromBase64;
398
+ var arrayBufferToBase64 = Binary.toBase64;
399
+ var hexToArrayBuffer = Binary.fromHex;
400
+ var arrayBufferToHex = Binary.toHex;
401
+ function clone(source, refs = /* @__PURE__ */ new Map()) {
402
+ if (!source || typeof source !== "object") return source;
403
+ if (is("Date", source)) return new Date(source.valueOf());
404
+ if (is("RegExp", source)) return new RegExp(source.source, source.flags);
405
+ if (isArrayBufferLike(source)) return source.slice(0);
406
+ if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
407
+ const cached = refs.get(source);
408
+ if (cached) return cached;
409
+ if (Array.isArray(source)) {
410
+ const result2 = [];
411
+ refs.set(source, result2);
412
+ source.forEach((value, index) => {
413
+ result2[index] = Reflect.apply(clone, null, [value, refs]);
414
+ });
415
+ return result2;
416
+ }
417
+ const result = Object.create(Object.getPrototypeOf(source));
418
+ refs.set(source, result);
419
+ for (const key of Reflect.ownKeys(source)) {
420
+ const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
421
+ if ("value" in descriptor) descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
422
+ Reflect.defineProperty(result, key, descriptor);
423
+ }
424
+ return result;
425
+ }
426
+ function deepEqual(a, b, strict) {
427
+ if (a === b) return true;
428
+ if (!strict && isNullable(a) && isNullable(b)) return true;
429
+ if (typeof a !== typeof b) return false;
430
+ if (typeof a !== "object") return false;
431
+ if (!a || !b) return false;
432
+ function check(test, then) {
433
+ return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
434
+ }
435
+ return check(Array.isArray, (a2, b2) => a2.length === b2.length && a2.every((item, index) => deepEqual(item, b2[index]))) ?? check(is("Date"), (a2, b2) => a2.valueOf() === b2.valueOf()) ?? check(is("RegExp"), (a2, b2) => a2.source === b2.source && a2.flags === b2.flags) ?? check(isArrayBufferLike, (a2, b2) => {
436
+ if (a2.byteLength !== b2.byteLength) return false;
437
+ const viewA = new Uint8Array(a2);
438
+ const viewB = new Uint8Array(b2);
439
+ for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
440
+ return true;
441
+ }) ?? Object.keys({
442
+ ...a,
443
+ ...b
444
+ }).every((key) => deepEqual(a[key], b[key], strict));
445
+ }
446
+ function capitalize(source) {
447
+ return source.charAt(0).toUpperCase() + source.slice(1);
448
+ }
449
+ function uncapitalize(source) {
450
+ return source.charAt(0).toLowerCase() + source.slice(1);
451
+ }
452
+ function camelCase(source) {
453
+ return source.replace(/[_-][a-z]/g, (str) => str.slice(1).toUpperCase());
454
+ }
455
+ function tokenize(source, delimiters, delimiter) {
456
+ const output = [];
457
+ let state = 0;
458
+ for (let i = 0; i < source.length; i++) {
459
+ const code = source.charCodeAt(i);
460
+ if (code >= 65 && code <= 90) {
461
+ if (state === 1) {
462
+ const next = source.charCodeAt(i + 1);
463
+ if (next >= 97 && next <= 122) output.push(delimiter);
464
+ output.push(code + 32);
465
+ } else {
466
+ if (state !== 0) output.push(delimiter);
467
+ output.push(code + 32);
468
+ }
469
+ state = 1;
470
+ } else if (code >= 97 && code <= 122) {
471
+ output.push(code);
472
+ state = 2;
473
+ } else if (delimiters.includes(code)) {
474
+ if (state !== 0) output.push(delimiter);
475
+ state = 0;
476
+ } else output.push(code);
477
+ }
478
+ return String.fromCharCode(...output);
479
+ }
480
+ function paramCase(source) {
481
+ return tokenize(source, [45, 95], 45);
482
+ }
483
+ function snakeCase(source) {
484
+ return tokenize(source, [45, 95], 95);
485
+ }
486
+ var camelize = camelCase;
487
+ var hyphenate = paramCase;
488
+ function formatProperty(key) {
489
+ if (typeof key !== "string") return `[${key.toString()}]`;
490
+ return /^[a-z_$][\w$]*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
491
+ }
492
+ function trimSlash(source) {
493
+ return source.replace(/\/$/, "");
494
+ }
495
+ function sanitize(source) {
496
+ if (!source.startsWith("/")) source = "/" + source;
497
+ return trimSlash(source);
498
+ }
499
+ var Time;
500
+ ((Time2) => {
501
+ Time2.millisecond = 1;
502
+ Time2.second = 1e3;
503
+ Time2.minute = Time2.second * 60;
504
+ Time2.hour = Time2.minute * 60;
505
+ Time2.day = Time2.hour * 24;
506
+ Time2.week = Time2.day * 7;
507
+ let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
508
+ function setTimezoneOffset(offset) {
509
+ timezoneOffset = offset;
510
+ }
511
+ Time2.setTimezoneOffset = setTimezoneOffset;
512
+ function getTimezoneOffset() {
513
+ return timezoneOffset;
514
+ }
515
+ Time2.getTimezoneOffset = getTimezoneOffset;
516
+ function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
517
+ if (typeof date === "number") date = new Date(date);
518
+ if (offset === void 0) offset = timezoneOffset;
519
+ return Math.floor((date.valueOf() / Time2.minute - offset) / 1440);
520
+ }
521
+ Time2.getDateNumber = getDateNumber;
522
+ function fromDateNumber(value, offset) {
523
+ const date = new Date(value * Time2.day);
524
+ if (offset === void 0) offset = timezoneOffset;
525
+ return new Date(+date + offset * Time2.minute);
526
+ }
527
+ Time2.fromDateNumber = fromDateNumber;
528
+ const numeric = /\d+(?:\.\d+)?/.source;
529
+ const timeRegExp = new RegExp(`^${[
530
+ "w(?:eek(?:s)?)?",
531
+ "d(?:ay(?:s)?)?",
532
+ "h(?:our(?:s)?)?",
533
+ "m(?:in(?:ute)?(?:s)?)?",
534
+ "s(?:ec(?:ond)?(?:s)?)?"
535
+ ].map((unit) => `(${numeric}${unit})?`).join("")}$`);
536
+ function parseTime(source) {
537
+ const capture = timeRegExp.exec(source);
538
+ if (!capture) return 0;
539
+ return (parseFloat(capture[1]) * Time2.week || 0) + (parseFloat(capture[2]) * Time2.day || 0) + (parseFloat(capture[3]) * Time2.hour || 0) + (parseFloat(capture[4]) * Time2.minute || 0) + (parseFloat(capture[5]) * Time2.second || 0);
540
+ }
541
+ Time2.parseTime = parseTime;
542
+ function parseDate(date) {
543
+ const parsed = parseTime(date);
544
+ if (parsed) date = Date.now() + parsed;
545
+ else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
546
+ else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
547
+ return date ? new Date(date) : /* @__PURE__ */ new Date();
548
+ }
549
+ Time2.parseDate = parseDate;
550
+ function format(ms) {
551
+ const abs = Math.abs(ms);
552
+ if (abs >= Time2.day - Time2.hour / 2) return Math.round(ms / Time2.day) + "d";
553
+ else if (abs >= Time2.hour - Time2.minute / 2) return Math.round(ms / Time2.hour) + "h";
554
+ else if (abs >= Time2.minute - Time2.second / 2) return Math.round(ms / Time2.minute) + "m";
555
+ else if (abs >= Time2.second) return Math.round(ms / Time2.second) + "s";
556
+ return ms + "ms";
557
+ }
558
+ Time2.format = format;
559
+ function toDigits(source, length = 2) {
560
+ return source.toString().padStart(length, "0");
561
+ }
562
+ Time2.toDigits = toDigits;
563
+ function template(template2, time = /* @__PURE__ */ new Date()) {
564
+ return template2.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
565
+ }
566
+ Time2.template = template;
567
+ })(Time || (Time = {}));
568
+ 0 && (module.exports = {
569
+ Binary,
570
+ Time,
571
+ arrayBufferToBase64,
572
+ arrayBufferToHex,
573
+ base64ToArrayBuffer,
574
+ camelCase,
575
+ camelize,
576
+ capitalize,
577
+ clone,
578
+ contain,
579
+ deduplicate,
580
+ deepEqual,
581
+ defineProperty,
582
+ difference,
583
+ filterKeys,
584
+ formatProperty,
585
+ hexToArrayBuffer,
586
+ hyphenate,
587
+ intersection,
588
+ is,
589
+ isNonNullable,
590
+ isNullable,
591
+ isPlainObject,
592
+ makeArray,
593
+ mapValues,
594
+ noop,
595
+ omit,
596
+ paramCase,
597
+ pick,
598
+ remove,
599
+ sanitize,
600
+ snakeCase,
601
+ trimSlash,
602
+ uncapitalize,
603
+ union,
604
+ valueMap
605
+ });
606
+ }));
607
+ //#endregion
608
+ //#region src/host/gate.ts
609
+ var import_lib = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
610
+ var __defProp = Object.defineProperty;
611
+ var __name = (target, value) => __defProp(target, "name", {
612
+ value,
613
+ configurable: true
614
+ });
615
+ var import_cosmokit = require_lib$1();
616
+ var kSchema = Symbol.for("schemastery");
617
+ var kValidationError = Symbol.for("ValidationError");
618
+ globalThis.__schemastery_index__ ??= 0;
619
+ globalThis.__schemastery_refs__ = void 0;
620
+ var ValidationError = class extends TypeError {
621
+ constructor(message, options) {
622
+ let prefix = "$";
623
+ for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
624
+ else if (typeof segment === "number") prefix += "[" + segment + "]";
625
+ else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
626
+ if (prefix.startsWith(".")) prefix = prefix.slice(1);
627
+ super((prefix === "$" ? "" : `${prefix} `) + message);
628
+ this.options = options;
629
+ }
630
+ static {
631
+ __name(this, "ValidationError");
632
+ }
633
+ name = "ValidationError";
634
+ static is(error) {
635
+ return !!error?.[kValidationError];
636
+ }
637
+ };
638
+ Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
639
+ var Schema = /* @__PURE__ */ __name(function(options) {
640
+ const schema = /* @__PURE__ */ __name(function(data, options2 = {}) {
641
+ return Schema.resolve(data, schema, options2)[0];
642
+ }, "schema");
643
+ if (options.refs) {
644
+ const refs = (0, import_cosmokit.valueMap)(options.refs, (options2) => new Schema(options2));
645
+ const getRef = /* @__PURE__ */ __name((uid) => refs[uid], "getRef");
646
+ for (const key in refs) {
647
+ const options2 = refs[key];
648
+ options2.sKey = getRef(options2.sKey);
649
+ options2.inner = getRef(options2.inner);
650
+ options2.list = options2.list && options2.list.map(getRef);
651
+ options2.dict = options2.dict && (0, import_cosmokit.valueMap)(options2.dict, getRef);
652
+ }
653
+ return refs[options.uid];
654
+ }
655
+ Object.assign(schema, options);
656
+ if (typeof schema.callback === "string") try {
657
+ schema.callback = new Function("return " + schema.callback)();
658
+ } catch {}
659
+ Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
660
+ Object.setPrototypeOf(schema, Schema.prototype);
661
+ schema.meta ||= {};
662
+ schema.toString = schema.toString.bind(schema);
663
+ return schema;
664
+ }, "Schema");
665
+ Schema.prototype = Object.create(Function.prototype);
666
+ Schema.prototype[kSchema] = true;
667
+ Object.defineProperty(Schema.prototype, "~standard", { get() {
668
+ return {
669
+ version: 1,
670
+ vendor: "schemastery",
671
+ validate: /* @__PURE__ */ __name((value) => {
672
+ try {
673
+ return { value: Schema.resolve(value, this, {})[0] };
674
+ } catch (error) {
675
+ if (ValidationError.is(error)) return { issues: [{
676
+ message: error.message,
677
+ path: error.options.path
678
+ }] };
679
+ throw error;
680
+ }
681
+ }, "validate")
682
+ };
683
+ } });
684
+ Schema.ValidationError = ValidationError;
685
+ Schema.prototype.toJSON = /* @__PURE__ */ __name(function toJSON() {
686
+ if (globalThis.__schemastery_refs__) {
687
+ globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
688
+ return this.uid;
689
+ }
690
+ globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
691
+ globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
692
+ const result = {
693
+ uid: this.uid,
694
+ refs: globalThis.__schemastery_refs__
695
+ };
696
+ globalThis.__schemastery_refs__ = void 0;
697
+ return result;
698
+ }, "toJSON");
699
+ Schema.prototype.set = /* @__PURE__ */ __name(function set(key, value) {
700
+ this.dict[key] = value;
701
+ return this;
702
+ }, "set");
703
+ Schema.prototype.push = /* @__PURE__ */ __name(function push(value) {
704
+ this.list.push(value);
705
+ return this;
706
+ }, "push");
707
+ function mergeDesc(original, messages) {
708
+ const result = typeof original === "string" ? { "": original } : { ...original };
709
+ for (const locale in messages) {
710
+ const value = messages[locale];
711
+ if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
712
+ else if (typeof value === "string") result[locale] = value;
713
+ }
714
+ return result;
715
+ }
716
+ __name(mergeDesc, "mergeDesc");
717
+ function getInner(value) {
718
+ return value?.$value ?? value?.$inner;
719
+ }
720
+ __name(getInner, "getInner");
721
+ function extractKeys(data) {
722
+ return (0, import_cosmokit.filterKeys)(data ?? {}, (key) => !key.startsWith("$"));
723
+ }
724
+ __name(extractKeys, "extractKeys");
725
+ Schema.prototype.i18n = /* @__PURE__ */ __name(function i18n(messages) {
726
+ const schema = Schema(this);
727
+ const desc = mergeDesc(schema.meta.description, messages);
728
+ if (Object.keys(desc).length) schema.meta.description = desc;
729
+ if (schema.dict) schema.dict = (0, import_cosmokit.valueMap)(schema.dict, (inner, key) => {
730
+ return inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
731
+ });
732
+ if (schema.list) schema.list = schema.list.map((inner, index) => {
733
+ return inner.i18n((0, import_cosmokit.valueMap)(messages, (data = {}) => {
734
+ if (Array.isArray(getInner(data))) return getInner(data)[index];
735
+ if (Array.isArray(data)) return data[index];
736
+ return extractKeys(data);
737
+ }));
738
+ });
739
+ if (schema.inner) schema.inner = schema.inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => {
740
+ if (getInner(data)) return getInner(data);
741
+ return extractKeys(data);
742
+ }));
743
+ if (schema.sKey) schema.sKey = schema.sKey.i18n((0, import_cosmokit.valueMap)(messages, (data) => data?.$key));
744
+ return schema;
745
+ }, "i18n");
746
+ Schema.prototype.extra = /* @__PURE__ */ __name(function extra(key, value) {
747
+ const schema = Schema(this);
748
+ schema.meta = {
749
+ ...schema.meta,
750
+ [key]: value
751
+ };
752
+ return schema;
753
+ }, "extra");
754
+ for (const key of [
755
+ "required",
756
+ "disabled",
757
+ "collapse",
758
+ "hidden",
759
+ "loose"
760
+ ]) Object.assign(Schema.prototype, { [key](value = true) {
761
+ const schema = Schema(this);
762
+ schema.meta = {
763
+ ...schema.meta,
764
+ [key]: value
765
+ };
766
+ return schema;
767
+ } });
768
+ Schema.prototype.deprecated = /* @__PURE__ */ __name(function deprecated() {
769
+ const schema = Schema(this);
770
+ schema.meta.badges ||= [];
771
+ schema.meta.badges.push({
772
+ text: "deprecated",
773
+ type: "danger"
774
+ });
775
+ return schema;
776
+ }, "deprecated");
777
+ Schema.prototype.experimental = /* @__PURE__ */ __name(function experimental() {
778
+ const schema = Schema(this);
779
+ schema.meta.badges ||= [];
780
+ schema.meta.badges.push({
781
+ text: "experimental",
782
+ type: "warning"
783
+ });
784
+ return schema;
785
+ }, "experimental");
786
+ Schema.prototype.pattern = /* @__PURE__ */ __name(function pattern(regexp) {
787
+ const schema = Schema(this);
788
+ const pattern2 = (0, import_cosmokit.pick)(regexp, ["source", "flags"]);
789
+ schema.meta = {
790
+ ...schema.meta,
791
+ pattern: pattern2
792
+ };
793
+ return schema;
794
+ }, "pattern");
795
+ Schema.prototype.simplify = /* @__PURE__ */ __name(function simplify(value) {
796
+ if ((0, import_cosmokit.deepEqual)(value, this.meta.default, this.type === "dict")) return null;
797
+ if ((0, import_cosmokit.isNullable)(value)) return value;
798
+ if (this.type === "object" || this.type === "dict") {
799
+ const result = {};
800
+ for (const key in value) {
801
+ const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
802
+ if (this.type === "dict" || !(0, import_cosmokit.isNullable)(item)) result[key] = item;
803
+ }
804
+ if ((0, import_cosmokit.deepEqual)(result, this.meta.default, this.type === "dict")) return null;
805
+ return result;
806
+ } else if (this.type === "array" || this.type === "tuple") {
807
+ const result = [];
808
+ value.forEach((value2, index) => {
809
+ const schema = this.type === "array" ? this.inner : this.list[index];
810
+ const item = schema ? schema.simplify(value2) : value2;
811
+ result.push(item);
812
+ });
813
+ return result;
814
+ } else if (this.type === "intersect") {
815
+ const result = {};
816
+ for (const item of this.list) Object.assign(result, item.simplify(value));
817
+ return result;
818
+ } else if (this.type === "union") for (const schema of this.list) try {
819
+ Schema.resolve(value, schema, {});
820
+ return schema.simplify(value);
821
+ } catch {}
822
+ return value;
823
+ }, "simplify");
824
+ Schema.prototype.toString = /* @__PURE__ */ __name(function toString(inline) {
825
+ return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
826
+ }, "toString");
827
+ Schema.prototype.role = /* @__PURE__ */ __name(function role(role, extra2) {
828
+ const schema = Schema(this);
829
+ schema.meta = {
830
+ ...schema.meta,
831
+ role,
832
+ extra: extra2
833
+ };
834
+ return schema;
835
+ }, "role");
836
+ for (const key of [
837
+ "default",
838
+ "link",
839
+ "comment",
840
+ "description",
841
+ "max",
842
+ "min",
843
+ "step"
844
+ ]) Object.assign(Schema.prototype, { [key](value) {
845
+ const schema = Schema(this);
846
+ schema.meta = {
847
+ ...schema.meta,
848
+ [key]: value
849
+ };
850
+ return schema;
851
+ } });
852
+ var resolvers = {};
853
+ Schema.extend = /* @__PURE__ */ __name(function extend(type, resolve2) {
854
+ resolvers[type] = resolve2;
855
+ }, "extend");
856
+ Schema.resolve = /* @__PURE__ */ __name(function resolve(data, schema, options = {}, strict = false) {
857
+ if (!schema) return [data];
858
+ if (options.ignore?.(data, schema)) return [data];
859
+ if ((0, import_cosmokit.isNullable)(data) && schema.type !== "lazy") {
860
+ if (schema.meta.required) throw new ValidationError(`missing required value`, options);
861
+ let current = schema;
862
+ let fallback = schema.meta.default;
863
+ while (current?.type === "intersect" && (0, import_cosmokit.isNullable)(fallback)) {
864
+ current = current.list[0];
865
+ fallback = current?.meta.default;
866
+ }
867
+ if ((0, import_cosmokit.isNullable)(fallback)) return [data];
868
+ data = (0, import_cosmokit.clone)(fallback);
869
+ }
870
+ const callback = resolvers[schema.type];
871
+ if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
872
+ try {
873
+ return callback(data, schema, options, strict);
874
+ } catch (error) {
875
+ if (!schema.meta.loose) throw error;
876
+ return [schema.meta.default];
877
+ }
878
+ }, "resolve");
879
+ Schema.from = /* @__PURE__ */ __name(function from(source) {
880
+ if ((0, import_cosmokit.isNullable)(source)) return Schema.any();
881
+ else if ([
882
+ "string",
883
+ "number",
884
+ "boolean"
885
+ ].includes(typeof source)) return Schema.const(source).required();
886
+ else if (source[kSchema]) return source;
887
+ else if (typeof source === "function") switch (source) {
888
+ case String: return Schema.string().required();
889
+ case Number: return Schema.number().required();
890
+ case Boolean: return Schema.boolean().required();
891
+ case Function: return Schema.function().required();
892
+ default: return Schema.is(source).required();
893
+ }
894
+ else throw new TypeError(`cannot infer schema from ${source}`);
895
+ }, "from");
896
+ Schema.lazy = /* @__PURE__ */ __name(function lazy(builder) {
897
+ const schema = new Schema({
898
+ type: "lazy",
899
+ builder,
900
+ inner: { toJSON: /* @__PURE__ */ __name(() => {
901
+ if (!schema.inner[kSchema]) {
902
+ schema.inner = schema.builder();
903
+ schema.inner.meta = {
904
+ ...schema.meta,
905
+ ...schema.inner.meta
906
+ };
907
+ }
908
+ return schema.inner.toJSON();
909
+ }, "toJSON") }
910
+ });
911
+ return schema;
912
+ }, "lazy");
913
+ Schema.natural = /* @__PURE__ */ __name(function natural() {
914
+ return Schema.number().step(1).min(0);
915
+ }, "natural");
916
+ Schema.percent = /* @__PURE__ */ __name(function percent() {
917
+ return Schema.number().step(.01).min(0).max(1).role("slider");
918
+ }, "percent");
919
+ Schema.date = /* @__PURE__ */ __name(function date() {
920
+ return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
921
+ const date2 = new Date(value);
922
+ if (isNaN(+date2)) throw new ValidationError(`invalid date "${value}"`, options);
923
+ return date2;
924
+ }, true)]);
925
+ }, "date");
926
+ Schema.regExp = /* @__PURE__ */ __name(function regExp(flag = "") {
927
+ return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
928
+ try {
929
+ return new RegExp(value, flag);
930
+ } catch (e) {
931
+ throw new ValidationError(e.message, options);
932
+ }
933
+ }, true)]);
934
+ }, "regExp");
935
+ Schema.arrayBuffer = /* @__PURE__ */ __name(function arrayBuffer(encoding) {
936
+ return Schema.union([
937
+ Schema.is(ArrayBuffer),
938
+ Schema.is(SharedArrayBuffer),
939
+ Schema.transform(Schema.any(), (value, options) => {
940
+ if (import_cosmokit.Binary.isSource(value)) return import_cosmokit.Binary.fromSource(value);
941
+ throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
942
+ }, true),
943
+ ...encoding ? [Schema.transform(Schema.string(), (value, options) => {
944
+ try {
945
+ return encoding === "base64" ? import_cosmokit.Binary.fromBase64(value) : import_cosmokit.Binary.fromHex(value);
946
+ } catch (e) {
947
+ throw new ValidationError(e.message, options);
948
+ }
949
+ }, true)] : []
950
+ ]);
951
+ }, "arrayBuffer");
952
+ Schema.extend("lazy", (data, schema, options, strict) => {
953
+ if (!schema.inner[kSchema]) {
954
+ schema.inner = schema.builder();
955
+ schema.inner.meta = {
956
+ ...schema.meta,
957
+ ...schema.inner.meta
958
+ };
959
+ }
960
+ return Schema.resolve(data, schema.inner, options, strict);
961
+ });
962
+ Schema.extend("any", (data) => {
963
+ return [data];
964
+ });
965
+ Schema.extend("never", (data, _, options) => {
966
+ throw new ValidationError(`expected nullable but got ${data}`, options);
967
+ });
968
+ Schema.extend("const", (data, { value }, options) => {
969
+ if ((0, import_cosmokit.deepEqual)(data, value)) return [value];
970
+ throw new ValidationError(`expected ${value} but got ${data}`, options);
971
+ });
972
+ function checkWithinRange(data, meta, description, options, skipMin = false) {
973
+ const { max = Infinity, min = -Infinity } = meta;
974
+ if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
975
+ if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
976
+ }
977
+ __name(checkWithinRange, "checkWithinRange");
978
+ Schema.extend("string", (data, { meta }, options) => {
979
+ if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
980
+ if (meta.pattern) {
981
+ const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
982
+ if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
983
+ }
984
+ checkWithinRange(data.length, meta, "string length", options);
985
+ return [data];
986
+ });
987
+ function decimalShift(data, digits) {
988
+ const str = data.toString();
989
+ if (str.includes("e")) return data * Math.pow(10, digits);
990
+ const index = str.indexOf(".");
991
+ if (index === -1) return data * Math.pow(10, digits);
992
+ const frac = str.slice(index + 1);
993
+ const integer = str.slice(0, index);
994
+ if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
995
+ return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
996
+ }
997
+ __name(decimalShift, "decimalShift");
998
+ function isMultipleOf(data, min, step) {
999
+ step = Math.abs(step);
1000
+ if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
1001
+ const index = step.toString().indexOf(".");
1002
+ const digits = step.toString().slice(index + 1).length;
1003
+ return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
1004
+ }
1005
+ __name(isMultipleOf, "isMultipleOf");
1006
+ Schema.extend("number", (data, { meta }, options) => {
1007
+ if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
1008
+ checkWithinRange(data, meta, "number", options);
1009
+ const { step } = meta;
1010
+ if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
1011
+ return [data];
1012
+ });
1013
+ Schema.extend("boolean", (data, _, options) => {
1014
+ if (typeof data === "boolean") return [data];
1015
+ throw new ValidationError(`expected boolean but got ${data}`, options);
1016
+ });
1017
+ Schema.extend("bitset", (data, { bits, meta }, options) => {
1018
+ let value = 0, keys = [];
1019
+ if (typeof data === "number") {
1020
+ value = data;
1021
+ for (const key in bits) if (data & bits[key]) keys.push(key);
1022
+ } else if (Array.isArray(data)) {
1023
+ keys = data;
1024
+ for (const key of keys) {
1025
+ if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
1026
+ if (key in bits) value |= bits[key];
1027
+ }
1028
+ } else throw new ValidationError(`expected number or array but got ${data}`, options);
1029
+ if (value === meta.default) return [value];
1030
+ return [value, keys];
1031
+ });
1032
+ Schema.extend("function", (data, _, options) => {
1033
+ if (typeof data === "function") return [data];
1034
+ throw new ValidationError(`expected function but got ${data}`, options);
1035
+ });
1036
+ Schema.extend("is", (data, { constructor }, options) => {
1037
+ if (typeof constructor === "function") {
1038
+ if (data instanceof constructor) return [data];
1039
+ throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
1040
+ } else {
1041
+ if ((0, import_cosmokit.isNullable)(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
1042
+ let prototype = Object.getPrototypeOf(data);
1043
+ while (prototype) {
1044
+ if (prototype.constructor?.name === constructor) return [data];
1045
+ prototype = Object.getPrototypeOf(prototype);
1046
+ }
1047
+ throw new ValidationError(`expected ${constructor} but got ${data}`, options);
1048
+ }
1049
+ });
1050
+ function property(data, key, schema, options) {
1051
+ try {
1052
+ const [value, adapted] = Schema.resolve(data[key], schema, {
1053
+ ...options,
1054
+ path: [...options.path || [], key]
1055
+ });
1056
+ if (adapted !== void 0) data[key] = adapted;
1057
+ return value;
1058
+ } catch (e) {
1059
+ if (!options?.autofix) throw e;
1060
+ delete data[key];
1061
+ return schema.meta.default;
1062
+ }
1063
+ }
1064
+ __name(property, "property");
1065
+ Schema.extend("array", (data, { inner, meta }, options) => {
1066
+ if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
1067
+ checkWithinRange(data.length, meta, "array length", options, !(0, import_cosmokit.isNullable)(inner.meta.default));
1068
+ return [data.map((_, index) => property(data, index, inner, options))];
1069
+ });
1070
+ Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
1071
+ if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
1072
+ const result = {};
1073
+ for (const key in data) {
1074
+ let rKey;
1075
+ try {
1076
+ rKey = Schema.resolve(key, sKey, options)[0];
1077
+ } catch (error) {
1078
+ if (strict) continue;
1079
+ throw error;
1080
+ }
1081
+ result[rKey] = property(data, key, inner, options);
1082
+ data[rKey] = data[key];
1083
+ if (key !== rKey) delete data[key];
1084
+ }
1085
+ return [result];
1086
+ });
1087
+ Schema.extend("tuple", (data, { list }, options, strict) => {
1088
+ if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
1089
+ const result = list.map((inner, index) => property(data, index, inner, options));
1090
+ if (strict) return [result];
1091
+ result.push(...data.slice(list.length));
1092
+ return [result];
1093
+ });
1094
+ function merge(result, data) {
1095
+ for (const key in data) {
1096
+ if (key in result) continue;
1097
+ result[key] = data[key];
1098
+ }
1099
+ }
1100
+ __name(merge, "merge");
1101
+ Schema.extend("object", (data, { dict }, options, strict) => {
1102
+ if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
1103
+ const result = {};
1104
+ for (const key in dict) {
1105
+ const value = property(data, key, dict[key], options);
1106
+ if (!(0, import_cosmokit.isNullable)(value) || key in data) result[key] = value;
1107
+ }
1108
+ if (!strict) merge(result, data);
1109
+ return [result];
1110
+ });
1111
+ Schema.extend("union", (data, { list, toString: toString2 }, options, strict) => {
1112
+ const messages = [];
1113
+ for (const inner of list) try {
1114
+ return Schema.resolve(data, inner, options, strict);
1115
+ } catch (error) {
1116
+ messages.push(error);
1117
+ }
1118
+ throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
1119
+ });
1120
+ Schema.extend("intersect", (data, { list, toString: toString2 }, options, strict) => {
1121
+ if (!list.length) return [data];
1122
+ let result;
1123
+ for (const inner of list) {
1124
+ const value = Schema.resolve(data, inner, options, true)[0];
1125
+ if ((0, import_cosmokit.isNullable)(value)) continue;
1126
+ if ((0, import_cosmokit.isNullable)(result)) result = value;
1127
+ else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
1128
+ else if (typeof value === "object") merge(result ??= {}, value);
1129
+ else if (result !== value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
1130
+ }
1131
+ if (!strict && (0, import_cosmokit.isPlainObject)(data)) merge(result, data);
1132
+ return [result];
1133
+ });
1134
+ Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
1135
+ const [result, adapted = data] = Schema.resolve(data, inner, options, true);
1136
+ if (preserve) return [callback(result)];
1137
+ else return [callback(result), callback(adapted)];
1138
+ });
1139
+ var formatters = {};
1140
+ function defineMethod(name, keys, format) {
1141
+ formatters[name] = format;
1142
+ Object.assign(Schema, { [name](...args) {
1143
+ const schema = new Schema({ type: name });
1144
+ keys.forEach((key, index) => {
1145
+ switch (key) {
1146
+ case "sKey":
1147
+ schema.sKey = args[index] ?? Schema.string();
1148
+ break;
1149
+ case "inner":
1150
+ schema.inner = Schema.from(args[index]);
1151
+ break;
1152
+ case "list":
1153
+ schema.list = args[index].map(Schema.from);
1154
+ break;
1155
+ case "dict":
1156
+ schema.dict = (0, import_cosmokit.valueMap)(args[index], Schema.from);
1157
+ break;
1158
+ case "bits":
1159
+ schema.bits = {};
1160
+ for (const key2 in args[index]) {
1161
+ if (typeof args[index][key2] !== "number") continue;
1162
+ schema.bits[key2] = args[index][key2];
1163
+ }
1164
+ break;
1165
+ case "callback": {
1166
+ const callback = schema.callback = args[index];
1167
+ callback["toJSON"] ||= () => callback.toString();
1168
+ break;
1169
+ }
1170
+ case "constructor": {
1171
+ const constructor = schema.constructor = args[index];
1172
+ if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
1173
+ break;
1174
+ }
1175
+ default: schema[key] = args[index];
1176
+ }
1177
+ });
1178
+ if (name === "object" || name === "dict") schema.meta.default = {};
1179
+ else if (name === "array" || name === "tuple") schema.meta.default = [];
1180
+ else if (name === "bitset") schema.meta.default = 0;
1181
+ return schema;
1182
+ } });
1183
+ }
1184
+ __name(defineMethod, "defineMethod");
1185
+ defineMethod("is", ["constructor"], ({ constructor }) => {
1186
+ if (typeof constructor === "function") return constructor.name;
1187
+ else return constructor;
1188
+ });
1189
+ defineMethod("any", [], () => "any");
1190
+ defineMethod("never", [], () => "never");
1191
+ defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
1192
+ defineMethod("string", [], () => "string");
1193
+ defineMethod("number", [], () => "number");
1194
+ defineMethod("boolean", [], () => "boolean");
1195
+ defineMethod("bitset", ["bits"], () => "bitset");
1196
+ defineMethod("function", [], () => "function");
1197
+ defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
1198
+ defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
1199
+ defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
1200
+ defineMethod("object", ["dict"], ({ dict }) => {
1201
+ if (Object.keys(dict).length === 0) return "{}";
1202
+ return `{ ${Object.entries(dict).map(([key, inner]) => {
1203
+ return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
1204
+ }).join(", ")} }`;
1205
+ });
1206
+ defineMethod("union", ["list"], ({ list }, inline) => {
1207
+ const result = list.map(({ toString: format }) => format()).join(" | ");
1208
+ return inline ? `(${result})` : result;
1209
+ });
1210
+ defineMethod("intersect", ["list"], ({ list }) => {
1211
+ return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
1212
+ });
1213
+ defineMethod("transform", [
1214
+ "inner",
1215
+ "callback",
1216
+ "preserve"
1217
+ ], ({ inner }, isInner) => inner.toString(isInner));
1218
+ module.exports = Schema;
1219
+ })))(), 1);
1220
+ /**
1221
+ * Workspace gate for the /filemgr routes: canonicalize the requested
1222
+ * project root and require it to be a registered workspace (or a directory
1223
+ * inside one). This is the security boundary of the panel's fs/git routes -
1224
+ * the browser may only read and mutate files under registered workspace
1225
+ * roots, never arbitrary host directories.
1226
+ * @module dsh-filemgr/host/gate
1227
+ */
1228
+ /**
1229
+ * Normalize a path for prefix comparison: collapse Windows separators to `/`
1230
+ * and drop any trailing slash. On win32 the whole path is also lower-cased so
1231
+ * a case-insensitive FS cannot trip the membership check (the drive letter and
1232
+ * every segment are compared case-insensitively). On any other platform the
1233
+ * path separator and case are left untouched.
1234
+ */
1235
+ function normalizeForPrefix(value) {
1236
+ const normalized = value.replaceAll("\\", "/").replace(/\/+$/, "");
1237
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
1238
+ }
1239
+ /**
1240
+ * The canonical prefix check: child must live inside (or equal) the root.
1241
+ * Separator- and case-robust on Windows: `path.join` yields backslashes while
1242
+ * git (`rev-parse --show-toplevel`) and the browser (`./x`) yield forward
1243
+ * slashes, so both sides are normalized to forward slashes before comparing,
1244
+ * and the comparison is case-insensitive on win32 (the FS is case-insensitive).
1245
+ */
1246
+ function isPathInside(root, child) {
1247
+ if (root === "" || child === "") return false;
1248
+ const normRoot = normalizeForPrefix(root);
1249
+ const normChild = normalizeForPrefix(child);
1250
+ if (normChild === normRoot) return true;
1251
+ return normChild.startsWith(`${normRoot}/`);
1252
+ }
1253
+ /**
1254
+ * Production gate: canonicalize the requested root and require it to be a
1255
+ * registered workspace path (or a subdirectory of one). The host's workspace
1256
+ * registry owns canonicalization, so an unowned path is rejected outright.
1257
+ * @param ctx - context carrying the workspace service.
1258
+ * @returns the gate.
1259
+ */
1260
+ function createWorkspaceGate(ctx) {
1261
+ return async (root) => {
1262
+ if (typeof root !== "string" || root === "") return {
1263
+ ok: false,
1264
+ error: {
1265
+ code: "workspace-unknown",
1266
+ message: "empty project root"
1267
+ }
1268
+ };
1269
+ let canonical;
1270
+ try {
1271
+ canonical = await realpath(root);
1272
+ } catch {
1273
+ return {
1274
+ ok: false,
1275
+ error: {
1276
+ code: "workspace-unknown",
1277
+ message: "path does not resolve on disk"
1278
+ }
1279
+ };
1280
+ }
1281
+ const workspaces = ctx.workspaceRegistry.list();
1282
+ for (const workspace of workspaces) if (isPathInside(workspace.path, canonical)) return {
1283
+ ok: true,
1284
+ canonical
1285
+ };
1286
+ return {
1287
+ ok: false,
1288
+ error: {
1289
+ code: "workspace-unknown",
1290
+ message: "path is not inside a registered workspace"
1291
+ }
1292
+ };
1293
+ };
1294
+ }
1295
+ //#endregion
1296
+ //#region src/host/fs-service.ts
1297
+ /**
1298
+ * Host filesystem service for the panel: directory listing, file read with a
1299
+ * preview ceiling, text write with an mtime conflict check, filename search
1300
+ * with directory pruning, and delete (untracked discard). Every operation
1301
+ * resolves against a gated project root and refuses to escape it (path
1302
+ * traversal guard). Text is decoded utf-8; images come back as data URLs
1303
+ * (capped) so the browser renders them without extra round trips.
1304
+ *
1305
+ * Change delivery is intentionally NOT watch-based: the panel refreshes on
1306
+ * user actions (list/read/write) and the SCM tab polls git status on its own
1307
+ * schedule. A recursive `fs.watch` over the workspace root turned out to be
1308
+ * a measurable CPU/IO cost on large trees (a 70k-file workspace pushed the
1309
+ * host process to ~70% CPU during write bursts) for near-zero visible
1310
+ * benefit — content edits never auto-reloaded the preview anyway (only a
1311
+ * "updated" badge), so the always-on watcher was removed.
1312
+ * @module dsh-filemgr/host/fs-service
1313
+ */
1314
+ /** Preview text ceiling — mirrors FileManager's single-tab 80k-char cap. */
1315
+ const TEXT_CAP_CHARS = 8e4;
1316
+ /** Image read cap (data URL payload budget). */
1317
+ const IMAGE_CAP_BYTES = 8 << 20;
1318
+ /** Filename-search caps (results and scanned entries). */
1319
+ const SEARCH_HIT_CAP = 200;
1320
+ const SEARCH_SCAN_CAP = 2e4;
1321
+ /** Directories skipped by search (VS Code-like noise reduction). */
1322
+ const SEARCH_SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
1323
+ /** Directories never listed in the tree. */
1324
+ const TREE_SKIP_DIRS = /* @__PURE__ */ new Set([".git"]);
1325
+ /**
1326
+ * Listing cap: a giant flat dir (node_modules top level) must not choke the
1327
+ * tree or the @ mention menu. The tail is dropped (no paging) — the explorer
1328
+ * tree stays usable and the truncated flag lets the UI hint at the cutoff.
1329
+ */
1330
+ const MAX_ENTRIES = 1e3;
1331
+ /**
1332
+ * Resolve a relative path against the canonical root, realpath-checking the
1333
+ * existing ancestors so a symlink cannot smuggle the operation outside the
1334
+ * root. A path that does not yet exist (ENOENT) is verified through its
1335
+ * nearest existing ancestor — a nonexistent tail cannot itself be a symlink.
1336
+ * A path whose real path escapes the root is rejected with path-outside-root.
1337
+ */
1338
+ async function resolveInsideRoot(root, rel) {
1339
+ if (rel.includes("\0")) return {
1340
+ ok: false,
1341
+ error: {
1342
+ code: "path-outside-root",
1343
+ message: "invalid path"
1344
+ }
1345
+ };
1346
+ const abs = join(root, rel);
1347
+ if (!isPathInside(root, abs)) return {
1348
+ ok: false,
1349
+ error: {
1350
+ code: "path-outside-root",
1351
+ message: `path escapes root: ${rel}`
1352
+ }
1353
+ };
1354
+ let probe = abs;
1355
+ for (let hop = 0; hop < 32; hop += 1) {
1356
+ let real;
1357
+ try {
1358
+ real = await realpath(probe);
1359
+ } catch (error) {
1360
+ if (error.code !== "ENOENT") return {
1361
+ ok: true,
1362
+ abs
1363
+ };
1364
+ const parent = dirname(probe);
1365
+ if (parent === probe) return {
1366
+ ok: true,
1367
+ abs
1368
+ };
1369
+ probe = parent;
1370
+ continue;
1371
+ }
1372
+ if (!isPathInside(root, real)) return {
1373
+ ok: false,
1374
+ error: {
1375
+ code: "path-outside-root",
1376
+ message: `path resolves outside root: ${rel}`
1377
+ }
1378
+ };
1379
+ return {
1380
+ ok: true,
1381
+ abs
1382
+ };
1383
+ }
1384
+ return {
1385
+ ok: false,
1386
+ error: {
1387
+ code: "path-outside-root",
1388
+ message: `path cannot be resolved: ${rel}`
1389
+ }
1390
+ };
1391
+ }
1392
+ /** True when the relative path is, or passes through, a .git component. */
1393
+ function isGitPath(rel) {
1394
+ return rel.split("/").some((part) => part.toLowerCase() === ".git");
1395
+ }
1396
+ /** Case-insensitive alpha compare (dirs first, then files). */
1397
+ function compareEntries(a, b) {
1398
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1399
+ const an = a.name.toLowerCase();
1400
+ const bn = b.name.toLowerCase();
1401
+ return an < bn ? -1 : an > bn ? 1 : 0;
1402
+ }
1403
+ /** The image probe: parse PNG/JPEG/GIF/WebP header dimensions (undefined on failure). */
1404
+ function probeImageSize(data) {
1405
+ try {
1406
+ if (data.length >= 24 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return {
1407
+ width: data.readUInt32BE(16),
1408
+ height: data.readUInt32BE(20)
1409
+ };
1410
+ if (data.length >= 10 && data[0] === 255 && data[1] === 216 && data[2] === 255) {
1411
+ let pos = 2;
1412
+ for (let segment = 0; segment < 16; segment += 1) {
1413
+ if (pos + 2 > data.length) return void 0;
1414
+ if (data[pos] !== 255) return void 0;
1415
+ while (pos < data.length && data[pos] === 255) pos += 1;
1416
+ if (pos >= data.length) return void 0;
1417
+ const marker = data[pos];
1418
+ pos += 1;
1419
+ if (marker === 1 || marker >= 208 && marker <= 215 || marker === 216) continue;
1420
+ if (marker === 192 || marker === 193 || marker === 194 || marker === 195 || marker === 197 || marker === 198 || marker === 199 || marker === 201 || marker === 202 || marker === 203 || marker === 205 || marker === 206 || marker === 207) {
1421
+ if (pos + 7 > data.length) return void 0;
1422
+ return {
1423
+ height: data.readUInt16BE(pos + 3),
1424
+ width: data.readUInt16BE(pos + 5)
1425
+ };
1426
+ }
1427
+ if (pos + 2 > data.length) return void 0;
1428
+ const length = data.readUInt16BE(pos);
1429
+ pos += length;
1430
+ if (pos < 0) return void 0;
1431
+ }
1432
+ return;
1433
+ }
1434
+ if (data.length >= 14 && data[0] === 71 && data[1] === 73 && data[2] === 70) return {
1435
+ width: data.readUInt16LE(6),
1436
+ height: data.readUInt16LE(8)
1437
+ };
1438
+ if (data.length >= 30 && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80 && data[12] === 86 && data[13] === 80 && data[14] === 56 && data[15] === 88) {
1439
+ const size = (o) => data[o] | data[o + 1] << 8 | data[o + 2] << 16;
1440
+ return {
1441
+ width: size(24) + 1,
1442
+ height: size(27) + 1
1443
+ };
1444
+ }
1445
+ } catch {
1446
+ return;
1447
+ }
1448
+ }
1449
+ /** Mime lookup by file extension (undefined when the extension is unknown). */
1450
+ function mimeByExtension(rel) {
1451
+ return {
1452
+ png: "image/png",
1453
+ jpg: "image/jpeg",
1454
+ jpeg: "image/jpeg",
1455
+ gif: "image/gif",
1456
+ webp: "image/webp",
1457
+ svg: "image/svg+xml",
1458
+ ico: "image/x-icon",
1459
+ avif: "image/avif",
1460
+ bmp: "image/bmp",
1461
+ pdf: "application/pdf"
1462
+ }[rel.split(".").pop()?.toLowerCase() ?? ""];
1463
+ }
1464
+ /** Derive the mime type for a raw read from the extension, then the content. */
1465
+ function imageMime(rel, data) {
1466
+ const byExt = mimeByExtension(rel);
1467
+ if (byExt !== void 0) return byExt;
1468
+ if (data.length >= 3 && data[0] === 137 && data[1] === 80 && data[2] === 78) return "image/png";
1469
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216) return "image/jpeg";
1470
+ if (data.length >= 4 && data[0] === 37 && data[1] === 80 && data[2] === 68 && data[3] === 70) return "application/pdf";
1471
+ return "application/octet-stream";
1472
+ }
1473
+ /** Read the first 4 magic bytes of a file (empty buffer when unreadable). */
1474
+ async function readMagicBytes(abs) {
1475
+ let handle;
1476
+ try {
1477
+ handle = await open(abs, "r");
1478
+ const buf = Buffer.alloc(4);
1479
+ const { bytesRead } = await handle.read(buf, 0, 4, 0);
1480
+ return buf.subarray(0, bytesRead);
1481
+ } catch {
1482
+ return Buffer.alloc(0);
1483
+ } finally {
1484
+ if (handle !== void 0) await handle.close().catch(() => {});
1485
+ }
1486
+ }
1487
+ /**
1488
+ * Filesystem service: gated listing/read/write/search/delete. All relative
1489
+ * paths are resolved against the gated root.
1490
+ * @param gate - the workspace gate (host: registered workspace membership).
1491
+ */
1492
+ var FsService = class {
1493
+ gate;
1494
+ constructor(gate) {
1495
+ this.gate = gate;
1496
+ }
1497
+ /** Verify a project root against the workspace gate (used by the SSE layer). */
1498
+ verify(root) {
1499
+ return this.gate(root);
1500
+ }
1501
+ /**
1502
+ * List one directory (relative path; '' = root). Sorted dirs-first alpha.
1503
+ * Entry metadata comes straight from `Dirent` (name + is-directory only) —
1504
+ * no per-file stat — so listing a giant dir stays cheap. The result is
1505
+ * capped at MAX_ENTRIES (truncated flag) to keep the tree and the '@'
1506
+ * menu responsive on node_modules-style flat dirs.
1507
+ */
1508
+ async list(root, rel) {
1509
+ const gated = await this.gate(root);
1510
+ if (!gated.ok) return gated.error;
1511
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1512
+ if (!resolved.ok) return resolved.error;
1513
+ let dirents;
1514
+ try {
1515
+ dirents = await readdir(resolved.abs, { withFileTypes: true });
1516
+ } catch {
1517
+ return {
1518
+ code: "not-found",
1519
+ message: `cannot list ${rel}`
1520
+ };
1521
+ }
1522
+ const entries = [];
1523
+ for (const entry of dirents) {
1524
+ if (entry.isDirectory() && TREE_SKIP_DIRS.has(entry.name)) continue;
1525
+ entries.push({
1526
+ name: entry.name,
1527
+ path: rel === "" ? entry.name : `${rel}/${entry.name}`,
1528
+ isDir: entry.isDirectory(),
1529
+ size: 0,
1530
+ mtime: 0
1531
+ });
1532
+ }
1533
+ const truncated = entries.length > MAX_ENTRIES;
1534
+ if (truncated) entries.length = MAX_ENTRIES;
1535
+ entries.sort(compareEntries);
1536
+ return {
1537
+ root: gated.canonical,
1538
+ entries,
1539
+ truncated
1540
+ };
1541
+ }
1542
+ /** Read one file for preview: text decoded utf-8 (capped), images as data URLs. */
1543
+ async read(root, rel, asImage) {
1544
+ const gated = await this.gate(root);
1545
+ if (!gated.ok) return gated.error;
1546
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1547
+ if (!resolved.ok) return resolved.error;
1548
+ let info;
1549
+ try {
1550
+ info = await stat(resolved.abs);
1551
+ } catch {
1552
+ return {
1553
+ code: "not-found",
1554
+ message: `cannot read ${rel}`
1555
+ };
1556
+ }
1557
+ if (info.isDirectory()) return {
1558
+ code: "is-directory",
1559
+ message: `${rel} is a directory`
1560
+ };
1561
+ if (asImage) {
1562
+ if (info.size > IMAGE_CAP_BYTES) return {
1563
+ code: "read-failed",
1564
+ message: "image exceeds preview cap"
1565
+ };
1566
+ let data;
1567
+ try {
1568
+ data = await readFile(resolved.abs);
1569
+ } catch {
1570
+ return {
1571
+ code: "not-found",
1572
+ message: `cannot read ${rel}`
1573
+ };
1574
+ }
1575
+ return {
1576
+ content: `data:${imageMime(rel, data)};base64,${data.toString("base64")}`,
1577
+ truncated: false,
1578
+ size: data.length,
1579
+ mtime: info.mtimeMs,
1580
+ image: probeImageSize(data)
1581
+ };
1582
+ }
1583
+ const budget = 320003;
1584
+ let data;
1585
+ try {
1586
+ if (info.size <= budget) data = await readFile(resolved.abs);
1587
+ else {
1588
+ const handle = await open(resolved.abs, "r");
1589
+ try {
1590
+ const buffer = Buffer.alloc(budget);
1591
+ const { bytesRead } = await handle.read(buffer, 0, budget, 0);
1592
+ data = buffer.subarray(0, bytesRead);
1593
+ } finally {
1594
+ await handle.close();
1595
+ }
1596
+ }
1597
+ } catch {
1598
+ return {
1599
+ code: "not-found",
1600
+ message: `cannot read ${rel}`
1601
+ };
1602
+ }
1603
+ const text = data.toString("utf8");
1604
+ const truncated = info.size > data.length || text.length > 8e4;
1605
+ return {
1606
+ content: truncated ? text.slice(0, TEXT_CAP_CHARS) : text,
1607
+ truncated,
1608
+ size: info.size,
1609
+ mtime: info.mtimeMs
1610
+ };
1611
+ }
1612
+ /**
1613
+ * Resolve one file for raw streaming (the markdown image / pdf preview
1614
+ * route): gated, traversal-guarded, and .git-refusing. Returns the absolute
1615
+ * path with the derived mime, size and mtime — the HTTP layer streams the
1616
+ * bytes itself (createReadStream + Range), so even large files never sit in
1617
+ * host memory. Mime magic detection reads only the first few bytes. The
1618
+ * mtime feeds the route's ETag/Last-Modified validators.
1619
+ */
1620
+ async readRaw(root, rel) {
1621
+ const gated = await this.gate(root);
1622
+ if (!gated.ok) return gated.error;
1623
+ if (isGitPath(rel)) return {
1624
+ code: "path-outside-root",
1625
+ message: "refusing to read .git"
1626
+ };
1627
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1628
+ if (!resolved.ok) return resolved.error;
1629
+ let info;
1630
+ try {
1631
+ info = await stat(resolved.abs);
1632
+ } catch {
1633
+ return {
1634
+ code: "not-found",
1635
+ message: `cannot read ${rel}`
1636
+ };
1637
+ }
1638
+ if (info.isDirectory()) return {
1639
+ code: "is-directory",
1640
+ message: `${rel} is a directory`
1641
+ };
1642
+ const mime = mimeByExtension(rel) ?? imageMime(rel, await readMagicBytes(resolved.abs));
1643
+ return {
1644
+ abs: resolved.abs,
1645
+ mime,
1646
+ size: info.size,
1647
+ mtime: info.mtimeMs
1648
+ };
1649
+ }
1650
+ /** Write text content back, refusing when the file moved on disk (mtime conflict). */
1651
+ async write(root, rel, content, baseMtime) {
1652
+ const gated = await this.gate(root);
1653
+ if (!gated.ok) return gated.error;
1654
+ if (isGitPath(rel)) return {
1655
+ code: "path-outside-root",
1656
+ message: "refusing to touch .git"
1657
+ };
1658
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1659
+ if (!resolved.ok) return resolved.error;
1660
+ try {
1661
+ let current;
1662
+ try {
1663
+ current = await stat(resolved.abs);
1664
+ } catch {
1665
+ current = { mtimeMs: 0 };
1666
+ }
1667
+ if (baseMtime !== void 0 && Number(current.mtimeMs) !== 0 && Math.abs(Number(current.mtimeMs) - baseMtime) > 1) return {
1668
+ code: "write-conflict",
1669
+ message: "file changed on disk since it was loaded"
1670
+ };
1671
+ await mkdir(dirname(resolved.abs), { recursive: true });
1672
+ await writeFile(resolved.abs, content, "utf8");
1673
+ return { mtime: (await stat(resolved.abs)).mtimeMs };
1674
+ } catch {
1675
+ return {
1676
+ code: "write-failed",
1677
+ message: `cannot write ${rel}`
1678
+ };
1679
+ }
1680
+ }
1681
+ /**
1682
+ * Rename a path within the root. newName is a bare name (no separators,
1683
+ * no '.'/'..') so the target always stays in the source's own directory;
1684
+ * the joined target is re-checked against the canonical root anyway.
1685
+ */
1686
+ async rename(root, rel, newName) {
1687
+ const gated = await this.gate(root);
1688
+ if (!gated.ok) return gated.error;
1689
+ if (rel === "") return {
1690
+ code: "path-outside-root",
1691
+ message: "refusing to rename the root"
1692
+ };
1693
+ if (isGitPath(rel)) return {
1694
+ code: "path-outside-root",
1695
+ message: "refusing to touch .git"
1696
+ };
1697
+ const name = newName.trim();
1698
+ if (name === "" || name === "." || name === ".." || /[\\/]/.test(name)) return {
1699
+ code: "path-outside-root",
1700
+ message: `invalid name: ${newName}`
1701
+ };
1702
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1703
+ if (!resolved.ok) return resolved.error;
1704
+ if (relative(gated.canonical, resolved.abs) === "") return {
1705
+ code: "path-outside-root",
1706
+ message: "refusing to rename the root"
1707
+ };
1708
+ const target = join(dirname(resolved.abs), name);
1709
+ if (!isPathInside(gated.canonical, target)) return {
1710
+ code: "path-outside-root",
1711
+ message: `path escapes root: ${rel}`
1712
+ };
1713
+ try {
1714
+ await rename(resolved.abs, target);
1715
+ return { ok: true };
1716
+ } catch {
1717
+ return {
1718
+ code: "write-failed",
1719
+ message: `cannot rename ${rel}`
1720
+ };
1721
+ }
1722
+ }
1723
+ /** Create a directory at a relative path (its parent must already exist). */
1724
+ async mkdir(root, rel) {
1725
+ const gated = await this.gate(root);
1726
+ if (!gated.ok) return gated.error;
1727
+ if (rel === "") return {
1728
+ code: "path-outside-root",
1729
+ message: "refusing to create the root"
1730
+ };
1731
+ if (isGitPath(rel)) return {
1732
+ code: "path-outside-root",
1733
+ message: "refusing to touch .git"
1734
+ };
1735
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1736
+ if (!resolved.ok) return resolved.error;
1737
+ try {
1738
+ await mkdir(resolved.abs);
1739
+ return { ok: true };
1740
+ } catch {
1741
+ return {
1742
+ code: "write-failed",
1743
+ message: `cannot create directory ${rel}`
1744
+ };
1745
+ }
1746
+ }
1747
+ /** Create an empty file at a relative path (wx: refuses to overwrite). */
1748
+ async newFile(root, rel) {
1749
+ const gated = await this.gate(root);
1750
+ if (!gated.ok) return gated.error;
1751
+ if (rel === "") return {
1752
+ code: "path-outside-root",
1753
+ message: "refusing to create the root"
1754
+ };
1755
+ if (isGitPath(rel)) return {
1756
+ code: "path-outside-root",
1757
+ message: "refusing to touch .git"
1758
+ };
1759
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1760
+ if (!resolved.ok) return resolved.error;
1761
+ try {
1762
+ await writeFile(resolved.abs, "", { flag: "wx" });
1763
+ return { ok: true };
1764
+ } catch {
1765
+ return {
1766
+ code: "write-failed",
1767
+ message: `cannot create file ${rel}`
1768
+ };
1769
+ }
1770
+ }
1771
+ /**
1772
+ * Resolve a relative path to its gated absolute path without touching it —
1773
+ * the route layer uses it for reveal-in-file-manager / open-with-default.
1774
+ */
1775
+ async resolveAbsolute(root, rel) {
1776
+ const gated = await this.gate(root);
1777
+ if (!gated.ok) return gated.error;
1778
+ if (isGitPath(rel)) return {
1779
+ code: "path-outside-root",
1780
+ message: "refusing to touch .git"
1781
+ };
1782
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1783
+ if (!resolved.ok) return resolved.error;
1784
+ return {
1785
+ ok: true,
1786
+ abs: resolved.abs
1787
+ };
1788
+ }
1789
+ /** Recursive filename search (case-insensitive substring), pruned at noise dirs. */
1790
+ async search(root, query) {
1791
+ const gated = await this.gate(root);
1792
+ if (!gated.ok) return gated.error;
1793
+ const needle = query.trim().toLowerCase();
1794
+ if (needle === "") return {
1795
+ query,
1796
+ hits: [],
1797
+ truncated: false
1798
+ };
1799
+ const hits = [];
1800
+ let scanned = 0;
1801
+ let truncated = false;
1802
+ const walk = async (rel, depth) => {
1803
+ if (truncated) return;
1804
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1805
+ if (!resolved.ok) return;
1806
+ let dirents;
1807
+ try {
1808
+ dirents = await readdir(resolved.abs, { withFileTypes: true });
1809
+ } catch {
1810
+ return;
1811
+ }
1812
+ for (const entry of dirents) {
1813
+ if (scanned >= SEARCH_SCAN_CAP) {
1814
+ truncated = true;
1815
+ return;
1816
+ }
1817
+ scanned += 1;
1818
+ const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
1819
+ if (entry.isDirectory()) {
1820
+ if (SEARCH_SKIP_DIRS.has(entry.name)) continue;
1821
+ if (depth < 24 && !truncated) await walk(path, depth + 1);
1822
+ continue;
1823
+ }
1824
+ if (entry.name.toLowerCase().includes(needle)) {
1825
+ if (hits.length >= SEARCH_HIT_CAP) {
1826
+ truncated = true;
1827
+ return;
1828
+ }
1829
+ hits.push({
1830
+ path,
1831
+ name: entry.name,
1832
+ isDir: false
1833
+ });
1834
+ }
1835
+ }
1836
+ };
1837
+ try {
1838
+ await walk("", 0);
1839
+ } catch {
1840
+ return {
1841
+ code: "search-failed",
1842
+ message: "search walk failed"
1843
+ };
1844
+ }
1845
+ const rank = (hit) => {
1846
+ const name = hit.name.toLowerCase();
1847
+ if (name === needle) return 0;
1848
+ if (name.startsWith(needle)) return 1;
1849
+ return 2;
1850
+ };
1851
+ hits.sort((a, b) => rank(a) - rank(b) || a.path.length - b.path.length || (a.path < b.path ? -1 : 1));
1852
+ return {
1853
+ query,
1854
+ hits,
1855
+ truncated
1856
+ };
1857
+ }
1858
+ /** Delete a path (discard of untracked files). Recursive for directories. */
1859
+ async delete(root, rel) {
1860
+ const gated = await this.gate(root);
1861
+ if (!gated.ok) return gated.error;
1862
+ if (rel === "") return {
1863
+ code: "path-outside-root",
1864
+ message: "refusing to delete the root"
1865
+ };
1866
+ if (isGitPath(rel)) return {
1867
+ code: "path-outside-root",
1868
+ message: "refusing to touch .git"
1869
+ };
1870
+ const resolved = await resolveInsideRoot(gated.canonical, rel);
1871
+ if (!resolved.ok) return resolved.error;
1872
+ if (relative(gated.canonical, resolved.abs) === "") return {
1873
+ code: "path-outside-root",
1874
+ message: "refusing to delete the root"
1875
+ };
1876
+ try {
1877
+ await rm(resolved.abs, {
1878
+ recursive: true,
1879
+ force: true
1880
+ });
1881
+ return { ok: true };
1882
+ } catch {
1883
+ return {
1884
+ code: "write-failed",
1885
+ message: `cannot delete ${rel}`
1886
+ };
1887
+ }
1888
+ }
1889
+ };
1890
+ //#endregion
1891
+ //#region src/host/git-runner.ts
1892
+ /** Collected-output cap for one git command. */
1893
+ const OUTPUT_CAP_BYTES = 1 << 20;
1894
+ /**
1895
+ * Production runner over the subprocess service: one managed child per
1896
+ * command, bounded collect on both streams. Degrade mode keeps the SCM tab
1897
+ * showing the friendly "not a git repository" state instead of a bare 400
1898
+ * when git is missing or the subprocess service fails.
1899
+ * @param ctx - context carrying the subprocess service.
1900
+ * @param options - per-package behavior knobs.
1901
+ * @returns the runner.
1902
+ */
1903
+ function subprocessRunner$1(ctx, options = {}) {
1904
+ const spawnArgv = options.spawnArgv ?? ((argv) => ["git", ...argv]);
1905
+ const degrade = options.failureMode === "degrade";
1906
+ const errorTag = options.errorTag ?? "git";
1907
+ const failure = (prefix, error) => ({
1908
+ exitCode: 127,
1909
+ stdout: "",
1910
+ stderr: prefix + (error instanceof Error ? error.message : String(error))
1911
+ });
1912
+ return { async run(argv, cwd) {
1913
+ const spec = {
1914
+ argv: spawnArgv(argv),
1915
+ cwd,
1916
+ stdio: {
1917
+ stdin: "ignore",
1918
+ stdout: { maxBytes: OUTPUT_CAP_BYTES },
1919
+ stderr: { maxBytes: OUTPUT_CAP_BYTES }
1920
+ },
1921
+ graceMs: 1e4
1922
+ };
1923
+ if (degrade) {
1924
+ let handle;
1925
+ try {
1926
+ handle = ctx.subprocess.spawn(spec);
1927
+ } catch (error) {
1928
+ console.error("[" + errorTag + "] git spawn failed:", error);
1929
+ return failure("git: spawn failed: ", error);
1930
+ }
1931
+ try {
1932
+ const outcome = await handle.done;
1933
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? "";
1934
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? "";
1935
+ return {
1936
+ exitCode: outcome.exitCode,
1937
+ stdout,
1938
+ stderr
1939
+ };
1940
+ } catch (error) {
1941
+ console.error("[" + errorTag + "] git run failed:", error);
1942
+ return failure("git: run failed: ", error);
1943
+ }
1944
+ }
1945
+ const handle = ctx.subprocess.spawn(spec);
1946
+ const outcome = await handle.done;
1947
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? "";
1948
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? "";
1949
+ return {
1950
+ exitCode: outcome.exitCode,
1951
+ stdout,
1952
+ stderr
1953
+ };
1954
+ } };
1955
+ }
1956
+ //#endregion
1957
+ //#region src/host/git-service.ts
1958
+ /**
1959
+ * Host git service for the SCM tab: working-tree status (porcelain v1, -z),
1960
+ * stage/unstage/discard batches, all scoped to the gated project root and
1961
+ * executed through the managed subprocess seam. Parsing is pure and exported
1962
+ * for tests; the service only wraps the runner. Discard never touches the
1963
+ * staged side (the index is only ever rewritten by stage/unstage), matching
1964
+ * the "discard = worktree side" contract.
1965
+ * @module dsh-filemgr/host/git-service
1966
+ */
1967
+ /** TTL for a positive repo-top-level verdict. */
1968
+ const REPO_CACHE_TTL_MS = 6e4;
1969
+ /** TTL for a negative (null) repo-top-level verdict. */
1970
+ const NO_REPO_CACHE_TTL_MS = 3e4;
1971
+ /** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
1972
+ function subprocessRunner(ctx) {
1973
+ return subprocessRunner$1(ctx, {
1974
+ failureMode: "degrade",
1975
+ errorTag: "dsh-filemgr"
1976
+ });
1977
+ }
1978
+ /** Map one porcelain letter to the row state (unknown letters stay unknown). */
1979
+ function porcelainState(letter) {
1980
+ switch (letter) {
1981
+ case "A": return "created";
1982
+ case "M": return "modified";
1983
+ case "D": return "deleted";
1984
+ case "R": return "renamed";
1985
+ case "C": return "created";
1986
+ case "U": return "conflicted";
1987
+ case "?": return "untracked";
1988
+ default: return "unknown";
1989
+ }
1990
+ }
1991
+ /**
1992
+ * Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
1993
+ * rows. With -z every entry is NUL-terminated; rename entries carry two paths
1994
+ * (old and new). Pure — exported for tests.
1995
+ * @param output - raw porcelain v1 -z output.
1996
+ * @returns the three change groups.
1997
+ */
1998
+ function parsePorcelain(output) {
1999
+ const staged = [];
2000
+ const unstaged = [];
2001
+ const untracked = [];
2002
+ if (output === "") return {
2003
+ staged,
2004
+ unstaged,
2005
+ untracked
2006
+ };
2007
+ const fields = output.split("\0");
2008
+ for (let i = 0; i < fields.length; i += 1) {
2009
+ const field = fields[i];
2010
+ if (field === "") continue;
2011
+ const x = field[0] ?? " ";
2012
+ const y = field[1] ?? " ";
2013
+ const path = field.slice(3);
2014
+ if (x === "?" && y === "?") {
2015
+ untracked.push({
2016
+ path,
2017
+ state: "untracked",
2018
+ staged: false
2019
+ });
2020
+ continue;
2021
+ }
2022
+ if (x === "R" || x === "C") {
2023
+ const oldPath = path;
2024
+ const newPath = fields[i + 1] ?? oldPath;
2025
+ i += 1;
2026
+ staged.push({
2027
+ path: newPath,
2028
+ oldPath,
2029
+ state: porcelainState(x),
2030
+ staged: true
2031
+ });
2032
+ if (y !== " ") unstaged.push({
2033
+ path: newPath,
2034
+ oldPath,
2035
+ state: porcelainState(y),
2036
+ staged: false
2037
+ });
2038
+ continue;
2039
+ }
2040
+ if (x !== " ") staged.push({
2041
+ path,
2042
+ state: porcelainState(x),
2043
+ staged: true
2044
+ });
2045
+ if (y !== " ") unstaged.push({
2046
+ path,
2047
+ state: porcelainState(y),
2048
+ staged: false
2049
+ });
2050
+ }
2051
+ return {
2052
+ staged,
2053
+ unstaged,
2054
+ untracked
2055
+ };
2056
+ }
2057
+ /** Parse the porcelain row set into the status view shape. */
2058
+ function parseStatusView(root, branch, output) {
2059
+ const { staged, unstaged, untracked } = parsePorcelain(output);
2060
+ return {
2061
+ root,
2062
+ branch,
2063
+ staged,
2064
+ unstaged,
2065
+ untracked
2066
+ };
2067
+ }
2068
+ /** The not-a-repository verdict for status reads. */
2069
+ const NO_REPO = {
2070
+ code: "git-unavailable",
2071
+ message: "not a git repository"
2072
+ };
2073
+ /**
2074
+ * Workspace-scoped git operations. Gated methods pass the gate, resolve the
2075
+ * repository root, and reject non-repositories with a stable error; the
2076
+ * `Canonical` variants trust an already-gated canonical root (the SSE poll)
2077
+ * and skip the gate.
2078
+ * @param runner - the spawn seam.
2079
+ * @param gate - workspace-membership gate.
2080
+ * @param fsDelete - delete seam for untracked discard (host: FsService.delete).
2081
+ */
2082
+ var GitService = class {
2083
+ runner;
2084
+ gate;
2085
+ fsDelete;
2086
+ constructor(runner, gate, fsDelete) {
2087
+ this.runner = runner;
2088
+ this.gate = gate;
2089
+ this.fsDelete = fsDelete;
2090
+ }
2091
+ /** Cached one-shot git binary probe; never re-probes after the first call. */
2092
+ availablePromise;
2093
+ /**
2094
+ * Cached repo-top-level resolution per canonical workspace, with a TTL so
2095
+ * running `git init` (positive self-heal) or deleting `.git` (negative
2096
+ * self-heal) is discovered by a later probe. Positive verdicts live 60s,
2097
+ * negative (null) verdicts 30s; exitCode 127 is never cached because it
2098
+ * means spawn/run failed rather than "not a repository".
2099
+ */
2100
+ repoCache = /* @__PURE__ */ new Map();
2101
+ /**
2102
+ * Probe the git binary once (git --version) and cache the verdict for the
2103
+ * service lifetime. A machine without git then degrades every operation to
2104
+ * the stable "not a git repository" state after a single failed spawn,
2105
+ * instead of re-spawning ENOENT on every poll tick. The cache stays false
2106
+ * even if git is installed later; the host restart picks it up.
2107
+ */
2108
+ gitAvailable() {
2109
+ if (this.availablePromise === void 0) this.availablePromise = this.runner.run(["--version"], "/").then((result) => result.exitCode === 0).catch(() => false);
2110
+ return this.availablePromise;
2111
+ }
2112
+ /**
2113
+ * Resolve the repo top-level for one canonical root. Verdicts are cached
2114
+ * with a TTL: a positive repo path for 60s, a negative null for 30s. After
2115
+ * expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
2116
+ * created or removed while the host is running is picked up later. An
2117
+ * exitCode 127 means the spawn/run itself failed; it returns null but is
2118
+ * deliberately not cached so the next call retries. Any other failure is
2119
+ * cached as a negative verdict for its TTL.
2120
+ */
2121
+ repoOf(root) {
2122
+ const now = Date.now();
2123
+ const cached = this.repoCache.get(root);
2124
+ if (cached !== void 0 && cached.expiresAt > now) return cached.value;
2125
+ const entry = {
2126
+ value: Promise.resolve(null),
2127
+ expiresAt: Number.POSITIVE_INFINITY
2128
+ };
2129
+ entry.value = this.run(["rev-parse", "--show-toplevel"], root).then((result) => {
2130
+ if (result.exitCode === 127) {
2131
+ if (this.repoCache.get(root) === entry) this.repoCache.delete(root);
2132
+ return null;
2133
+ }
2134
+ if (result.exitCode !== 0) {
2135
+ entry.expiresAt = now + NO_REPO_CACHE_TTL_MS;
2136
+ return null;
2137
+ }
2138
+ const repo = result.stdout.trim();
2139
+ const found = repo !== "" && isPathInside(repo, root) ? repo : null;
2140
+ entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS);
2141
+ return found;
2142
+ }).catch(() => {
2143
+ entry.expiresAt = now + NO_REPO_CACHE_TTL_MS;
2144
+ return null;
2145
+ });
2146
+ this.repoCache.set(root, entry);
2147
+ return entry.value;
2148
+ }
2149
+ /**
2150
+ * Whether an already-gated canonical root is a git repository. Skips the
2151
+ * workspace gate so the SSE poll does not double-gate every 2s tick; the
2152
+ * underlying repoOf cache keeps rev-parse probes at TTL cadence.
2153
+ */
2154
+ isRepositoryCanonical(canonicalRoot) {
2155
+ return this.repoOf(canonicalRoot).then((repo) => repo !== null);
2156
+ }
2157
+ /**
2158
+ * Whether a workspace root is a git repository. Gates the root first (POST
2159
+ * route entry point); the SSE poll should use `isRepositoryCanonical`.
2160
+ */
2161
+ async isRepository(root) {
2162
+ const gated = await this.gate(root);
2163
+ if (!gated.ok) return false;
2164
+ return await this.isRepositoryCanonical(gated.canonical);
2165
+ }
2166
+ /** Resolve the gated canonical root and the repository top-level. */
2167
+ async repo(root) {
2168
+ const gated = await this.gate(root);
2169
+ if (!gated.ok) return {
2170
+ ok: false,
2171
+ error: gated.error
2172
+ };
2173
+ const repo = await this.repoOf(gated.canonical);
2174
+ if (repo === null) return {
2175
+ ok: false,
2176
+ error: NO_REPO
2177
+ };
2178
+ return {
2179
+ ok: true,
2180
+ root: gated.canonical,
2181
+ repo
2182
+ };
2183
+ }
2184
+ /** Run one git invocation and classify failures. */
2185
+ async run(argv, cwd) {
2186
+ return this.runner.run(argv, cwd);
2187
+ }
2188
+ /** The repo status view; null when the root is not a repository. */
2189
+ async status(root) {
2190
+ if (!await this.gitAvailable()) return null;
2191
+ const repo = await this.repo(root);
2192
+ if (!repo.ok) return repo.error.code === "git-unavailable" ? null : repo.error;
2193
+ return this.statusAt(repo.root, repo.repo);
2194
+ }
2195
+ /**
2196
+ * The repo status view for an already-gated canonical root; null when it is
2197
+ * not a repository. Skips the workspace gate (SSE subscribers were gated at
2198
+ * connect) and reuses the same repoOf cache + status parsing as `status`.
2199
+ */
2200
+ async statusCanonical(canonicalRoot) {
2201
+ const repo = await this.repoOf(canonicalRoot);
2202
+ if (repo === null) return null;
2203
+ return this.statusAt(canonicalRoot, repo);
2204
+ }
2205
+ /** Run branch + porcelain status for one resolved repo and parse the view. */
2206
+ async statusAt(root, repo) {
2207
+ const [branchResult, statusResult] = await Promise.all([this.run([
2208
+ "rev-parse",
2209
+ "--abbrev-ref",
2210
+ "HEAD"
2211
+ ], repo), this.run([
2212
+ "status",
2213
+ "--porcelain=v1",
2214
+ "-z",
2215
+ "--untracked-files=all"
2216
+ ], repo)]);
2217
+ return parseStatusView(root, branchResult.stdout.trim() === "HEAD" ? "" : branchResult.stdout.trim(), statusResult.stdout);
2218
+ }
2219
+ /** The repo root for the watch layer (null when not a repository). */
2220
+ async repoRoot(root) {
2221
+ const repo = await this.repo(root);
2222
+ return repo.ok ? repo.repo : null;
2223
+ }
2224
+ /**
2225
+ * The unified diff of one path ('' when there is no diff to show). Staged
2226
+ * paths diff the index against HEAD (`--cached`); unstaged paths diff the
2227
+ * worktree against the index. Untracked paths have no index/HEAD entry, so
2228
+ * they diff against /dev/null (the canonical new-file shape); its exit code
2229
+ * is 1 — differences exist — which is a success here, not a failure.
2230
+ */
2231
+ async diff(root, path, staged) {
2232
+ const repo = await this.repo(root);
2233
+ if (!repo.ok) return repo.error;
2234
+ const abs = join(repo.repo, path);
2235
+ if (!isPathInside(repo.repo, abs)) return {
2236
+ code: "path-outside-root",
2237
+ message: "path outside the repository"
2238
+ };
2239
+ const rel = relative(repo.repo, abs);
2240
+ const result = (await this.run([
2241
+ "ls-files",
2242
+ "--error-unmatch",
2243
+ "--",
2244
+ rel
2245
+ ], repo.repo)).exitCode !== 0 ? await this.run([
2246
+ "diff",
2247
+ "--no-index",
2248
+ "--",
2249
+ "/dev/null",
2250
+ rel
2251
+ ], repo.repo) : staged ? await this.run([
2252
+ "diff",
2253
+ "--cached",
2254
+ "--",
2255
+ rel
2256
+ ], repo.repo) : await this.run([
2257
+ "diff",
2258
+ "--",
2259
+ rel
2260
+ ], repo.repo);
2261
+ if (result.exitCode !== 0 && result.exitCode !== 1) return {
2262
+ code: "git-failed",
2263
+ message: "git diff failed"
2264
+ };
2265
+ return { content: result.stdout };
2266
+ }
2267
+ /** Verify paths stay inside the repo root (defense in depth). */
2268
+ pathsInside(repo, paths) {
2269
+ return paths.map((p) => join(repo, p)).filter((p) => isPathInside(repo, p)).map((p) => p);
2270
+ }
2271
+ /** Stage paths (git add). Batch result reflects the post-op status. */
2272
+ async stage(root, paths) {
2273
+ return this.batch(root, paths, async (repo, inside) => {
2274
+ return (await this.run([
2275
+ "add",
2276
+ "--",
2277
+ ...inside
2278
+ ], repo)).exitCode === 0;
2279
+ });
2280
+ }
2281
+ /** Unstage paths (git restore --staged). */
2282
+ async unstage(root, paths) {
2283
+ return this.batch(root, paths, async (repo, inside) => {
2284
+ return (await this.run([
2285
+ "restore",
2286
+ "--staged",
2287
+ "--",
2288
+ ...inside
2289
+ ], repo)).exitCode === 0;
2290
+ });
2291
+ }
2292
+ /**
2293
+ * Discard paths (worktree side only). Tracked paths are restored from the
2294
+ * index; untracked paths are deleted through the fs seam. The batch reports
2295
+ * applied/failed per path.
2296
+ */
2297
+ async discard(root, paths) {
2298
+ const repo = await this.repo(root);
2299
+ if (!repo.ok) return repo.error;
2300
+ const inside = this.pathsInside(repo.repo, paths);
2301
+ const applied = [];
2302
+ const failed = [];
2303
+ const eligible = [];
2304
+ for (const p of paths) if (inside.includes(join(repo.repo, p))) eligible.push(p);
2305
+ else failed.push(p);
2306
+ const trackedSet = /* @__PURE__ */ new Set();
2307
+ if (eligible.length > 0) {
2308
+ const listed = await this.run([
2309
+ "ls-files",
2310
+ "-z",
2311
+ "--",
2312
+ ...eligible.map((p) => ":(literal)" + p)
2313
+ ], repo.repo);
2314
+ for (const entry of listed.stdout.split("\0")) if (entry !== "") trackedSet.add(entry);
2315
+ }
2316
+ const tracked = eligible.filter((p) => trackedSet.has(p));
2317
+ const untracked = eligible.filter((p) => !trackedSet.has(p));
2318
+ if (tracked.length > 0) if ((await this.run([
2319
+ "restore",
2320
+ "--worktree",
2321
+ "--",
2322
+ ...tracked.map((p) => ":(literal)" + p)
2323
+ ], repo.repo)).exitCode === 0) applied.push(...tracked);
2324
+ else for (const p of tracked) if ((await this.run([
2325
+ "restore",
2326
+ "--worktree",
2327
+ "--",
2328
+ ":(literal)" + p
2329
+ ], repo.repo)).exitCode === 0) applied.push(p);
2330
+ else failed.push(p);
2331
+ for (const p of untracked) {
2332
+ try {
2333
+ const real = await realpath(join(repo.repo, p));
2334
+ if (!isPathInside(repo.repo, real)) {
2335
+ failed.push(p);
2336
+ continue;
2337
+ }
2338
+ } catch {}
2339
+ const rel = relative(repo.root, join(repo.repo, p));
2340
+ if (rel === ".." || rel.startsWith("../")) {
2341
+ failed.push(p);
2342
+ continue;
2343
+ }
2344
+ const deleted = await this.fsDelete(repo.root, rel);
2345
+ if ("ok" in deleted && deleted.ok) applied.push(p);
2346
+ else failed.push(p);
2347
+ }
2348
+ return {
2349
+ applied,
2350
+ failed
2351
+ };
2352
+ }
2353
+ /** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
2354
+ async batch(root, paths, op) {
2355
+ const repo = await this.repo(root);
2356
+ if (!repo.ok) return repo.error;
2357
+ const inside = this.pathsInside(repo.repo, paths);
2358
+ const ok = inside.length > 0 ? await op(repo.repo, inside) : true;
2359
+ if (!ok) return {
2360
+ code: "git-failed",
2361
+ message: "git operation failed"
2362
+ };
2363
+ return {
2364
+ applied: ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : [],
2365
+ failed: paths.filter((p) => !inside.includes(join(repo.repo, p)))
2366
+ };
2367
+ }
2368
+ };
2369
+ //#endregion
2370
+ //#region src/host/poll-guard.ts
2371
+ const DEFAULT_TIMERS = {
2372
+ set: (fn, ms) => setTimeout(fn, ms),
2373
+ clear: (handle) => {
2374
+ clearTimeout(handle);
2375
+ }
2376
+ };
2377
+ /**
2378
+ * Owns one bounded poll loop.
2379
+ *
2380
+ * Guarantees: at most one task runs at a time (a scheduled tick whose turn
2381
+ * arrives while a run is in flight is dropped); consecutive failures double
2382
+ * the delay up to maxBackoffMs and reset on the first success; the loop
2383
+ * stops forever at deadlineMs and cancels its timer.
2384
+ */
2385
+ var PollGuard = class {
2386
+ options;
2387
+ handle;
2388
+ running = false;
2389
+ startedAt = 0;
2390
+ stopped = false;
2391
+ failures = 0;
2392
+ /** @param options - loop bounds; interval/deadline/backoff/onRun are required, the rest optional. */
2393
+ constructor(options) {
2394
+ this.options = {
2395
+ timers: DEFAULT_TIMERS,
2396
+ onDeadline: () => {},
2397
+ onSettled: () => {},
2398
+ ...options
2399
+ };
2400
+ }
2401
+ /** Start the loop. Safe to call once; later calls are ignored. */
2402
+ start() {
2403
+ if (this.startedAt !== 0) return;
2404
+ this.startedAt = Date.now();
2405
+ this.schedule(this.options.intervalMs);
2406
+ }
2407
+ /** Stop the loop permanently and drop any pending tick. */
2408
+ stop() {
2409
+ this.stopped = true;
2410
+ this.options.timers.clear(this.handle);
2411
+ this.handle = void 0;
2412
+ }
2413
+ schedule(delayMs) {
2414
+ if (this.stopped) return;
2415
+ this.handle = this.options.timers.set(() => {
2416
+ this.tick();
2417
+ }, delayMs);
2418
+ }
2419
+ delay() {
2420
+ const backoff = this.options.intervalMs * 2 ** Math.min(this.failures, 8);
2421
+ return Math.min(backoff, this.options.maxBackoffMs);
2422
+ }
2423
+ async tick() {
2424
+ if (this.stopped) return;
2425
+ if (this.running) return;
2426
+ if (Date.now() - this.startedAt >= this.options.deadlineMs) {
2427
+ this.stopped = true;
2428
+ this.options.onDeadline();
2429
+ return;
2430
+ }
2431
+ this.running = true;
2432
+ try {
2433
+ await this.options.onRun();
2434
+ this.failures = 0;
2435
+ } catch {
2436
+ this.failures += 1;
2437
+ } finally {
2438
+ this.running = false;
2439
+ this.options.onSettled(this.failures);
2440
+ this.schedule(this.delay());
2441
+ }
2442
+ }
2443
+ };
2444
+ //#endregion
2445
+ //#region src/host/loopback.ts
2446
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
2447
+ function isIPv4Loopback(v4) {
2448
+ const parts = v4.split(".");
2449
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
2450
+ }
2451
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
2452
+ function isLoopbackAddress(address) {
2453
+ if (address === void 0) return false;
2454
+ const normalized = address.toLowerCase();
2455
+ if (normalized === "::1") return true;
2456
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
2457
+ return isIPv4Loopback(normalized);
2458
+ }
2459
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
2460
+ function isLoopbackHostname(hostname) {
2461
+ if (hostname === "localhost" || hostname === "[::1]") return true;
2462
+ return isIPv4Loopback(hostname);
2463
+ }
2464
+ /**
2465
+ * Request-level trust fence: a loopback socket address AND a loopback Host
2466
+ * header, plus browser same-origin markers. The socket address is
2467
+ * authoritative; X-Forwarded-For is never trusted.
2468
+ */
2469
+ function isLoopbackRequest(request) {
2470
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
2471
+ const host = request.headers.host;
2472
+ if (typeof host !== "string") return false;
2473
+ let hostUrl;
2474
+ try {
2475
+ hostUrl = new URL("http://" + host);
2476
+ } catch {
2477
+ return false;
2478
+ }
2479
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
2480
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
2481
+ const origin = request.headers.origin;
2482
+ if (origin === void 0) return true;
2483
+ try {
2484
+ return new URL(origin).host === hostUrl.host;
2485
+ } catch {
2486
+ return false;
2487
+ }
2488
+ }
2489
+ //#endregion
2490
+ //#region src/host/routes.ts
2491
+ const OK = (value) => ({
2492
+ ok: true,
2493
+ value
2494
+ });
2495
+ const FAIL = (error) => ({
2496
+ ok: false,
2497
+ error
2498
+ });
2499
+ /** Structural request failure (never a workspace fault). */
2500
+ const BAD_REQUEST = {
2501
+ code: "internal",
2502
+ message: "malformed request"
2503
+ };
2504
+ /**
2505
+ * Platform argv for "reveal in file manager" (select the entry). Windows
2506
+ * Explorer selects via /select,; macOS Finder via open -R; Linux desktops
2507
+ * have no select mode, so xdg-open opens the parent directory.
2508
+ */
2509
+ function revealArgv(platform, abs) {
2510
+ if (platform === "win32") return ["explorer.exe", `/select,${abs}`];
2511
+ if (platform === "darwin") return [
2512
+ "open",
2513
+ "-R",
2514
+ abs
2515
+ ];
2516
+ return ["xdg-open", dirname(abs)];
2517
+ }
2518
+ /** Platform argv for "open with the default app". */
2519
+ function openArgv(platform, abs) {
2520
+ if (platform === "win32") return [
2521
+ "cmd.exe",
2522
+ "/c",
2523
+ "start",
2524
+ "",
2525
+ abs
2526
+ ];
2527
+ if (platform === "darwin") return ["open", abs];
2528
+ return ["xdg-open", abs];
2529
+ }
2530
+ /**
2531
+ * Spawn one OS GUI command fire-and-forget: Explorer / Finder / xdg-open
2532
+ * detach immediately and their exit codes are not meaningful, so nothing is
2533
+ * awaited beyond the spawn itself (failures still surface as an error).
2534
+ */
2535
+ function spawnOsCommand(ctx, argv) {
2536
+ const spec = {
2537
+ argv,
2538
+ cwd: dirname(argv[argv.length - 1] ?? process.cwd()),
2539
+ stdio: {
2540
+ stdin: "ignore",
2541
+ stdout: { maxBytes: 65536 },
2542
+ stderr: { maxBytes: 65536 }
2543
+ },
2544
+ graceMs: 5e3
2545
+ };
2546
+ try {
2547
+ ctx.subprocess.spawn(spec).done.catch(() => {});
2548
+ return null;
2549
+ } catch (error) {
2550
+ ctx.logger.warn(`dsh-filemgr: OS command failed ([${argv.join(", ")}]): ${String(error)}`);
2551
+ return {
2552
+ code: "internal",
2553
+ message: "cannot run OS command"
2554
+ };
2555
+ }
2556
+ }
2557
+ /**
2558
+ * Poll interval for git-status changes while subscribers are connected.
2559
+ * Kept deliberately long (30s): on Windows a cold git.exe costs ~0.7s per
2560
+ * spawn, and the SCM panel already refreshes event-driven (fs watch for
2561
+ * file edits) and on window focus — the poll only needs to catch
2562
+ * out-of-band .git writes (commits/checkouts from other tools).
2563
+ */
2564
+ const GIT_POLL_MS = 3e4;
2565
+ /** SSE keep-alive comment interval (proxies drop idle connections). */
2566
+ const HEARTBEAT_MS = 15e3;
2567
+ /**
2568
+ * Parse a `Range: bytes=start-end` header against the file size. RFC 7233
2569
+ * lets a server ignore any Range it does not support, so unknown units,
2570
+ * malformed headers and multi-range requests all return null (the caller
2571
+ * answers 200 with the full body); only a syntactically valid single range
2572
+ * that cannot be satisfied returns 'invalid' (the caller answers 416).
2573
+ * Suffix ranges (`bytes=-N`) select the last N bytes. Range support added
2574
+ * after human review on #242 (pdf seeking); ignore-instead-of-416 for
2575
+ * unsupported shapes per maintainer feedback.
2576
+ */
2577
+ function parseRangeHeader(header, size) {
2578
+ if (header === void 0) return null;
2579
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2580
+ if (match === null || match[1] === "" && match[2] === "") return null;
2581
+ if (match[1] === "") {
2582
+ const suffix = Number(match[2]);
2583
+ if (suffix <= 0 || size === 0) return "invalid";
2584
+ return {
2585
+ start: Math.max(0, size - suffix),
2586
+ end: size - 1
2587
+ };
2588
+ }
2589
+ const start = Number(match[1]);
2590
+ const end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
2591
+ if (size === 0 || start > end || start >= size) return "invalid";
2592
+ return {
2593
+ start,
2594
+ end
2595
+ };
2596
+ }
2597
+ /** Strip the weak prefix and quotes so entity-tags compare by opaque value. */
2598
+ function normalizeEtag(value) {
2599
+ return value.trim().replace(/^W\//, "").replace(/^"|"$/g, "");
2600
+ }
2601
+ /**
2602
+ * Whether an If-None-Match header matches the current etag. Handles `*` and
2603
+ * comma-separated entity-tag lists; GET revalidation uses weak comparison
2604
+ * (RFC 9110), so the weak prefix is ignored on both sides.
2605
+ */
2606
+ function ifNoneMatchSaidFresh(header, etag) {
2607
+ if (header === void 0) return false;
2608
+ const current = normalizeEtag(etag);
2609
+ return header.split(",").some((candidate) => {
2610
+ const tag = candidate.trim();
2611
+ return tag === "*" || normalizeEtag(tag) === current;
2612
+ });
2613
+ }
2614
+ /**
2615
+ * Deadline for one git-status subprocess inside pollGit. Not an execution
2616
+ * timeout — the subprocess' own graceMs limits a single binary run; this is
2617
+ * the route layer's guard against a hung status (e.g. a wedged git daemon on
2618
+ * a cold path) that would otherwise leave the anti-overlap guard (owned by
2619
+ * PollGuard) wedged forever and silence SCM. Owned here so the deadline is independent
2620
+ * of any service-level setting.
2621
+ */
2622
+ const GIT_STATUS_TIMEOUT_MS = 15e3;
2623
+ /**
2624
+ * PollGuard loop bounds. The poll is stopped by the SSE subscriber lifecycle
2625
+ * (start on first subscriber, stop when the last disconnects), so the guard's
2626
+ * own deadline is never reached in practice (MAX_SAFE_INTEGER ms ~ no
2627
+ * deadline), preserving the former setInterval which kept polling as long as
2628
+ * any stream was connected. maxBackoffMs equals the interval so a rejected
2629
+ * run retries at the same cadence as a healthy one (interval unchanged).
2630
+ */
2631
+ const GIT_POLL_DEADLINE_MS = Number.MAX_SAFE_INTEGER;
2632
+ const GIT_POLL_MAX_BACKOFF_MS = GIT_POLL_MS;
2633
+ /** Write the shared non-loopback rejection (same body as dsh-ssh). */
2634
+ function forbidden(res) {
2635
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
2636
+ res.end(JSON.stringify({ error: "forbidden: loopback-only" }));
2637
+ }
2638
+ /** Read a JSON request body into an unknown value; null when unparseable. */
2639
+ async function readJsonBody(req) {
2640
+ const chunks = [];
2641
+ let total = 0;
2642
+ for await (const chunk of req) {
2643
+ const buffer = chunk;
2644
+ chunks.push(buffer);
2645
+ total += buffer.length;
2646
+ if (total > 1 << 20) return null;
2647
+ }
2648
+ const text = Buffer.concat(chunks).toString("utf8");
2649
+ if (text === "") return null;
2650
+ try {
2651
+ return JSON.parse(text);
2652
+ } catch {
2653
+ return null;
2654
+ }
2655
+ }
2656
+ /** Extract the required string field from a JSON object payload. */
2657
+ function strField(payload, key) {
2658
+ if (typeof payload !== "object" || payload === null) return null;
2659
+ const value = payload[key];
2660
+ return typeof value === "string" && value !== "" ? value : null;
2661
+ }
2662
+ /** Extract a string field, accepting the empty string as a value. */
2663
+ function strOrEmpty(payload, key) {
2664
+ if (typeof payload !== "object" || payload === null) return null;
2665
+ const value = payload[key];
2666
+ return typeof value === "string" ? value : null;
2667
+ }
2668
+ /** Extract a string array field (defaults to []). */
2669
+ function strArray(payload, key) {
2670
+ if (typeof payload !== "object" || payload === null) return null;
2671
+ const value = payload[key];
2672
+ if (value === void 0) return [];
2673
+ if (!Array.isArray(value)) return null;
2674
+ if (!value.every((item) => typeof item === "string")) return null;
2675
+ return value;
2676
+ }
2677
+ /** Write one JSON envelope response. */
2678
+ function json(res, envelope, status = 200) {
2679
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
2680
+ res.end(JSON.stringify(envelope));
2681
+ }
2682
+ /**
2683
+ * Register the /filemgr routes (prefix for JSON, exact for the SSE
2684
+ * stream — longest-prefix-wins keeps them disjoint).
2685
+ * @param ctx - context carrying the webServer service.
2686
+ * @param fs - the gated filesystem service.
2687
+ * @param git - the gated git service.
2688
+ * @returns the route disposers.
2689
+ */
2690
+ function registerPanelRoutes(ctx, fs, git) {
2691
+ const subscribers = /* @__PURE__ */ new Set();
2692
+ let gitPoll;
2693
+ let heartbeatTimer;
2694
+ const push = (subscriber, payload) => {
2695
+ subscriber.res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`);
2696
+ };
2697
+ let gitProbed = false;
2698
+ let gitUnavailable = false;
2699
+ const pollGit = async () => {
2700
+ if (!gitProbed) {
2701
+ gitProbed = true;
2702
+ if (!await git.gitAvailable()) {
2703
+ gitUnavailable = true;
2704
+ ctx.logger.warn("dsh-filemgr: git binary unavailable, SCM polling disabled");
2705
+ for (const subscriber of subscribers) push(subscriber, { kind: "gitUnavailable" });
2706
+ }
2707
+ }
2708
+ if (gitUnavailable) return;
2709
+ await Promise.all([...subscribers].map(async (subscriber) => {
2710
+ try {
2711
+ if (!await git.isRepositoryCanonical(subscriber.root)) return;
2712
+ let timeout;
2713
+ const status = await Promise.race([git.statusCanonical(subscriber.root), new Promise((_, reject) => {
2714
+ timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("git status timed out")), GIT_STATUS_TIMEOUT_MS);
2715
+ })]).finally(() => {
2716
+ if (timeout !== void 0) clearTimeout(timeout);
2717
+ });
2718
+ if (status === null) return;
2719
+ const key = `${status.branch}|${JSON.stringify(status.staged)}|${JSON.stringify(status.unstaged)}|${JSON.stringify(status.untracked)}`;
2720
+ if (key === subscriber.lastGit) return;
2721
+ subscriber.lastGit = key;
2722
+ push(subscriber, {
2723
+ kind: "git",
2724
+ status
2725
+ });
2726
+ } catch (error) {
2727
+ ctx.logger.warn(`dsh-filemgr: git poll failed for ${subscriber.root}: ${String(error)}`);
2728
+ }
2729
+ }));
2730
+ };
2731
+ const startGitPoll = () => {
2732
+ if (gitPoll !== void 0) return;
2733
+ gitPoll = new PollGuard({
2734
+ intervalMs: GIT_POLL_MS,
2735
+ deadlineMs: GIT_POLL_DEADLINE_MS,
2736
+ maxBackoffMs: GIT_POLL_MAX_BACKOFF_MS,
2737
+ onRun: pollGit
2738
+ });
2739
+ gitPoll.start();
2740
+ };
2741
+ const stopGitPoll = () => {
2742
+ if (gitPoll === void 0) return;
2743
+ gitPoll.stop();
2744
+ gitPoll = void 0;
2745
+ };
2746
+ /**
2747
+ * GET /filemgr/raw: stream one workspace file (markdown image srcs,
2748
+ * pdf preview). Gated like every other operation; FsService.readRaw only
2749
+ * resolves and stats the path, the bytes are piped straight from disk with
2750
+ * the derived mime — the whole file never sits in host memory. Single byte
2751
+ * ranges are honored (206/416) so the browser pdf viewer can seek large
2752
+ * files; unsupported range shapes are ignored per RFC 7233 (200 full
2753
+ * body). ETag/Last-Modified (size+mtime) keep no-cache revalidation cheap:
2754
+ * unchanged files answer 304, If-Range mismatches fall back to 200.
2755
+ */
2756
+ const serveRaw = async (req, url, res) => {
2757
+ const root = url.searchParams.get("root");
2758
+ const path = url.searchParams.get("path");
2759
+ if (root === null || root === "" || path === null || path === "") {
2760
+ json(res, FAIL(BAD_REQUEST), 400);
2761
+ return;
2762
+ }
2763
+ const result = await fs.readRaw(root, path);
2764
+ if (!("abs" in result)) {
2765
+ const status = result.code === "path-outside-root" || result.code === "is-directory" ? 403 : 404;
2766
+ json(res, FAIL(result), status);
2767
+ return;
2768
+ }
2769
+ const etag = `W/"${result.size}-${Math.floor(result.mtime)}"`;
2770
+ const lastModified = new Date(result.mtime).toUTCString();
2771
+ const baseHeaders = {
2772
+ "content-type": result.mime,
2773
+ "cache-control": "no-cache",
2774
+ "x-content-type-options": "nosniff",
2775
+ "accept-ranges": "bytes",
2776
+ etag,
2777
+ "last-modified": lastModified
2778
+ };
2779
+ if (ifNoneMatchSaidFresh(req.headers["if-none-match"], etag) && req.headers.range === void 0) {
2780
+ res.writeHead(304, baseHeaders);
2781
+ res.end();
2782
+ return;
2783
+ }
2784
+ const ifRange = req.headers["if-range"];
2785
+ const range = ifRange !== void 0 && ifRange !== etag && ifRange !== lastModified ? null : parseRangeHeader(req.headers.range, result.size);
2786
+ if (range === "invalid") {
2787
+ res.writeHead(416, {
2788
+ ...baseHeaders,
2789
+ "content-range": `bytes */${result.size}`
2790
+ });
2791
+ res.end();
2792
+ return;
2793
+ }
2794
+ const headers = { ...baseHeaders };
2795
+ if (range === null) {
2796
+ headers["content-length"] = result.size;
2797
+ res.writeHead(200, headers);
2798
+ } else {
2799
+ headers["content-range"] = `bytes ${range.start}-${range.end}/${result.size}`;
2800
+ headers["content-length"] = range.end - range.start + 1;
2801
+ res.writeHead(206, headers);
2802
+ }
2803
+ try {
2804
+ await pipeline(createReadStream(result.abs, range === null ? void 0 : {
2805
+ start: range.start,
2806
+ end: range.end
2807
+ }), res);
2808
+ } catch {
2809
+ res.destroy();
2810
+ }
2811
+ };
2812
+ /**
2813
+ * GET /filemgr/vendor/mermaid.js: the mermaid IIFE bundle shipped in
2814
+ * the package (lib/assets/mermaid.min.js, copied from the mermaid npm
2815
+ * dependency at build time). Same-origin for the browser half (no CDN),
2816
+ * loopback-fenced like every other route. One read is cached per plugin
2817
+ * instance; the size+mtime pair doubles as the ETag so the browser
2818
+ * revalidation is a cheap 304. A missing asset (build without the copy
2819
+ * step) 404s and the client keeps plain code blocks.
2820
+ */
2821
+ let mermaidAsset;
2822
+ const serveVendorMermaid = async (req, res) => {
2823
+ if (mermaidAsset === void 0) {
2824
+ for (const relative of ["./assets/mermaid.min.js", "../../lib/assets/mermaid.min.js"]) try {
2825
+ const assetPath = fileURLToPath(new URL(relative, import.meta.url));
2826
+ const [data, info] = await Promise.all([readFile(assetPath), stat(assetPath)]);
2827
+ mermaidAsset = {
2828
+ data,
2829
+ etag: `"${data.length}-${info.mtimeMs.toString(16)}"`
2830
+ };
2831
+ break;
2832
+ } catch {}
2833
+ if (mermaidAsset === void 0) {
2834
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
2835
+ res.end(JSON.stringify({ error: "mermaid vendor asset missing" }));
2836
+ return;
2837
+ }
2838
+ }
2839
+ if (req.headers["if-none-match"] === mermaidAsset.etag) {
2840
+ res.writeHead(304, { etag: mermaidAsset.etag });
2841
+ res.end();
2842
+ return;
2843
+ }
2844
+ res.writeHead(200, {
2845
+ "content-type": "application/javascript; charset=utf-8",
2846
+ "content-length": mermaidAsset.data.length,
2847
+ "cache-control": "no-cache",
2848
+ etag: mermaidAsset.etag,
2849
+ "x-content-type-options": "nosniff"
2850
+ });
2851
+ res.end(mermaidAsset.data);
2852
+ };
2853
+ const handler = async (req, res) => {
2854
+ if (!isLoopbackRequest(req)) {
2855
+ forbidden(res);
2856
+ return;
2857
+ }
2858
+ if (req.method === "GET") {
2859
+ const url = new URL(req.url ?? "/", "http://x");
2860
+ if (url.pathname === "/filemgr/raw") {
2861
+ await serveRaw(req, url, res);
2862
+ return;
2863
+ }
2864
+ if (url.pathname === "/filemgr/vendor/mermaid.js") {
2865
+ await serveVendorMermaid(req, res);
2866
+ return;
2867
+ }
2868
+ res.writeHead(405);
2869
+ res.end();
2870
+ return;
2871
+ }
2872
+ if (req.method !== "POST") {
2873
+ res.writeHead(405);
2874
+ res.end();
2875
+ return;
2876
+ }
2877
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
2878
+ json(res, FAIL(BAD_REQUEST), 415);
2879
+ return;
2880
+ }
2881
+ const pathname = new URL(req.url ?? "/", "http://x").pathname;
2882
+ const payload = await readJsonBody(req);
2883
+ if (payload === null) {
2884
+ json(res, FAIL(BAD_REQUEST));
2885
+ return;
2886
+ }
2887
+ const root = strField(payload, "root");
2888
+ if (root === null) {
2889
+ json(res, FAIL(BAD_REQUEST));
2890
+ return;
2891
+ }
2892
+ switch (pathname) {
2893
+ case "/filemgr/list": {
2894
+ const path = strField(payload, "path") ?? "";
2895
+ const result = await fs.list(root, path);
2896
+ json(res, "entries" in result ? OK(result) : FAIL(result));
2897
+ return;
2898
+ }
2899
+ case "/filemgr/read": {
2900
+ const path = strField(payload, "path");
2901
+ if (path === null) {
2902
+ json(res, FAIL(BAD_REQUEST));
2903
+ return;
2904
+ }
2905
+ const asImage = typeof payload === "object" && payload !== null ? payload.asImage === true : false;
2906
+ const result = await fs.read(root, path, asImage);
2907
+ json(res, "content" in result ? OK(result) : FAIL(result));
2908
+ return;
2909
+ }
2910
+ case "/filemgr/write": {
2911
+ const path = strField(payload, "path");
2912
+ const content = strOrEmpty(payload, "content");
2913
+ if (path === null || content === null) {
2914
+ json(res, FAIL(BAD_REQUEST));
2915
+ return;
2916
+ }
2917
+ const rawBase = typeof payload === "object" && payload !== null ? payload.baseMtime : void 0;
2918
+ const baseMtime = typeof rawBase === "number" && Number.isFinite(rawBase) ? rawBase : void 0;
2919
+ const result = await fs.write(root, path, content, baseMtime);
2920
+ json(res, "mtime" in result ? OK(result) : FAIL(result));
2921
+ return;
2922
+ }
2923
+ case "/filemgr/search": {
2924
+ const query = strField(payload, "query") ?? "";
2925
+ const result = await fs.search(root, query);
2926
+ json(res, "hits" in result ? OK(result) : FAIL(result));
2927
+ return;
2928
+ }
2929
+ case "/filemgr/delete": {
2930
+ const path = strField(payload, "path");
2931
+ if (path === null) {
2932
+ json(res, FAIL(BAD_REQUEST));
2933
+ return;
2934
+ }
2935
+ const result = await fs.delete(root, path);
2936
+ json(res, "ok" in result ? OK(result) : FAIL(result));
2937
+ return;
2938
+ }
2939
+ case "/filemgr/reveal": {
2940
+ const path = strField(payload, "path");
2941
+ if (path === null) {
2942
+ json(res, FAIL(BAD_REQUEST));
2943
+ return;
2944
+ }
2945
+ const resolved = await fs.resolveAbsolute(root, path);
2946
+ if (!("ok" in resolved)) {
2947
+ json(res, FAIL(resolved));
2948
+ return;
2949
+ }
2950
+ const error = spawnOsCommand(ctx, revealArgv(process.platform, resolved.abs));
2951
+ json(res, error === null ? OK({ ok: true }) : FAIL(error));
2952
+ return;
2953
+ }
2954
+ case "/filemgr/open-with-default": {
2955
+ const path = strField(payload, "path");
2956
+ if (path === null) {
2957
+ json(res, FAIL(BAD_REQUEST));
2958
+ return;
2959
+ }
2960
+ const resolved = await fs.resolveAbsolute(root, path);
2961
+ if (!("ok" in resolved)) {
2962
+ json(res, FAIL(resolved));
2963
+ return;
2964
+ }
2965
+ const error = spawnOsCommand(ctx, openArgv(process.platform, resolved.abs));
2966
+ json(res, error === null ? OK({ ok: true }) : FAIL(error));
2967
+ return;
2968
+ }
2969
+ case "/filemgr/rename": {
2970
+ const path = strField(payload, "path");
2971
+ const newName = strField(payload, "newName");
2972
+ if (path === null || newName === null) {
2973
+ json(res, FAIL(BAD_REQUEST));
2974
+ return;
2975
+ }
2976
+ const result = await fs.rename(root, path, newName);
2977
+ json(res, "ok" in result ? OK(result) : FAIL(result));
2978
+ return;
2979
+ }
2980
+ case "/filemgr/mkdir": {
2981
+ const path = strField(payload, "path");
2982
+ if (path === null) {
2983
+ json(res, FAIL(BAD_REQUEST));
2984
+ return;
2985
+ }
2986
+ const result = await fs.mkdir(root, path);
2987
+ json(res, "ok" in result ? OK(result) : FAIL(result));
2988
+ return;
2989
+ }
2990
+ case "/filemgr/new-file": {
2991
+ const path = strField(payload, "path");
2992
+ if (path === null) {
2993
+ json(res, FAIL(BAD_REQUEST));
2994
+ return;
2995
+ }
2996
+ const result = await fs.newFile(root, path);
2997
+ json(res, "ok" in result ? OK(result) : FAIL(result));
2998
+ return;
2999
+ }
3000
+ case "/filemgr/git-status": {
3001
+ const result = await git.status(root);
3002
+ json(res, result === null ? OK(null) : "root" in result ? OK(result) : FAIL(result));
3003
+ return;
3004
+ }
3005
+ case "/filemgr/git-diff": {
3006
+ const path = strField(payload, "path");
3007
+ if (path === null) {
3008
+ json(res, FAIL(BAD_REQUEST));
3009
+ return;
3010
+ }
3011
+ const staged = typeof payload === "object" && payload !== null ? payload.staged === true : false;
3012
+ const result = await git.diff(root, path, staged);
3013
+ json(res, "content" in result ? OK(result) : FAIL(result));
3014
+ return;
3015
+ }
3016
+ case "/filemgr/git-stage": {
3017
+ const paths = strArray(payload, "paths");
3018
+ if (paths === null) {
3019
+ json(res, FAIL(BAD_REQUEST));
3020
+ return;
3021
+ }
3022
+ const result = await git.stage(root, paths);
3023
+ json(res, "applied" in result ? OK(result) : FAIL(result));
3024
+ return;
3025
+ }
3026
+ case "/filemgr/git-unstage": {
3027
+ const paths = strArray(payload, "paths");
3028
+ if (paths === null) {
3029
+ json(res, FAIL(BAD_REQUEST));
3030
+ return;
3031
+ }
3032
+ const result = await git.unstage(root, paths);
3033
+ json(res, "applied" in result ? OK(result) : FAIL(result));
3034
+ return;
3035
+ }
3036
+ case "/filemgr/git-discard": {
3037
+ const paths = strArray(payload, "paths");
3038
+ if (paths === null) {
3039
+ json(res, FAIL(BAD_REQUEST));
3040
+ return;
3041
+ }
3042
+ const result = await git.discard(root, paths);
3043
+ json(res, "applied" in result ? OK(result) : FAIL(result));
3044
+ return;
3045
+ }
3046
+ default:
3047
+ res.writeHead(404);
3048
+ res.end();
3049
+ }
3050
+ };
3051
+ const sse = async (req, res) => {
3052
+ if (!isLoopbackRequest(req)) {
3053
+ forbidden(res);
3054
+ return;
3055
+ }
3056
+ const root = new URL(req.url ?? "/", "http://x").searchParams.get("root");
3057
+ if (root === null || root === "") {
3058
+ res.writeHead(400);
3059
+ res.end();
3060
+ return;
3061
+ }
3062
+ const gated = await fs.verify(root);
3063
+ if (!gated.ok) {
3064
+ json(res, FAIL(gated.error), 400);
3065
+ return;
3066
+ }
3067
+ res.writeHead(200, {
3068
+ "content-type": "text/event-stream; charset=utf-8",
3069
+ "cache-control": "no-cache",
3070
+ connection: "keep-alive"
3071
+ });
3072
+ res.write("retry: 2000\n\n");
3073
+ const subscriber = {
3074
+ root: gated.canonical,
3075
+ lastGit: "",
3076
+ res
3077
+ };
3078
+ subscribers.add(subscriber);
3079
+ if (gitUnavailable) push(subscriber, { kind: "gitUnavailable" });
3080
+ startGitPoll();
3081
+ if (heartbeatTimer === void 0) heartbeatTimer = setInterval(() => {
3082
+ for (const current of subscribers) current.res.write(": ping\n\n");
3083
+ }, HEARTBEAT_MS);
3084
+ req.on("close", () => {
3085
+ subscribers.delete(subscriber);
3086
+ if (subscribers.size === 0) {
3087
+ stopGitPoll();
3088
+ if (heartbeatTimer !== void 0) clearInterval(heartbeatTimer);
3089
+ heartbeatTimer = void 0;
3090
+ }
3091
+ });
3092
+ };
3093
+ const disposers = [ctx.webServer.register({
3094
+ kind: "prefix",
3095
+ path: "/filemgr",
3096
+ handler
3097
+ }), ctx.webServer.register({
3098
+ kind: "exact",
3099
+ path: "/filemgr/events",
3100
+ handler: sse
3101
+ })];
3102
+ return () => {
3103
+ for (const dispose of disposers) dispose();
3104
+ stopGitPoll();
3105
+ if (heartbeatTimer !== void 0) clearInterval(heartbeatTimer);
3106
+ for (const subscriber of subscribers) subscriber.res.end();
3107
+ subscribers.clear();
3108
+ };
3109
+ }
3110
+ //#endregion
3111
+ //#region src/mount-once.ts
3112
+ /**
3113
+ * Host single-instance guard shared by the plugin family. The family bundle
3114
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
3115
+ * the loader accepts a standalone install of the same package side by side;
3116
+ * without this guard the second instance would still re-register the same
3117
+ * webserver routes, tools, settings namespaces, and system-prompt sections
3118
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
3119
+ * lifetime of the first instance (the browser half is already deduped by
3120
+ * package name in the client module host).
3121
+ *
3122
+ * The registry rides a global symbol so two module instances of the same
3123
+ * package (npm copy vs repository link) still share one verdict. cordis
3124
+ * `ctx.effect` runs its callback immediately and treats the callback's
3125
+ * return value as the fiber disposer, so the unmarker is returned, not run.
3126
+ */
3127
+ const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
3128
+ function mountedSet() {
3129
+ const registry = globalThis;
3130
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
3131
+ }
3132
+ /**
3133
+ * Wrap a cordis plugin apply so the package runs at most once per process.
3134
+ * The first mount registers normally and unmarks when its fiber disposes;
3135
+ * any later mount of the same package name is a no-op.
3136
+ * @param packageName - npm package identity shared by every install source.
3137
+ * @param fn - the original plugin apply.
3138
+ * @returns an apply of the same shape.
3139
+ */
3140
+ function mountOnce(packageName, fn) {
3141
+ return ((...args) => {
3142
+ const mounted = mountedSet();
3143
+ if (mounted.has(packageName)) return;
3144
+ mounted.add(packageName);
3145
+ args[0]?.effect?.(() => () => {
3146
+ mounted.delete(packageName);
3147
+ });
3148
+ return fn(...args);
3149
+ });
3150
+ }
3151
+ //#endregion
3152
+ //#region src/index.ts
3153
+ /** Required services: the route registry, the managed subprocess seam, the workspace registry, and the prompt band. */
3154
+ const inject = [
3155
+ "webServer",
3156
+ "subprocess",
3157
+ "workspaceRegistry",
3158
+ "systemPrompt"
3159
+ ];
3160
+ /** Settings namespace the browser settings card edits (the Host registers it). */
3161
+ const FILEMANAGER_PANEL_SETTINGS_NAMESPACE = "filemgr";
3162
+ /** Order of the announcement section within the tool-guidance band. */
3163
+ const SECTION_ORDER = 210;
3164
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
3165
+ const FILEMANAGER_PANEL_GUIDANCE = "本机已安装 dsh-filemgr 插件(DSH Web GUI 的右侧面板系统):项目会话打开时,聊天区右侧出现「预览」与「文件/变更」两块面板。能力:Explorer 文件树(点击文件在预览面板打开、整行点击展开文件夹、按文件名搜索定位);Preview 多 tab 预览(markdown/html/code/diff/csv/pdf/office/图片/文本等格式,支持源码/预览切换、分屏编辑、保存;markdown 与聊天消息中的 mermaid 代码块会渲染成图表,图源语法错误时回退为代码块);SCM 变更面板(真实 git stage/unstage/discard);面板宽度可拖拽调整(Explorer 220~500px、Preview 340~1200px),双击把手复位默认宽度,折叠状态与宽度按项目持久化(localStorage)。数据源为当前会话工作目录的真实文件系统与真实 git 仓库,宿主进程经 /filemgr/* 路由提供。用户提到「右侧面板 / 预览面板 / 文件树 / 变更面板」时即指本插件,请据此协作。";
3166
+ /**
3167
+ * Mount the panel data services and their routes.
3168
+ * @param ctx - context carrying webServer, subprocess, workspaceRegistry, systemPrompt.
3169
+ */
3170
+ const apply = mountOnce("@lijian-ui/dsh-file-manager", applyImpl);
3171
+ function applyImpl(ctx, config = {}) {
3172
+ const gate = createWorkspaceGate(ctx);
3173
+ const fs = new FsService(gate);
3174
+ const git = new GitService(subprocessRunner(ctx), gate, (root, rel) => fs.delete(root, rel));
3175
+ let current = () => base;
3176
+ const base = { enabled: config.enabled ?? true };
3177
+ let disposePanel;
3178
+ let disposePrompt;
3179
+ const syncPanel = () => {
3180
+ const enabled = current().enabled ?? true;
3181
+ if (disposePanel === void 0 && enabled) {
3182
+ disposePanel = ctx.effect(() => {
3183
+ const disposeRoutes = registerPanelRoutes(ctx, fs, git);
3184
+ return () => disposeRoutes();
3185
+ }, "dsh-filemgr: /filemgr routes");
3186
+ disposePrompt = ctx.effect(() => ctx.systemPrompt.section({
3187
+ name: "plugin:filemgr",
3188
+ order: SECTION_ORDER,
3189
+ text: FILEMANAGER_PANEL_GUIDANCE
3190
+ }), "dsh-filemgr: prompt section");
3191
+ } else if (disposePanel !== void 0 && !enabled) {
3192
+ disposePanel();
3193
+ disposePanel = void 0;
3194
+ disposePrompt?.();
3195
+ disposePrompt = void 0;
3196
+ }
3197
+ };
3198
+ installSettingsSection(ctx, settingsNamespace(FILEMANAGER_PANEL_SETTINGS_NAMESPACE), import_lib.default.object({ enabled: import_lib.default.boolean().default(true) }), base, {
3199
+ setSource: (source) => {
3200
+ current = source;
3201
+ },
3202
+ onChange: () => syncPanel()
3203
+ });
3204
+ syncPanel();
3205
+ }
3206
+ //#endregion
3207
+ export { FILEMANAGER_PANEL_GUIDANCE, FILEMANAGER_PANEL_SETTINGS_NAMESPACE, apply, inject };