dsh-vscode-workbench 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.en.md +15 -6
  2. package/README.md +17 -6
  3. package/lib/client.js +5118 -728
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +899 -57
  6. package/lib/index.js.map +1 -1
  7. package/lib/types/client/ActivityBar.d.ts +24 -0
  8. package/lib/types/client/DshConversationPanel.d.ts +1 -1
  9. package/lib/types/client/FileIcon.d.ts +8 -0
  10. package/lib/types/client/Icon.d.ts +1 -3
  11. package/lib/types/client/MonacoEditor.d.ts +36 -0
  12. package/lib/types/client/Workbench.d.ts +27 -7
  13. package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
  14. package/lib/types/client/api.d.ts +2 -0
  15. package/lib/types/client/documentPreview.d.ts +10 -2
  16. package/lib/types/client/dshComposer.d.ts +21 -0
  17. package/lib/types/client/dshNavigation.d.ts +46 -0
  18. package/lib/types/client/fileIconMap.d.ts +2 -0
  19. package/lib/types/client/gitError.d.ts +2 -0
  20. package/lib/types/client/lsp.d.ts +27 -0
  21. package/lib/types/client/searchHighlight.d.ts +5 -0
  22. package/lib/types/client/typescriptProject.d.ts +20 -0
  23. package/lib/types/context.d.ts +15 -0
  24. package/lib/types/host.d.ts +37 -0
  25. package/lib/types/lsp.d.ts +54 -0
  26. package/lib/types/office.d.ts +17 -0
  27. package/lib/types/types.d.ts +5 -0
  28. package/lib/types/watch-ignore.d.ts +12 -0
  29. package/package.json +21 -17
  30. package/src/client/ActivityBar.tsx +152 -0
  31. package/src/client/DshConversationPanel.tsx +15 -12
  32. package/src/client/FileIcon.tsx +6 -0
  33. package/src/client/Icon.tsx +1 -14
  34. package/src/client/MonacoEditor.tsx +187 -19
  35. package/src/client/TerminalPanel.tsx +6 -3
  36. package/src/client/Workbench.tsx +342 -78
  37. package/src/client/WorkspaceSwitcher.tsx +53 -0
  38. package/src/client/api.ts +27 -1
  39. package/src/client/documentPreview.tsx +92 -5
  40. package/src/client/dshComposer.ts +73 -0
  41. package/src/client/dshNavigation.ts +81 -0
  42. package/src/client/fileIconMap.ts +7 -0
  43. package/src/client/gitError.ts +5 -0
  44. package/src/client/index.tsx +28 -14
  45. package/src/client/lsp.ts +300 -0
  46. package/src/client/searchHighlight.ts +6 -0
  47. package/src/client/styles.ts +31 -30
  48. package/src/client/typescriptProject.ts +81 -0
  49. package/src/context.ts +8 -0
  50. package/src/host.ts +100 -15
  51. package/src/index.ts +123 -12
  52. package/src/lsp.ts +170 -0
  53. package/src/office.ts +95 -0
  54. package/src/types.ts +2 -0
  55. package/src/watch-ignore.ts +84 -0
