dsh-vscode-workbench 0.1.14 → 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.
- package/README.en.md +4 -0
- package/README.md +8 -2
- package/lib/client.js +5014 -674
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -80
- package/lib/index.js.map +1 -1
- package/lib/types/client/ActivityBar.d.ts +24 -0
- package/lib/types/client/DshConversationPanel.d.ts +1 -1
- package/lib/types/client/FileIcon.d.ts +8 -0
- package/lib/types/client/Icon.d.ts +1 -3
- package/lib/types/client/MonacoEditor.d.ts +36 -0
- package/lib/types/client/Workbench.d.ts +19 -7
- package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/documentPreview.d.ts +10 -2
- package/lib/types/client/dshComposer.d.ts +21 -0
- package/lib/types/client/dshNavigation.d.ts +46 -0
- package/lib/types/client/fileIconMap.d.ts +2 -0
- package/lib/types/client/gitError.d.ts +2 -0
- package/lib/types/client/lsp.d.ts +27 -0
- package/lib/types/client/searchHighlight.d.ts +5 -0
- package/lib/types/client/typescriptProject.d.ts +20 -0
- package/lib/types/context.d.ts +15 -0
- package/lib/types/host.d.ts +37 -0
- package/lib/types/lsp.d.ts +54 -0
- package/lib/types/office.d.ts +17 -0
- package/lib/types/types.d.ts +5 -0
- package/lib/types/watch-ignore.d.ts +4 -0
- package/package.json +5 -2
- package/src/client/ActivityBar.tsx +152 -0
- package/src/client/DshConversationPanel.tsx +15 -12
- package/src/client/FileIcon.tsx +6 -0
- package/src/client/Icon.tsx +1 -14
- package/src/client/MonacoEditor.tsx +187 -19
- package/src/client/TerminalPanel.tsx +6 -3
- package/src/client/Workbench.tsx +324 -81
- package/src/client/WorkspaceSwitcher.tsx +53 -0
- package/src/client/api.ts +27 -1
- package/src/client/documentPreview.tsx +92 -5
- package/src/client/dshComposer.ts +73 -0
- package/src/client/dshNavigation.ts +81 -0
- package/src/client/fileIconMap.ts +7 -0
- package/src/client/gitError.ts +5 -0
- package/src/client/index.tsx +28 -14
- package/src/client/lsp.ts +300 -0
- package/src/client/searchHighlight.ts +6 -0
- package/src/client/styles.ts +28 -31
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +121 -17
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- package/src/watch-ignore.ts +22 -1
|
@@ -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
|
|
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
|
-
|
|
233
|
-
|
|
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,
|
|
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
|
-
|
|
320
|
-
const shown=
|
|
321
|
-
|
|
322
|
-
|
|
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,20 +1,28 @@
|
|
|
1
1
|
/** DSH host plugin for the VS Code workbench. */
|
|
2
|
-
import {
|
|
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'
|
|
12
|
-
import {
|
|
16
|
+
import { watchIgnore, workspaceWatchIgnores } from './watch-ignore.ts'
|
|
13
17
|
import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
|
|
14
18
|
|
|
15
19
|
export { WorkbenchError } from './wire.ts'
|
|
16
20
|
export * from './types.ts'
|
|
17
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
|
|
18
26
|
export const inject = ['webServer', 'sessions', 'connection', 'workingDirectory']
|
|
19
27
|
|
|
20
28
|
async function dispatch(ctx: HostContext, method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
@@ -27,6 +35,12 @@ async function dispatch(ctx: HostContext, method: string, body: Record<string, u
|
|
|
27
35
|
case 'fs.create': return createEntry(cwd, stringField(body, 'path'), body.kind === 'directory' ? 'directory' : 'file').then(() => ({ ok: true }))
|
|
28
36
|
case 'fs.rename': return renameEntry(cwd, stringField(body, 'path'), stringField(body, 'nextPath')).then(() => ({ ok: true }))
|
|
29
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()
|
|
30
44
|
case 'search': return searchWorkspace(cwd, body as unknown as SearchRequest, signal)
|
|
31
45
|
case 'git.status': return gitStatus(cwd)
|
|
32
46
|
case 'git.action': return gitAction(cwd, stringField(body, 'action'), typeof body.path === 'string' ? body.path : undefined, typeof body.value === 'string' ? body.value : undefined)
|
|
@@ -35,6 +49,17 @@ async function dispatch(ctx: HostContext, method: string, body: Record<string, u
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
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')
|
|
38
63
|
installTerminal(ctx)
|
|
39
64
|
ctx.effect(() => ctx.webServer.register({
|
|
40
65
|
kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
|
|
@@ -47,6 +72,29 @@ export function apply(ctx: HostContext): void {
|
|
|
47
72
|
},
|
|
48
73
|
}), 'dsh-vscode-workbench: Codicon font')
|
|
49
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
|
+
|
|
50
98
|
ctx.effect(() => ctx.webServer.register({
|
|
51
99
|
kind: 'exact', path: '/dsh-vscode/pdfjs/worker.mjs', handler: async (req, res) => {
|
|
52
100
|
if (!authorize(req, res, ctx.connection)) return
|
|
@@ -89,6 +137,26 @@ export function apply(ctx: HostContext): void {
|
|
|
89
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)}}
|
|
90
138
|
}), 'dsh-vscode-workbench: file preview')
|
|
91
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
|
+
|
|
92
160
|
ctx.effect(() => ctx.webServer.register({
|
|
93
161
|
kind: 'prefix', path: '/dsh-vscode/monaco', handler: async (req, res) => {
|
|
94
162
|
if (!authorize(req, res, ctx.connection)) return
|
|
@@ -112,23 +180,59 @@ export function apply(ctx: HostContext): void {
|
|
|
112
180
|
let workspace: string
|
|
113
181
|
try { workspace = cwdFor(ctx, sessionId) }
|
|
114
182
|
catch { ws.close(1008, 'session workspace unavailable'); return }
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
paths =
|
|
127
|
-
|
|
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
|
+
})
|
|
128
199
|
})
|
|
129
|
-
ws.on('close', () => { clearTimeout(timer); void watcher
|
|
200
|
+
ws.on('close', () => { clearTimeout(timer); void watcher?.close() })
|
|
130
201
|
})
|
|
131
202
|
},
|
|
132
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')
|
|
133
237
|
ctx.effect(() => () => { wss.close() }, 'dsh-vscode-workbench: watcher teardown')
|
|
134
238
|
}
|