@lijian-ui/dsh-file-manager 0.2.2 → 0.2.4

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 (101) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27354 -355
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts +0 -1
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +3 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/mount.d.ts +1 -1
  20. package/lib/types/client/mount.d.ts.map +1 -1
  21. package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
  22. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
  23. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  24. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  25. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  26. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  27. package/lib/types/client/preview/content.d.ts +3 -1
  28. package/lib/types/client/preview/content.d.ts.map +1 -1
  29. package/lib/types/client/preview/office.d.ts +23 -0
  30. package/lib/types/client/preview/office.d.ts.map +1 -0
  31. package/lib/types/client/reference.d.ts +23 -10
  32. package/lib/types/client/reference.d.ts.map +1 -1
  33. package/lib/types/client/store.d.ts.map +1 -1
  34. package/lib/types/host/fs-service.d.ts.map +1 -1
  35. package/lib/types/host/office-preview.d.ts +13 -0
  36. package/lib/types/host/office-preview.d.ts.map +1 -0
  37. package/lib/types/host/routes.d.ts.map +1 -1
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/package.json +19 -4
  40. package/src/client/DockItem.tsx +1 -1
  41. package/src/client/FileManagerSettingsCard.tsx +117 -117
  42. package/src/client/PluginSettingsCard.tsx +337 -337
  43. package/src/client/chat/file-ref.ts +144 -144
  44. package/src/client/chat/mermaid-chat.tsx +102 -102
  45. package/src/client/chat/placeholder-hint.tsx +46 -46
  46. package/src/client/components/ExplorerPanel.tsx +534 -534
  47. package/src/client/components/FileIcon.tsx +46 -46
  48. package/src/client/components/ScmPanel.tsx +480 -480
  49. package/src/client/components/a11y.ts +20 -20
  50. package/src/client/components/icons.tsx +274 -274
  51. package/src/client/components/overlay.tsx +225 -225
  52. package/src/client/drag/DragFileInlay.tsx +89 -89
  53. package/src/client/drag/file-drag.ts +67 -67
  54. package/src/client/drag.ts +154 -154
  55. package/src/client/file-source.ts +34 -0
  56. package/src/client/fileType.ts +32 -5
  57. package/src/client/hooks/useResizableSplit.ts +91 -91
  58. package/src/client/hooks/useStore.ts +15 -15
  59. package/src/client/index.ts +45 -17
  60. package/src/client/layout.ts +523 -523
  61. package/src/client/locales.ts +6 -0
  62. package/src/client/maximize.ts +34 -34
  63. package/src/client/mount.tsx +2 -1
  64. package/src/client/persist.ts +194 -194
  65. package/src/client/picker/FilePickerModal.tsx +526 -507
  66. package/src/client/picker/file-picker.ts +86 -86
  67. package/src/client/preview/PreviewPanel.tsx +8 -1
  68. package/src/client/preview/PreviewTabs.tsx +159 -159
  69. package/src/client/preview/PreviewToolbar.tsx +3 -2
  70. package/src/client/preview/content.tsx +7 -2
  71. package/src/client/preview/markdown.ts +351 -351
  72. package/src/client/preview/mermaid.ts +274 -274
  73. package/src/client/preview/office.tsx +321 -0
  74. package/src/client/reference.ts +62 -8
  75. package/src/client/settings-card.module.css +316 -316
  76. package/src/client/settings-form.ts +451 -451
  77. package/src/client/store.ts +17 -12
  78. package/src/client/styles/drag.module.css +30 -30
  79. package/src/client/styles/explorer.module.css +371 -371
  80. package/src/client/styles/picker.module.css +344 -344
  81. package/src/client/styles/preview.module.css +18 -11
  82. package/src/client/styles/scm.module.css +380 -380
  83. package/src/client/styles/tokens.module.css +376 -367
  84. package/src/core/types.ts +162 -162
  85. package/src/host/fs-service.ts +7 -1
  86. package/src/host/gate.ts +73 -73
  87. package/src/host/git-runner.ts +116 -116
  88. package/src/host/git-service.ts +395 -395
  89. package/src/host/loopback.ts +63 -63
  90. package/src/host/office-preview.ts +399 -0
  91. package/src/host/poll-guard.ts +109 -109
  92. package/src/host/routes.ts +72 -1
  93. package/src/index.ts +3 -1
  94. package/src/mount-once.ts +48 -48
  95. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  96. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  97. package/lib/types/client/floating.d.ts +0 -27
  98. package/lib/types/client/floating.d.ts.map +0 -1
  99. package/lib/types/client/mention.d.ts +0 -44
  100. package/lib/types/client/mention.d.ts.map +0 -1
  101. package/src/client/styles/chip.module.css +0 -38