@@ -0,0 +1,81 @@
1
+ import { api } from './api.ts'
2
+
3
+ type Monaco = typeof import('monaco-editor')
4
+
5
+ /** Settings key for the TS/JS language support; nothing is indexed until the user installs it in settings. */
6
+ export const TS_INTELLISENSE_KEY = 'dvw:lsp:typescript'
7
+
8
+ export function tsIntellisenseEnabled(): boolean { try { return localStorage.getItem(TS_INTELLISENSE_KEY) === 'on' } catch { return false } }
9
+
10
+ /** Model URI for a workspace file; must match MonacoEditor's `file:///<cwd>/<path>` tab URIs. */
11
+ export function projectModelPath(cwd: string, path: string): string { return `file:///${`${cwd}/${path}`.replace(/\\/g, '/').replace(/^\/+/, '')}` }
12
+
13
+ /** Workspace-relative path for a project model URI, or undefined when it lives outside `cwd`. */
14
+ export function workspacePathFromModel(cwd: string, uri: string): string | undefined {
15
+ const prefix = projectModelPath(cwd, '')
16
+ const decoded = decodeURIComponent(uri)
17
+ const lower = (value: string) => value.toLowerCase()
18
+ if (!lower(decoded).startsWith(lower(prefix))) return undefined
19
+ const path = decoded.slice(prefix.length)
20
+ return path === '' ? undefined : path
21
+ }
22
+
23
+ const COMPILER = (monaco: Monaco) => ({
24
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
25
+ module: monaco.languages.typescript.ModuleKind.ESNext,
26
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
27
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
28
+ allowJs: true, checkJs: false, allowNonTsExtensions: true, allowImportingTsExtensions: true,
29
+ esModuleInterop: true, resolveJsonModule: true, noEmit: true, skipLibCheck: true, strict: true,
30
+ })
31
+
32
+ let indexed: { cwd: string; uris: string[] } | undefined
33
+ let request = 0
34
+
35
+ /** Configure Monaco's TypeScript worker and load the workspace's TS/JS files so definitions, references, Peek and signature help resolve across files. */
36
+ export async function indexTypeScriptProject(monaco: Monaco, sessionId: string, cwd: string): Promise<{ files: number; truncated: boolean }> {
37
+ const ts = monaco.languages.typescript
38
+ for (const defaults of [ts.typescriptDefaults, ts.javascriptDefaults]) {
39
+ defaults.setCompilerOptions(COMPILER(monaco))
40
+ defaults.setEagerModelSync(true)
41
+ // Unresolved package imports are expected without node_modules typings; keep only syntax errors.
42
+ defaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false })
43
+ }
44
+ const id = ++request
45
+ const { files, truncated } = await api<{ files: { path: string; content: string }[]; truncated: boolean }>('project.sources', { sessionId })
46
+ if (id !== request) return { files: 0, truncated }
47
+ disposeTypeScriptProject(monaco)
48
+ const uris: string[] = []
49
+ for (const file of files) {
50
+ const uri = monaco.Uri.parse(projectModelPath(cwd, file.path))
51
+ // Open tabs own their model and draft; never overwrite them with the disk copy.
52
+ if (monaco.editor.getModel(uri) !== null) continue
53
+ monaco.editor.createModel(file.content, undefined, uri)
54
+ uris.push(uri.toString())
55
+ }
56
+ indexed = { cwd, uris }
57
+ return { files: files.length, truncated }
58
+ }
59
+
60
+ /** Drop the index models that no editor or tab is using. */
61
+ export function disposeTypeScriptProject(monaco: Monaco): void {
62
+ if (indexed === undefined) return
63
+ for (const uri of indexed.uris) { const model = monaco.editor.getModel(monaco.Uri.parse(uri)); if (model !== null && !model.isAttachedToEditor()) model.dispose() }
64
+ indexed = undefined
65
+ }
66
+
67
+ /** True for models the project index created, which tab cleanup must keep. */
68
+ export function isIndexedModel(uri: string): boolean { return indexed?.uris.includes(uri) ?? false }
69
+
70
+ /** Refresh index models for files changed on disk, unless an editor is showing them. */
71
+ export async function refreshIndexedFiles(monaco: Monaco, sessionId: string, cwd: string, paths: string[]): Promise<void> {
72
+ if (indexed === undefined || indexed.cwd !== cwd) return
73
+ for (const path of paths) {
74
+ const uri = monaco.Uri.parse(projectModelPath(cwd, path))
75
+ const model = monaco.editor.getModel(uri)
76
+ if (model === null || model.isAttachedToEditor() || !isIndexedModel(uri.toString())) continue
77
+ const document = await api<{ content: string }>('fs.read', { sessionId, path }).catch(() => undefined)
78
+ if (document === undefined) { model.dispose(); indexed.uris = indexed.uris.filter(value => value !== uri.toString()); continue }
79
+ if (model.getValue() !== document.content) model.setValue(document.content)
80
+ }
81
+ }
package/src/context.ts CHANGED
@@ -9,6 +9,14 @@ export interface WorkbenchWebServer {
9
9
  }
10
10
  export interface WorkbenchSessions { get(id: string): Session | undefined }
11
11
  export interface WorkbenchConnection { requestRejection(req: IncomingMessage): 401 | 403 | undefined }
12
+ /** Minimal shape of DSH's host directory chooser; only the native capability is usable here. */
13
+ export interface DirectoryPickerLike {
14
+ capability(): { kind: string; pick?(signal: AbortSignal): Promise<string | null> }
15
+ }
16
+ /** Minimal shape of DSH's durable workspace registry. */
17
+ export interface WorkspaceRegistryLike {
18
+ create(path: string, title?: string): Promise<{ id: string; path: string; title: string }>
19
+ }
12
20
  export type HostContext = CordisContext & {
13
21
  webServer: WorkbenchWebServer
14
22
  sessions: WorkbenchSessions
package/src/host.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { spawn } from 'node:child_process'
3
3
  import { createRequire } from 'node:module'
4
- import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
4
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
5
5
  import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
6
6
  import { constants } from 'node:fs'
7
7
  import type { HostContext } from './context.ts'
8
- import type { FileDocument, FileEntry, GitChange, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch, SearchRequest } from './types.ts'
8
+ import type { FileDocument, FileEntry, GitBranch, GitChange, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch, SearchRequest } from './types.ts'
9
9
  import { WorkbenchError } from './wire.ts'
10
10
 
11
11
  const require = createRequire(import.meta.url)
@@ -50,6 +50,51 @@ function revision(content: Buffer, mtimeMs: number): string {
50
50
  return createHash('sha256').update(content).update(String(mtimeMs)).digest('hex')
51
51
  }
52
52
 
53
+ const PROJECT_SOURCE = /\.(?:[cm]?[jt]sx?|d\.ts)$/i
54
+ const PROJECT_CONFIG = new Set(['tsconfig.json', 'jsconfig.json', 'package.json'])
55
+ const PROJECT_SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', '.turbo', '.cache', 'vendor', 'third_party'])
56
+
57
+ /** Add a folder picked in the OS chooser to DSH's workspace list, reusing an existing registration. */
58
+ export async function openWorkspaceFromPicker(
59
+ picker: import('./context.ts').DirectoryPickerLike | undefined,
60
+ registry: import('./context.ts').WorkspaceRegistryLike | undefined,
61
+ signal: AbortSignal,
62
+ ): Promise<{ opened: boolean; workspace?: { id: string; path: string; title: string } }> {
63
+ if (picker === undefined) throw new WorkbenchError('picker-unavailable', '当前环境没有可用的文件夹选择器', 409)
64
+ const capability = picker.capability()
65
+ if (capability.kind !== 'native' || capability.pick === undefined) throw new WorkbenchError('picker-browse', '浏览器模式暂不支持从工作台选择文件夹,请使用 DSH 侧栏的「添加工作区」', 409)
66
+ const picked = await capability.pick(signal)
67
+ if (picked === null) return { opened: false }
68
+ if (registry === undefined) throw new WorkbenchError('workspace-unavailable', '工作区服务不可用', 503)
69
+ const workspace = await registry.create(picked)
70
+ return { opened: true, workspace: { id: workspace.id, path: workspace.path, title: workspace.title } }
71
+ }
72
+
73
+ /** TS/JS sources the editor's language service indexes, bounded so huge repos stay responsive. */
74
+ export async function projectSources(cwd: string, limits: { files?: number; bytes?: number; fileBytes?: number } = {}): Promise<{ files: { path: string; content: string }[]; truncated: boolean }> {
75
+ const { files: maxFiles = 2000, bytes: maxBytes = 24 * 1024 * 1024, fileBytes = 512 * 1024 } = limits
76
+ const { root } = await workspacePath(cwd, '')
77
+ const files: { path: string; content: string }[] = []; let total = 0; let truncated = false
78
+ const queue = [root]
79
+ while (queue.length > 0) {
80
+ const directory = queue.shift()!
81
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => [])
82
+ entries.sort((a, b) => a.name.localeCompare(b.name))
83
+ for (const entry of entries) {
84
+ const full = join(directory, entry.name)
85
+ if (entry.isDirectory()) { if (!PROJECT_SKIP.has(entry.name) && !entry.name.startsWith('.')) queue.push(full); continue }
86
+ if (!entry.isFile() || !(PROJECT_SOURCE.test(entry.name) || PROJECT_CONFIG.has(entry.name))) continue
87
+ const info = await stat(full).catch(() => undefined)
88
+ if (info === undefined || info.size > fileBytes) continue
89
+ if (files.length >= maxFiles || total + info.size > maxBytes) { truncated = true; return { files, truncated } }
90
+ const content = await readFile(full, 'utf8').catch(() => undefined)
91
+ if (content === undefined) continue
92
+ total += info.size; files.push({ path: relativePath(root, full), content })
93
+ }
94
+ }
95
+ return { files, truncated }
96
+ }
97
+
53
98
  export async function listFiles(cwd: string, path: string): Promise<FileEntry[]> {
54
99
  const { root, absolute } = await workspacePath(cwd, path)
55
100
  const values = await readdir(absolute, { withFileTypes: true })
@@ -154,12 +199,15 @@ export async function deleteEntry(cwd: string, path: string): Promise<void> {
154
199
  }
155
200
 
156
201
  const IMAGE_TYPES:Record<string,string>={'.avif':'image/avif','.bmp':'image/bmp','.gif':'image/gif','.ico':'image/x-icon','.jpeg':'image/jpeg','.jpg':'image/jpeg','.png':'image/png','.svg':'image/svg+xml','.webp':'image/webp'}
157
- const PREVIEW_TYPES:Record<string,string>={...IMAGE_TYPES,'.pdf':'application/pdf','.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document','.odt':'application/vnd.oasis.opendocument.text'}
202
+ const PREVIEW_TYPES:Record<string,string>={...IMAGE_TYPES,'.pdf':'application/pdf','.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document','.odt':'application/vnd.oasis.opendocument.text','.xls':'application/vnd.ms-excel','.xlsx':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','.xlsm':'application/vnd.ms-excel.sheet.macroEnabled.12','.ods':'application/vnd.oasis.opendocument.spreadsheet','.pptx':'application/vnd.openxmlformats-officedocument.presentationml.presentation','.pptm':'application/vnd.ms-powerpoint.presentation.macroEnabled.12'}
158
203
  /** Resolve an image inside the workspace for protected browser preview. */
159
204
  export async function workspaceImage(cwd:string,path:string):Promise<{path:string;type:string}>{const resolved=await workspacePath(cwd,path);const type=IMAGE_TYPES[extname(resolved.absolute).toLowerCase()];if(type===undefined)throw new WorkbenchError('unsupported-media','file is not a supported image',415);return {path:resolved.absolute,type}}
160
205
  /** Resolve a supported preview file inside the workspace. */
161
206
  export async function workspacePreviewFile(cwd:string,path:string):Promise<{path:string;type:string}>{const resolved=await workspacePath(cwd,path);const type=PREVIEW_TYPES[extname(resolved.absolute).toLowerCase()];if(type===undefined)throw new WorkbenchError('unsupported-media','file is not a supported preview format',415);return {path:resolved.absolute,type}}
162
207
 
208
+ /** Resolve a regular workspace file for drag-out downloads. */
209
+ export async function workspaceDownloadFile(cwd:string,path:string):Promise<{path:string;name:string;size:number}>{const resolved=await workspacePath(cwd,path);const info=await stat(resolved.absolute);if(!info.isFile())throw new WorkbenchError('not-file','only files can be downloaded',400);return {path:resolved.absolute,name:basename(resolved.absolute),size:info.size}}
210
+
163
211
  /** Resolve the PDF.js worker shipped with this plugin's runtime dependency. */
164
212
  export function pdfWorkerAsset(): { path: string; type: string } {
165
213
  return { path: require.resolve('pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'text/javascript; charset=utf-8' }
@@ -199,7 +247,7 @@ export async function searchWorkspace(cwd: string, request: SearchRequest, signa
199
247
  const event = JSON.parse(line) as { type: string; data?: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: Array<{ start: number; end: number; match?: { text?: string } }> } }
200
248
  if (event.type !== 'match' || event.data?.path?.text === undefined || event.data.lines?.text === undefined) continue
201
249
  for (const hit of event.data.submatches ?? []) {
202
- matches.push({ path: event.data.path.text.replace(/^\.\//, ''), line: event.data.line_number ?? 1, column: hit.start + 1, preview: event.data.lines.text.trimEnd(), match: hit.match?.text ?? '' })
250
+ matches.push({ path: event.data.path.text.replace(/\\/g, '/').replace(/^\.\//, ''), line: event.data.line_number ?? 1, column: hit.start + 1, preview: event.data.lines.text.trimEnd(), match: hit.match?.text ?? '' })
203
251
  if (matches.length >= 2000) return { matches, limited: true }
204
252
  }
205
253
  }
@@ -227,10 +275,14 @@ async function gitObject(repository: string, spec: string): Promise<string> {
227
275
  return result.code === 0 ? result.stdout : ''
228
276
  }
229
277
 
230
- async function commitFiles(repository: string, hash: string): Promise<GitCommitFile[]> {
278
+ async function commitParent(repository: string, hash: string): Promise<string | undefined> {
231
279
  const parent=await run('git',['rev-parse','--verify',`${hash}^1`],repository)
232
- const result = parent.code===0
233
- ? await run('git',['diff','--name-status','-z','-M',parent.stdout.trim(),hash,'--'],repository)
280
+ return parent.code===0?parent.stdout.trim():undefined
281
+ }
282
+
283
+ async function commitFiles(repository: string, hash: string, parent: string | undefined): Promise<GitCommitFile[]> {
284
+ const result = parent!==undefined
285
+ ? await run('git',['diff','--name-status','-z','-M',parent,hash,'--'],repository)
234
286
  : await run('git', ['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-z', '-M', hash], repository)
235
287
  if (result.code !== 0) throw new WorkbenchError('git-failed', result.stderr || result.stdout, 409)
236
288
  const tokens = result.stdout.split('\0').filter(Boolean); const files: GitCommitFile[] = []
@@ -291,6 +343,24 @@ export async function gitAction(cwd: string, action: string, path?: string, valu
291
343
  case 'commit': args = ['commit', '-m', value ?? '']; break
292
344
  case 'branch.create': args = ['switch', '-c', value ?? '']; break
293
345
  case 'branch.switch': args = ['switch', value ?? '']; break
346
+ case 'branch.list': {
347
+ const refs = await run('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], repository)
348
+ if (refs.code !== 0) throw new WorkbenchError('git-failed', refs.stderr, 500)
349
+ const worktrees = await run('git', ['worktree', 'list', '--porcelain'], repository)
350
+ const used = new Map<string, string>()
351
+ if (worktrees.code === 0) {
352
+ const same = (a: string, b: string) => normalize(a) === normalize(b) || process.platform === 'win32' && normalize(a).toLowerCase() === normalize(b).toLowerCase()
353
+ let worktree = ''
354
+ for (const line of worktrees.stdout.split('\n')) {
355
+ if (line.startsWith('worktree ')) worktree = line.slice('worktree '.length).trim()
356
+ else if (line.startsWith('branch refs/heads/')) {
357
+ const name = line.slice('branch refs/heads/'.length).trim()
358
+ if (name !== '' && !same(worktree, repository)) used.set(name, worktree)
359
+ }
360
+ }
361
+ }
362
+ return refs.stdout.split('\n').filter(Boolean).map(name => ({ name, ...(used.has(name) ? { worktree: used.get(name) } : {}) })) satisfies GitBranch[]
363
+ }
294
364
  case 'branches': {
295
365
  const result = await run('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], repository)
296
366
  if (result.code !== 0) throw new WorkbenchError('git-failed', result.stderr, 500)
@@ -312,17 +382,19 @@ export async function gitAction(cwd: string, action: string, path?: string, valu
312
382
  if (result.code !== 0) throw new WorkbenchError('git-failed', result.stderr || result.stdout, 409)
313
383
  return { diff: result.stdout, original: '', modified: result.stdout } satisfies GitDiff
314
384
  }
315
- case 'commit.files': return commitFiles(repository, validateCommitHash(value))
385
+ case 'commit.files': { const hash=validateCommitHash(value); return commitFiles(repository, hash, await commitParent(repository, hash)) }
316
386
  case 'commit.file.diff': {
317
- const hash=validateCommitHash(value);const target=validateGitPath(repository,path);const files=await commitFiles(repository,hash);const file=files.find(item=>item.path===target)
387
+ const hash=validateCommitHash(value);const target=validateGitPath(repository,path);const parent=await commitParent(repository,hash);const files=await commitFiles(repository,hash,parent);const file=files.find(item=>item.path===target)
318
388
  if (file===undefined) throw new WorkbenchError('not-found','file is not part of the commit',404)
319
- const parent=await run('git',['rev-parse','--verify',`${hash}^1`],repository)
320
- const shown=parent.code===0
321
- ? await run('git',['diff','--no-ext-diff','--find-renames','--patch','--no-color',parent.stdout.trim(),hash,'--',file.previousPath??target,target],repository)
322
- : await run('git',['show','--no-ext-diff','--find-renames','--format=','--patch','--no-color',hash,'--',file.previousPath??target,target],repository)
389
+ // Spawning git is slow on Windows, so the patch and both blobs are read concurrently.
390
+ const [shown,original,modified]=await Promise.all([
391
+ parent!==undefined
392
+ ? run('git',['diff','--no-ext-diff','--find-renames','--patch','--no-color',parent,hash,'--',file.previousPath??target,target],repository)
393
+ : run('git',['show','--no-ext-diff','--find-renames','--format=','--patch','--no-color',hash,'--',file.previousPath??target,target],repository),
394
+ file.status==='A'||parent===undefined?'':gitObject(repository,`${parent}:${file.previousPath??target}`),
395
+ file.status==='D'?'':gitObject(repository,`${hash}:${target}`),
396
+ ])
323
397
  if(shown.code!==0)throw new WorkbenchError('git-failed',shown.stderr||shown.stdout,409)
324
- const original=file.status==='A'||parent.code!==0?'':await gitObject(repository,`${parent.stdout.trim()}:${file.previousPath??target}`)
325
- const modified=file.status==='D'?'':await gitObject(repository,`${hash}:${target}`)
326
398
  return {diff:shown.stdout,original,modified} satisfies GitDiff
327
399
  }
328
400
  case 'diff': {
@@ -352,6 +424,12 @@ export function codiconAsset(): { path: string; type: string } {
352
424
  return { path: resolve(packageRoot, 'dist', 'codicon.ttf'), type: 'font/ttf' }
353
425
  }
354
426
 
427
+ /** Browser build of SheetJS served as an asset so the client bundle stays free of Node builtins. */
428
+ export function xlsxAsset(): { path: string; type: string } {
429
+ const packageRoot = dirname(require.resolve('xlsx/package.json'))
430
+ return { path: resolve(packageRoot, 'dist', 'xlsx.full.min.js'), type: 'text/javascript; charset=utf-8' }
431
+ }
432
+
355
433
  export function monacoAsset(raw: string): { path: string; type: string } {
356
434
  const packageRoot = dirname(require.resolve('monaco-editor/package.json'))
357
435
  const path = resolve(packageRoot, 'min', raw)
@@ -360,3 +438,10 @@ export function monacoAsset(raw: string): { path: string; type: string } {
360
438
  const type = extname(path) === '.css' ? 'text/css' : extname(path) === '.js' ? 'text/javascript' : extname(path) === '.ttf' ? 'font/ttf' : 'application/octet-stream'
361
439
  return { path, type }
362
440
  }
441
+
442
+ /** Resolve a vscode-icons SVG shipped by @vscode-icons/vscode-icons-api. */
443
+ export function fileIconAsset(raw: string): { path: string; type: string } {
444
+ if (!/^[a-z0-9_-]+\.svg$/i.test(raw)) throw new WorkbenchError('asset-invalid', 'invalid file icon path', 403)
445
+ const packageRoot = dirname(require.resolve('@vscode-icons/vscode-icons-api/package.json'))
446
+ return { path: resolve(packageRoot, 'icons', raw), type: 'image/svg+xml' }
447
+ }
package/src/index.ts CHANGED
@@ -1,19 +1,28 @@
1
1
  /** DSH host plugin for the VS Code workbench. */
2
- import { readFile } from 'node:fs/promises'
2
+ import { createReadStream } from 'node:fs'
3
+ import { readFile, stat } from 'node:fs/promises'
3
4
  import type { IncomingMessage } from 'node:http'
4
5
  import { isAbsolute, relative, sep } from 'node:path'
5
6
  import type { Duplex } from 'node:stream'
7
+ import { spawn } from 'node:child_process'
6
8
  import chokidar from 'chokidar'
7
9
  import { WebSocket, WebSocketServer } from 'ws'
8
10
  import type { HostContext } from './context.ts'
9
- import { codiconAsset, createEntry, cwdFor, deleteEntry, gitAction, gitStatus, listFiles, monacoAsset, pdfWorkerAsset, readDocument, renameEntry, saveDocument, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
11
+ import { codiconAsset, createEntry, cwdFor, deleteEntry, fileIconAsset, gitAction, gitStatus, listFiles, monacoAsset, openWorkspaceFromPicker, pdfWorkerAsset, xlsxAsset, readDocument, projectSources, renameEntry, saveDocument, workspaceDownloadFile, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
12
+ import { installLsp, lspStatuses, resolveLsp, uninstallLsp, pipeLanguageServer } from './lsp.ts'
13
+ import { officePdf, officeStatus } from './office.ts'
10
14
  import { installTerminal } from './terminal.ts'
11
15
  import type { SearchRequest } from './types.ts'
16
+ import { watchIgnore, workspaceWatchIgnores } from './watch-ignore.ts'
12
17
  import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
13
18
 
14
19
  export { WorkbenchError } from './wire.ts'
15
20
  export * from './types.ts'
16
21
  export const name = 'dsh-vscode-workbench'
22
+
23
+ // DSH services used by the workbench when present; resolved through optional injection, never a hard dependency.
24
+ let dshDirectoryPicker: import('./context.ts').DirectoryPickerLike | undefined
25
+ let dshWorkspaceRegistry: import('./context.ts').WorkspaceRegistryLike | undefined
17
26
  export const inject = ['webServer', 'sessions', 'connection', 'workingDirectory']
18
27
 
19
28
  async function dispatch(ctx: HostContext, method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
@@ -26,6 +35,12 @@ async function dispatch(ctx: HostContext, method: string, body: Record<string, u
26
35
  case 'fs.create': return createEntry(cwd, stringField(body, 'path'), body.kind === 'directory' ? 'directory' : 'file').then(() => ({ ok: true }))
27
36
  case 'fs.rename': return renameEntry(cwd, stringField(body, 'path'), stringField(body, 'nextPath')).then(() => ({ ok: true }))
28
37
  case 'fs.delete': return deleteEntry(cwd, stringField(body, 'path')).then(() => ({ ok: true }))
38
+ case 'project.sources': return projectSources(cwd)
39
+ case 'office.status': return officeStatus()
40
+ case 'workspace.open': return openWorkspaceFromPicker(dshDirectoryPicker, dshWorkspaceRegistry, AbortSignal.timeout(10 * 60 * 1000))
41
+ case 'lsp.status': return lspStatuses()
42
+ case 'lsp.install': await installLsp(stringField(body, 'id')); return lspStatuses()
43
+ case 'lsp.uninstall': await uninstallLsp(stringField(body, 'id')); return lspStatuses()
29
44
  case 'search': return searchWorkspace(cwd, body as unknown as SearchRequest, signal)
30
45
  case 'git.status': return gitStatus(cwd)
31
46
  case 'git.action': return gitAction(cwd, stringField(body, 'action'), typeof body.path === 'string' ? body.path : undefined, typeof body.value === 'string' ? body.value : undefined)
@@ -34,6 +49,17 @@ async function dispatch(ctx: HostContext, method: string, body: Record<string, u
34
49
  }
35
50
 
36
51
  export function apply(ctx: HostContext): void {
52
+ ctx.effect(() => {
53
+ if (typeof ctx.inject !== 'function') return
54
+ // Optional injection: the callback runs once both services are active, and never if a profile omits them.
55
+ const fiber = ctx.inject(['directoryPicker', 'workspaceRegistry'], (child) => {
56
+ const services = child as unknown as { directoryPicker: import('./context.ts').DirectoryPickerLike; workspaceRegistry: import('./context.ts').WorkspaceRegistryLike }
57
+ dshDirectoryPicker = services.directoryPicker
58
+ dshWorkspaceRegistry = services.workspaceRegistry
59
+ return () => { dshDirectoryPicker = undefined; dshWorkspaceRegistry = undefined }
60
+ })
61
+ return () => { void fiber.dispose?.() }
62
+ }, 'dsh-vscode-workbench: optional DSH services')
37
63
  installTerminal(ctx)
38
64
  ctx.effect(() => ctx.webServer.register({
39
65
  kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
@@ -46,6 +72,29 @@ export function apply(ctx: HostContext): void {
46
72
  },
47
73
  }), 'dsh-vscode-workbench: Codicon font')
48
74
 
75
+ ctx.effect(() => ctx.webServer.register({
76
+ kind: 'exact', path: '/dsh-vscode/xlsx.js', handler: async (req, res) => {
77
+ if (!authorize(req, res, ctx.connection)) return
78
+ try {
79
+ const asset = xlsxAsset()
80
+ res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
81
+ res.end(await readFile(asset.path))
82
+ } catch (error) { sendError(res, error) }
83
+ },
84
+ }), 'dsh-vscode-workbench: SheetJS asset')
85
+
86
+ ctx.effect(() => ctx.webServer.register({
87
+ kind: 'prefix', path: '/dsh-vscode/file-icons', handler: async (req, res) => {
88
+ if (!authorize(req, res, ctx.connection)) return
89
+ try {
90
+ const raw = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode/file-icons/'.length))
91
+ const asset = fileIconAsset(raw)
92
+ res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
93
+ res.end(await readFile(asset.path))
94
+ } catch (error) { sendError(res, error) }
95
+ },
96
+ }), 'dsh-vscode-workbench: file icons')
97
+
49
98
  ctx.effect(() => ctx.webServer.register({
50
99
  kind: 'exact', path: '/dsh-vscode/pdfjs/worker.mjs', handler: async (req, res) => {
51
100
  if (!authorize(req, res, ctx.connection)) return
@@ -88,6 +137,26 @@ export function apply(ctx: HostContext): void {
88
137
  kind:'prefix',path:'/dsh-vscode/file',handler:async(req,res)=>{if(!authorize(req,res,ctx.connection))return;try{const url=new URL(req.url??'/','http://dsh.local');const sessionId=url.searchParams.get('sessionId');const path=url.searchParams.get('path');if(sessionId===null||path===null)throw new WorkbenchError('bad-request','sessionId and path are required');const asset=await workspacePreviewFile(cwdFor(ctx,sessionId),path);res.writeHead(200,{'content-type':asset.type,'x-content-type-options':'nosniff','cache-control':'no-store'});res.end(await readFile(asset.path))}catch(error){sendError(res,error)}}
89
138
  }), 'dsh-vscode-workbench: file preview')
90
139
 
140
+ ctx.effect(() => ctx.webServer.register({
141
+ kind:'exact',path:'/dsh-vscode/download',handler:async(req,res)=>{if(!authorize(req,res,ctx.connection))return;try{const url=new URL(req.url??'/','http://dsh.local');const sessionId=url.searchParams.get('sessionId');const path=url.searchParams.get('path');if(sessionId===null||path===null)throw new WorkbenchError('bad-request','sessionId and path are required');const file=await workspaceDownloadFile(cwdFor(ctx,sessionId),path);res.writeHead(200,{'content-type':'application/octet-stream','content-length':file.size,'content-disposition':`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,'x-content-type-options':'nosniff','cache-control':'no-store'});createReadStream(file.path).on('error',error=>res.destroy(error)).pipe(res)}catch(error){sendError(res,error)}}
142
+ }), 'dsh-vscode-workbench: file download')
143
+
144
+ ctx.effect(() => ctx.webServer.register({
145
+ kind: 'exact', path: '/dsh-vscode/office', handler: async (req, res) => {
146
+ if (!authorize(req, res, ctx.connection)) return
147
+ try {
148
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
149
+ const sessionId = url.searchParams.get('sessionId'); const path = url.searchParams.get('path')
150
+ if (sessionId === null || path === null) throw new WorkbenchError('bad-request', 'sessionId and path are required')
151
+ const pdf = await officePdf(cwdFor(ctx, sessionId), path)
152
+ const info = await stat(pdf.path)
153
+ res.writeHead(200, { 'content-type': 'application/pdf', 'content-length': info.size, 'x-content-type-options': 'nosniff', 'cache-control': 'no-store' })
154
+ createReadStream(pdf.path).on('error', error => res.destroy(error)).pipe(res)
155
+ } catch (error) { sendError(res, error) }
156
+ },
157
+ }), 'dsh-vscode-workbench: office preview')
158
+
159
+
91
160
  ctx.effect(() => ctx.webServer.register({
92
161
  kind: 'prefix', path: '/dsh-vscode/monaco', handler: async (req, res) => {
93
162
  if (!authorize(req, res, ctx.connection)) return
@@ -105,23 +174,65 @@ export function apply(ctx: HostContext): void {
105
174
  path: '/dsh-vscode/ws/files', handler(req, socket, head) {
106
175
  if (ctx.connection.requestRejection(req) !== undefined) { socket.destroy(); return }
107
176
  wss.handleUpgrade(req as IncomingMessage, socket as Duplex, head, (ws) => {
108
- const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')
177
+ const search = new URL(req.url ?? '/', 'http://dsh.local').searchParams
178
+ const sessionId = search.get('sessionId')
109
179
  if (sessionId === null) { ws.close(1008, 'sessionId required'); return }
110
180
  let workspace: string
111
181
  try { workspace = cwdFor(ctx, sessionId) }
112
182
  catch { ws.close(1008, 'session workspace unavailable'); return }
113
- const watcher = chokidar.watch(workspace, { ignoreInitial: true, ignored: /(^|[/\\])(\.git|node_modules|\.next|\.nuxt|\.turbo|\.vite|coverage)([/\\]|$)/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 } })
114
- let paths = new Set<string>(); let timer: ReturnType<typeof setTimeout> | undefined
115
- watcher.on('all', (_event, path) => {
116
- const changed = isAbsolute(path) ? relative(workspace, path) : path
117
- paths.add(changed.split(sep).join('/')); clearTimeout(timer); timer = setTimeout(() => {
118
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'files.changed', paths: [...paths] }))
119
- paths = new Set()
120
- }, 150)
183
+ let watcher: ReturnType<typeof chokidar.watch> | undefined; let paths = new Set<string>(); let timer: ReturnType<typeof setTimeout> | undefined
184
+ // Exclusions come from the workspace's .gitignore plus the built-in dependency/build trees.
185
+ void workspaceWatchIgnores(workspace).then(extra => {
186
+ const instance = watcher = chokidar.watch(workspace, { ignoreInitial: true, ignored: watchIgnore(workspace, extra), awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 } })
187
+ instance.on('error', error => {
188
+ console.warn('[dsh-vscode-workbench] file watcher stopped:', error)
189
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'files.watch-error', message: String(error) }))
190
+ void instance.close()
191
+ })
192
+ instance.on('all', (_event, path) => {
193
+ const changed = isAbsolute(path) ? relative(workspace, path) : path
194
+ paths.add(changed.split(sep).join('/')); clearTimeout(timer); timer = setTimeout(() => {
195
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'files.changed', paths: [...paths] }))
196
+ paths = new Set()
197
+ }, 150)
198
+ })
121
199
  })
122
- ws.on('close', () => { clearTimeout(timer); void watcher.close() })
200
+ ws.on('close', () => { clearTimeout(timer); void watcher?.close() })
123
201
  })
124
202
  },
125
203
  }), 'dsh-vscode-workbench: file watcher')
204
+
205
+ const lspWss = new WebSocketServer({ noServer: true })
206
+ ctx.effect(() => ctx.webServer.registerUpgrade({
207
+ path: '/dsh-vscode/ws/lsp', handler(req, socket, head) {
208
+ if (ctx.connection.requestRejection(req) !== undefined) { socket.destroy(); return }
209
+ lspWss.handleUpgrade(req as IncomingMessage, socket as Duplex, head, (ws) => {
210
+ const search = new URL(req.url ?? '/', 'http://dsh.local').searchParams
211
+ const sessionId = search.get('sessionId'); const serverId = search.get('server')
212
+ if (sessionId === null || serverId === null) { ws.close(1008, 'sessionId and server required'); return }
213
+ let workspace: string
214
+ try { workspace = cwdFor(ctx, sessionId) }
215
+ catch { ws.close(1008, 'session workspace unavailable'); return }
216
+ void (async () => {
217
+ try {
218
+ const server = await resolveLsp(serverId)
219
+ const child = spawn(server.command, server.args, { cwd: workspace, stdio: ['pipe', 'pipe', 'pipe'], shell: server.shell, windowsHide: true })
220
+ const bridge = pipeLanguageServer(child, {
221
+ onMessage: message => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)) },
222
+ onStderr: text => console.warn('[dsh-vscode-workbench] lsp:', text.trim().slice(0, 500)),
223
+ onExit: code => { if (ws.readyState === WebSocket.OPEN) ws.close(1000, `server exited ${code ?? ''}`.trim()) },
224
+ })
225
+ ws.on('message', data => bridge.write(String(data)))
226
+ ws.on('close', () => bridge.kill())
227
+ ws.on('error', () => bridge.kill())
228
+ child.on('error', error => { if (ws.readyState === WebSocket.OPEN) ws.close(1011, String(error).slice(0, 120)) })
229
+ } catch (error) {
230
+ ws.close(1008, (error instanceof Error ? error.message : 'language server unavailable').slice(0, 120))
231
+ }
232
+ })()
233
+ })
234
+ },
235
+ }), 'dsh-vscode-workbench: language server bridge')
236
+ ctx.effect(() => () => { lspWss.close() }, 'dsh-vscode-workbench: language server teardown')
126
237
  ctx.effect(() => () => { wss.close() }, 'dsh-vscode-workbench: watcher teardown')
127
238
  }