package/src/core/types.ts CHANGED
@@ -1,162 +1,162 @@
1
- /**
2
- * Wire vocabulary shared by the host data services and the browser client:
3
- * the request/response shapes of the /filemgr/* routes and the stable
4
- * error codes the client maps onto copy. Pure types — no runtime code.
5
- *
6
- * The design follows FileManager's right-panel system (Apache-2.0, re-implemented
7
- * from measured behavior, not copied code): the explorer column is a lazy
8
- * directory tree, the preview column opens files as tabs, and the SCM tab
9
- * reads the repository's real working-tree state.
10
- * @module dsh-filemgr/core/types
11
- */
12
-
13
- /** Envelope every /filemgr JSON response carries. */
14
- export type PanelEnvelope<T> =
15
- | { ok: true; value: T }
16
- | { ok: false; error: PanelError }
17
-
18
- /** Stable rejection codes (host-authored; the client prefers its own copy by code). */
19
- export type PanelErrorCode =
20
- | 'workspace-unknown'
21
- | 'path-outside-root'
22
- | 'not-found'
23
- | 'is-directory'
24
- | 'read-failed'
25
- | 'write-conflict'
26
- | 'write-failed'
27
- | 'search-failed'
28
- | 'git-unavailable'
29
- | 'git-failed'
30
- | 'internal'
31
-
32
- /** One rejection with a human-readable host message. */
33
- export interface PanelError {
34
- code: PanelErrorCode
35
- message: string
36
- }
37
-
38
- /** One filesystem entry in a directory listing (path is relative to the root). */
39
- export interface FsEntry {
40
- /** Display name (basename). */
41
- name: string
42
- /** Path relative to the project root, '/' separated; '' is the root itself. */
43
- path: string
44
- isDir: boolean
45
- /** Size in bytes (0 for directories). */
46
- size: number
47
- /** Last-modified epoch millis (0 for directories). */
48
- mtime: number
49
- }
50
-
51
- /** The listing of one directory. */
52
- export interface DirListing {
53
- /** Canonical root path the listing is relative to. */
54
- root: string
55
- /** Sorted entries (directories first, then files, both case-insensitive alpha). */
56
- entries: FsEntry[]
57
- /** True when the entry count was capped at MAX_ENTRIES (a giant flat dir). */
58
- truncated: boolean
59
- }
60
-
61
- /** The result of reading one file for preview. */
62
- export interface FileRead {
63
- /** Text content (decoded utf-8), or a data URL for image kinds. */
64
- content: string
65
- /** True when the text was truncated at the preview ceiling. */
66
- truncated: boolean
67
- /** Total size in bytes. */
68
- size: number
69
- /** Last-modified epoch millis (the write-conflict base). */
70
- mtime: number
71
- /** Image dimensions when the file is an image (host decodes via probe). */
72
- image?: { width: number; height: number }
73
- }
74
-
75
- /** One filename-search hit. */
76
- export interface SearchHit {
77
- /** Path relative to the root. */
78
- path: string
79
- /** Display name (basename). */
80
- name: string
81
- isDir: boolean
82
- }
83
-
84
- /** The filename-search result. */
85
- export interface SearchView {
86
- /** The query the hits were ranked for. */
87
- query: string
88
- hits: SearchHit[]
89
- /** True when the hit cap cut the result stream. */
90
- truncated: boolean
91
- }
92
-
93
- /** Working-tree state of one change row. */
94
- export type GitFileState = 'created' | 'modified' | 'deleted' | 'renamed' | 'conflicted' | 'untracked' | 'unknown'
95
-
96
- /** One change row in a git status. */
97
- export interface GitChangeRow {
98
- /** Path relative to the repo root. */
99
- path: string
100
- /** Original path for renames (old -> new). */
101
- oldPath?: string
102
- state: GitFileState
103
- /** True when the change sits in the index (staged). */
104
- staged: boolean
105
- }
106
-
107
- /** The git status view of one repo root. */
108
- export interface GitStatusView {
109
- /** Repo root (git rev-parse --show-toplevel). */
110
- root: string
111
- /** Current branch name; '' when detached. */
112
- branch: string
113
- /** Staged changes (index vs HEAD). */
114
- staged: GitChangeRow[]
115
- /** Unstaged changes (worktree vs index). */
116
- unstaged: GitChangeRow[]
117
- /** Untracked files (worktree, not in index). */
118
- untracked: GitChangeRow[]
119
- }
120
-
121
- /** The result of a stage/unstage/discard batch. */
122
- export interface GitBatchResult {
123
- /** Rows the operation actually changed (post-op status snapshot for these paths). */
124
- applied: string[]
125
- /** Paths the host refused to touch. */
126
- failed: string[]
127
- }
128
-
129
- /** One preview tab identity as persisted. */
130
- export interface PreviewTabMeta {
131
- /** Stable tab id (root + path + type). */
132
- id: string
133
- /** Display title (basename). */
134
- title: string
135
- /** Project root the file belongs to. */
136
- root: string
137
- /** File path relative to the root. */
138
- path: string
139
- contentType: PreviewContentType
140
- /** Whether the tab carries unsaved edits. */
141
- dirty?: boolean
142
- /** Last write timestamp the tab's content was based on (conflict base). */
143
- mtime?: number
144
- /** When the tab was last touched (LRU ordering within a scope). */
145
- savedAt: number
146
- }
147
-
148
- /** Preview content kinds the panel can render. */
149
- export type PreviewContentType =
150
- | 'markdown'
151
- | 'html'
152
- | 'code'
153
- | 'diff'
154
- | 'csv'
155
- | 'pdf'
156
- | 'word'
157
- | 'excel'
158
- | 'ppt'
159
- | 'image'
160
- | 'text'
161
- | 'url'
162
- | 'unsupported'
1
+ /**
2
+ * Wire vocabulary shared by the host data services and the browser client:
3
+ * the request/response shapes of the /filemgr/* routes and the stable
4
+ * error codes the client maps onto copy. Pure types — no runtime code.
5
+ *
6
+ * The design follows FileManager's right-panel system (Apache-2.0, re-implemented
7
+ * from measured behavior, not copied code): the explorer column is a lazy
8
+ * directory tree, the preview column opens files as tabs, and the SCM tab
9
+ * reads the repository's real working-tree state.
10
+ * @module dsh-filemgr/core/types
11
+ */
12
+
13
+ /** Envelope every /filemgr JSON response carries. */
14
+ export type PanelEnvelope<T> =
15
+ | { ok: true; value: T }
16
+ | { ok: false; error: PanelError }
17
+
18
+ /** Stable rejection codes (host-authored; the client prefers its own copy by code). */
19
+ export type PanelErrorCode =
20
+ | 'workspace-unknown'
21
+ | 'path-outside-root'
22
+ | 'not-found'
23
+ | 'is-directory'
24
+ | 'read-failed'
25
+ | 'write-conflict'
26
+ | 'write-failed'
27
+ | 'search-failed'
28
+ | 'git-unavailable'
29
+ | 'git-failed'
30
+ | 'internal'
31
+
32
+ /** One rejection with a human-readable host message. */
33
+ export interface PanelError {
34
+ code: PanelErrorCode
35
+ message: string
36
+ }
37
+
38
+ /** One filesystem entry in a directory listing (path is relative to the root). */
39
+ export interface FsEntry {
40
+ /** Display name (basename). */
41
+ name: string
42
+ /** Path relative to the project root, '/' separated; '' is the root itself. */
43
+ path: string
44
+ isDir: boolean
45
+ /** Size in bytes (0 for directories). */
46
+ size: number
47
+ /** Last-modified epoch millis (0 for directories). */
48
+ mtime: number
49
+ }
50
+
51
+ /** The listing of one directory. */
52
+ export interface DirListing {
53
+ /** Canonical root path the listing is relative to. */
54
+ root: string
55
+ /** Sorted entries (directories first, then files, both case-insensitive alpha). */
56
+ entries: FsEntry[]
57
+ /** True when the entry count was capped at MAX_ENTRIES (a giant flat dir). */
58
+ truncated: boolean
59
+ }
60
+
61
+ /** The result of reading one file for preview. */
62
+ export interface FileRead {
63
+ /** Text content (decoded utf-8), or a data URL for image kinds. */
64
+ content: string
65
+ /** True when the text was truncated at the preview ceiling. */
66
+ truncated: boolean
67
+ /** Total size in bytes. */
68
+ size: number
69
+ /** Last-modified epoch millis (the write-conflict base). */
70
+ mtime: number
71
+ /** Image dimensions when the file is an image (host decodes via probe). */
72
+ image?: { width: number; height: number }
73
+ }
74
+
75
+ /** One filename-search hit. */
76
+ export interface SearchHit {
77
+ /** Path relative to the root. */
78
+ path: string
79
+ /** Display name (basename). */
80
+ name: string
81
+ isDir: boolean
82
+ }
83
+
84
+ /** The filename-search result. */
85
+ export interface SearchView {
86
+ /** The query the hits were ranked for. */
87
+ query: string
88
+ hits: SearchHit[]
89
+ /** True when the hit cap cut the result stream. */
90
+ truncated: boolean
91
+ }
92
+
93
+ /** Working-tree state of one change row. */
94
+ export type GitFileState = 'created' | 'modified' | 'deleted' | 'renamed' | 'conflicted' | 'untracked' | 'unknown'
95
+
96
+ /** One change row in a git status. */
97
+ export interface GitChangeRow {
98
+ /** Path relative to the repo root. */
99
+ path: string
100
+ /** Original path for renames (old -> new). */
101
+ oldPath?: string
102
+ state: GitFileState
103
+ /** True when the change sits in the index (staged). */
104
+ staged: boolean
105
+ }
106
+
107
+ /** The git status view of one repo root. */
108
+ export interface GitStatusView {
109
+ /** Repo root (git rev-parse --show-toplevel). */
110
+ root: string
111
+ /** Current branch name; '' when detached. */
112
+ branch: string
113
+ /** Staged changes (index vs HEAD). */
114
+ staged: GitChangeRow[]
115
+ /** Unstaged changes (worktree vs index). */
116
+ unstaged: GitChangeRow[]
117
+ /** Untracked files (worktree, not in index). */
118
+ untracked: GitChangeRow[]
119
+ }
120
+
121
+ /** The result of a stage/unstage/discard batch. */
122
+ export interface GitBatchResult {
123
+ /** Rows the operation actually changed (post-op status snapshot for these paths). */
124
+ applied: string[]
125
+ /** Paths the host refused to touch. */
126
+ failed: string[]
127
+ }
128
+
129
+ /** One preview tab identity as persisted. */
130
+ export interface PreviewTabMeta {
131
+ /** Stable tab id (root + path + type). */
132
+ id: string
133
+ /** Display title (basename). */
134
+ title: string
135
+ /** Project root the file belongs to. */
136
+ root: string
137
+ /** File path relative to the root. */
138
+ path: string
139
+ contentType: PreviewContentType
140
+ /** Whether the tab carries unsaved edits. */
141
+ dirty?: boolean
142
+ /** Last write timestamp the tab's content was based on (conflict base). */
143
+ mtime?: number
144
+ /** When the tab was last touched (LRU ordering within a scope). */
145
+ savedAt: number
146
+ }
147
+
148
+ /** Preview content kinds the panel can render. */
149
+ export type PreviewContentType =
150
+ | 'markdown'
151
+ | 'html'
152
+ | 'code'
153
+ | 'diff'
154
+ | 'csv'
155
+ | 'pdf'
156
+ | 'word'
157
+ | 'excel'
158
+ | 'ppt'
159
+ | 'image'
160
+ | 'text'
161
+ | 'url'
162
+ | 'unsupported'
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { open, readdir, readFile, realpath, rename as renameFile, stat, writeFile, rm, mkdir } from 'node:fs/promises'
20
20
  import type { Dirent } from 'node:fs'
21
- import { join, dirname, relative } from 'node:path'
21
+ import { join, dirname, relative, basename } from 'node:path'
22
22
  import type { DirListing, FileRead, FsEntry, PanelError, SearchHit, SearchView } from '../core/types.ts'
23
23
  import { isPathInside, type GateVerdict, type WorkspaceGate } from './gate.ts'
24
24
 
@@ -156,6 +156,12 @@ function mimeByExtension(rel: string): string | undefined {
156
156
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
157
157
  webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', avif: 'image/avif', bmp: 'image/bmp',
158
158
  pdf: 'application/pdf',
159
+ doc: 'application/msword',
160
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
161
+ xls: 'application/vnd.ms-excel',
162
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
163
+ ppt: 'application/vnd.ms-powerpoint',
164
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
159
165
  }
160
166
  return byExt[ext]
161
167
  }
package/src/host/gate.ts CHANGED
@@ -1,74 +1,74 @@
1
- /**
2
- * Workspace gate for the /filemgr routes: canonicalize the requested
3
- * project root and require it to be a registered workspace (or a directory
4
- * inside one). This is the security boundary of the panel's fs/git routes -
5
- * the browser may only read and mutate files under registered workspace
6
- * roots, never arbitrary host directories.
7
- * @module dsh-filemgr/host/gate
8
- */
9
-
10
- import { realpath } from 'node:fs/promises'
11
- import type { Context } from '@deepseek-ai/cordis'
12
- import type {} from '@deepseek-ai/dsh-workspace'
13
- import type { PanelError } from '../core/types.ts'
14
-
15
- /** The gate verdict for one project root. */
16
- export type GateVerdict = { ok: true; canonical: string } | { ok: false; error: PanelError }
17
-
18
- /** The workspace-membership check the services run on every request. */
19
- export type WorkspaceGate = (root: string) => Promise<GateVerdict>
20
-
21
- /**
22
- * Normalize a path for prefix comparison: collapse Windows separators to `/`
23
- * and drop any trailing slash. On win32 the whole path is also lower-cased so
24
- * a case-insensitive FS cannot trip the membership check (the drive letter and
25
- * every segment are compared case-insensitively). On any other platform the
26
- * path separator and case are left untouched.
27
- */
28
- export function normalizeForPrefix(value: string): string {
29
- const normalized = value.replaceAll('\\', '/').replace(/\/+$/, '')
30
- return process.platform === 'win32' ? normalized.toLowerCase() : normalized
31
- }
32
-
33
- /**
34
- * The canonical prefix check: child must live inside (or equal) the root.
35
- * Separator- and case-robust on Windows: `path.join` yields backslashes while
36
- * git (`rev-parse --show-toplevel`) and the browser (`./x`) yield forward
37
- * slashes, so both sides are normalized to forward slashes before comparing,
38
- * and the comparison is case-insensitive on win32 (the FS is case-insensitive).
39
- */
40
- export function isPathInside(root: string, child: string): boolean {
41
- if (root === '' || child === '') return false
42
- const normRoot = normalizeForPrefix(root)
43
- const normChild = normalizeForPrefix(child)
44
- if (normChild === normRoot) return true
45
- return normChild.startsWith(`${normRoot}/`)
46
- }
47
-
48
- /**
49
- * Production gate: canonicalize the requested root and require it to be a
50
- * registered workspace path (or a subdirectory of one). The host's workspace
51
- * registry owns canonicalization, so an unowned path is rejected outright.
52
- * @param ctx - context carrying the workspace service.
53
- * @returns the gate.
54
- */
55
- export function createWorkspaceGate(ctx: Context): WorkspaceGate {
56
- return async (root) => {
57
- if (typeof root !== 'string' || root === '') {
58
- return { ok: false, error: { code: 'workspace-unknown', message: 'empty project root' } }
59
- }
60
- let canonical: string
61
- try {
62
- canonical = await realpath(root)
63
- } catch {
64
- return { ok: false, error: { code: 'workspace-unknown', message: 'path does not resolve on disk' } }
65
- }
66
- const workspaces = ctx.workspaceRegistry.list()
67
- for (const workspace of workspaces) {
68
- if (isPathInside(workspace.path, canonical)) {
69
- return { ok: true, canonical }
70
- }
71
- }
72
- return { ok: false, error: { code: 'workspace-unknown', message: 'path is not inside a registered workspace' } }
73
- }
1
+ /**
2
+ * Workspace gate for the /filemgr routes: canonicalize the requested
3
+ * project root and require it to be a registered workspace (or a directory
4
+ * inside one). This is the security boundary of the panel's fs/git routes -
5
+ * the browser may only read and mutate files under registered workspace
6
+ * roots, never arbitrary host directories.
7
+ * @module dsh-filemgr/host/gate
8
+ */
9
+
10
+ import { realpath } from 'node:fs/promises'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type {} from '@deepseek-ai/dsh-workspace'
13
+ import type { PanelError } from '../core/types.ts'
14
+
15
+ /** The gate verdict for one project root. */
16
+ export type GateVerdict = { ok: true; canonical: string } | { ok: false; error: PanelError }
17
+
18
+ /** The workspace-membership check the services run on every request. */
19
+ export type WorkspaceGate = (root: string) => Promise<GateVerdict>
20
+
21
+ /**
22
+ * Normalize a path for prefix comparison: collapse Windows separators to `/`
23
+ * and drop any trailing slash. On win32 the whole path is also lower-cased so
24
+ * a case-insensitive FS cannot trip the membership check (the drive letter and
25
+ * every segment are compared case-insensitively). On any other platform the
26
+ * path separator and case are left untouched.
27
+ */
28
+ export function normalizeForPrefix(value: string): string {
29
+ const normalized = value.replaceAll('\\', '/').replace(/\/+$/, '')
30
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized
31
+ }
32
+
33
+ /**
34
+ * The canonical prefix check: child must live inside (or equal) the root.
35
+ * Separator- and case-robust on Windows: `path.join` yields backslashes while
36
+ * git (`rev-parse --show-toplevel`) and the browser (`./x`) yield forward
37
+ * slashes, so both sides are normalized to forward slashes before comparing,
38
+ * and the comparison is case-insensitive on win32 (the FS is case-insensitive).
39
+ */
40
+ export function isPathInside(root: string, child: string): boolean {
41
+ if (root === '' || child === '') return false
42
+ const normRoot = normalizeForPrefix(root)
43
+ const normChild = normalizeForPrefix(child)
44
+ if (normChild === normRoot) return true
45
+ return normChild.startsWith(`${normRoot}/`)
46
+ }
47
+
48
+ /**
49
+ * Production gate: canonicalize the requested root and require it to be a
50
+ * registered workspace path (or a subdirectory of one). The host's workspace
51
+ * registry owns canonicalization, so an unowned path is rejected outright.
52
+ * @param ctx - context carrying the workspace service.
53
+ * @returns the gate.
54
+ */
55
+ export function createWorkspaceGate(ctx: Context): WorkspaceGate {
56
+ return async (root) => {
57
+ if (typeof root !== 'string' || root === '') {
58
+ return { ok: false, error: { code: 'workspace-unknown', message: 'empty project root' } }
59
+ }
60
+ let canonical: string
61
+ try {
62
+ canonical = await realpath(root)
63
+ } catch {
64
+ return { ok: false, error: { code: 'workspace-unknown', message: 'path does not resolve on disk' } }
65
+ }
66
+ const workspaces = ctx.workspaceRegistry.list()
67
+ for (const workspace of workspaces) {
68
+ if (isPathInside(workspace.path, canonical)) {
69
+ return { ok: true, canonical }
70
+ }
71
+ }
72
+ return { ok: false, error: { code: 'workspace-unknown', message: 'path is not inside a registered workspace' } }
73
+ }
74
74
  }