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.
- package/README.en.md +15 -6
- package/README.md +17 -6
- package/lib/client.js +5118 -728
- package/lib/client.js.map +1 -1
- package/lib/index.js +899 -57
- 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 +27 -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 +12 -0
- package/package.json +21 -17
- 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 +342 -78
- 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 +31 -30
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +123 -12
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- package/src/watch-ignore.ts +84 -0
package/src/lsp.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Host-side registry and process bridge for language servers exposed to the workbench editor. */
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import { access, mkdir, rm } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { WorkbenchError } from './wire.ts'
|
|
7
|
+
|
|
8
|
+
export type LspInstall = { kind: 'npm'; package: string; bin: string } | { kind: 'system'; command: string; hint: string }
|
|
9
|
+
|
|
10
|
+
export interface LspDefinition {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
/** Monaco language ids this server answers for. */
|
|
14
|
+
languages: string[]
|
|
15
|
+
install: LspInstall
|
|
16
|
+
args: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The strongest maintained server for each language, installable where the ecosystem allows it. */
|
|
20
|
+
export const LSP_SERVERS: readonly LspDefinition[] = [
|
|
21
|
+
{ id: 'python', name: 'Python · Pyright', languages: ['python'], install: { kind: 'npm', package: 'pyright', bin: 'pyright-langserver' }, args: ['--stdio'] },
|
|
22
|
+
{ id: 'php', name: 'PHP · Intelephense', languages: ['php'], install: { kind: 'npm', package: 'intelephense', bin: 'intelephense' }, args: ['--stdio'] },
|
|
23
|
+
{ id: 'shell', name: 'Shell · Bash Language Server', languages: ['shell'], install: { kind: 'npm', package: 'bash-language-server', bin: 'bash-language-server' }, args: ['start'] },
|
|
24
|
+
{ id: 'yaml', name: 'YAML · YAML Language Server', languages: ['yaml'], install: { kind: 'npm', package: 'yaml-language-server', bin: 'yaml-language-server' }, args: ['--stdio'] },
|
|
25
|
+
{ id: 'json', name: 'JSON · VS Code JSON', languages: ['json'], install: { kind: 'npm', package: 'vscode-langservers-extracted', bin: 'vscode-json-language-server' }, args: ['--stdio'] },
|
|
26
|
+
{ id: 'css', name: 'CSS / SCSS / Less · VS Code CSS', languages: ['css', 'scss', 'less'], install: { kind: 'npm', package: 'vscode-langservers-extracted', bin: 'vscode-css-language-server' }, args: ['--stdio'] },
|
|
27
|
+
{ id: 'html', name: 'HTML · VS Code HTML', languages: ['html'], install: { kind: 'npm', package: 'vscode-langservers-extracted', bin: 'vscode-html-language-server' }, args: ['--stdio'] },
|
|
28
|
+
{ id: 'dockerfile', name: 'Dockerfile · Docker LS', languages: ['dockerfile'], install: { kind: 'npm', package: 'dockerfile-language-server-nodejs', bin: 'dockerfile-language-server-nodejs' }, args: ['--stdio'] },
|
|
29
|
+
{ id: 'go', name: 'Go · gopls', languages: ['go'], install: { kind: 'system', command: 'gopls', hint: '在终端运行:go install golang.org/x/tools/gopls@latest(需要 Go 工具链)' }, args: ['serve'] },
|
|
30
|
+
{ id: 'rust', name: 'Rust · rust-analyzer', languages: ['rust'], install: { kind: 'system', command: 'rust-analyzer', hint: '运行:rustup component add rust-analyzer' }, args: [] },
|
|
31
|
+
{ id: 'cpp', name: 'C / C++ · clangd', languages: ['c', 'cpp', 'objective-c'], install: { kind: 'system', command: 'clangd', hint: '安装 LLVM(Windows:winget install LLVM.LLVM),并确保 clangd 在 PATH 中' }, args: [] },
|
|
32
|
+
{ id: 'java', name: 'Java · Eclipse JDT LS', languages: ['java'], install: { kind: 'system', command: 'jdtls', hint: '下载 eclipse.jdt.ls 并让 jdtls 启动脚本在 PATH 中' }, args: [] },
|
|
33
|
+
{ id: 'csharp', name: 'C# · csharp-ls', languages: ['csharp'], install: { kind: 'system', command: 'csharp-ls', hint: '运行:dotnet tool install --global csharp-ls(需要 .NET SDK)' }, args: [] },
|
|
34
|
+
{ id: 'lua', name: 'Lua · Lua Language Server', languages: ['lua'], install: { kind: 'system', command: 'lua-language-server', hint: '从 github.com/LuaLS/lua-language-server/releases 下载,并把 lua-language-server 加入 PATH' }, args: [] },
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
export interface LspStatus {
|
|
38
|
+
id: string
|
|
39
|
+
name: string
|
|
40
|
+
languages: string[]
|
|
41
|
+
installable: boolean
|
|
42
|
+
installed: boolean
|
|
43
|
+
hint?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Private install root so language servers never touch the user's global npm state. */
|
|
47
|
+
export function lspRoot(): string { return join(homedir(), '.dsh', 'dsh-vscode-workbench', 'lsp') }
|
|
48
|
+
|
|
49
|
+
function npmBinPath(id: string, bin: string): string {
|
|
50
|
+
const path = join(lspRoot(), id, 'node_modules', '.bin', bin)
|
|
51
|
+
return process.platform === 'win32' ? `${path}.cmd` : path
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function exists(path: string): Promise<boolean> { return access(path).then(() => true, () => false) }
|
|
55
|
+
|
|
56
|
+
function which(command: string): Promise<string | undefined> {
|
|
57
|
+
return new Promise(resolve => {
|
|
58
|
+
const child = spawn(process.platform === 'win32' ? 'where' : 'which', [command], { windowsHide: true })
|
|
59
|
+
let output = ''
|
|
60
|
+
child.stdout.on('data', chunk => { output += String(chunk) })
|
|
61
|
+
child.on('error', () => resolve(undefined))
|
|
62
|
+
child.on('close', code => resolve(code === 0 ? output.split(/\r?\n/).map(line => line.trim()).find(Boolean) : undefined))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function definition(id: string): LspDefinition {
|
|
67
|
+
const found = LSP_SERVERS.find(server => server.id === id)
|
|
68
|
+
if (found === undefined) throw new WorkbenchError('lsp-unknown', `unknown language server: ${id}`, 404)
|
|
69
|
+
return found
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function commandFor(server: LspDefinition): Promise<{ command: string; shell: boolean } | undefined> {
|
|
73
|
+
if (server.install.kind === 'npm') {
|
|
74
|
+
const command = npmBinPath(server.id, server.install.bin)
|
|
75
|
+
return await exists(command) ? { command, shell: process.platform === 'win32' } : undefined
|
|
76
|
+
}
|
|
77
|
+
const command = await which(server.install.command)
|
|
78
|
+
return command === undefined ? undefined : { command, shell: process.platform === 'win32' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function lspStatuses(): Promise<LspStatus[]> {
|
|
82
|
+
return Promise.all(LSP_SERVERS.map(async server => {
|
|
83
|
+
const resolved = await commandFor(server)
|
|
84
|
+
return {
|
|
85
|
+
id: server.id,
|
|
86
|
+
name: server.name,
|
|
87
|
+
languages: server.languages,
|
|
88
|
+
installable: server.install.kind === 'npm',
|
|
89
|
+
installed: resolved !== undefined,
|
|
90
|
+
...(server.install.kind === 'system' ? { hint: server.install.hint } : {}),
|
|
91
|
+
}
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Install an npm-distributed server into the plugin's private directory. */
|
|
96
|
+
export async function installLsp(id: string): Promise<void> {
|
|
97
|
+
const server = definition(id)
|
|
98
|
+
if (server.install.kind !== 'npm') throw new WorkbenchError('lsp-manual', server.install.hint, 409)
|
|
99
|
+
const directory = join(lspRoot(), id)
|
|
100
|
+
await mkdir(directory, { recursive: true })
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
const child = spawn('npm', ['install', '--prefix', directory, '--no-save', '--no-audit', '--no-fund', '--loglevel=error', server.install.kind === 'npm' ? server.install.package : ''], {
|
|
103
|
+
cwd: directory, shell: process.platform === 'win32', windowsHide: true,
|
|
104
|
+
})
|
|
105
|
+
let error = ''
|
|
106
|
+
child.stderr.on('data', chunk => { error = (error + String(chunk)).slice(-4000) })
|
|
107
|
+
const timer = setTimeout(() => { child.kill(); reject(new WorkbenchError('lsp-timeout', '语言服务器安装超时', 504)) }, 10 * 60 * 1000)
|
|
108
|
+
child.on('error', cause => { clearTimeout(timer); reject(new WorkbenchError('lsp-install', String(cause), 500)) })
|
|
109
|
+
child.on('close', code => {
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
if (code === 0) resolve()
|
|
112
|
+
else reject(new WorkbenchError('lsp-install', error.trim() || `npm exited with ${code}`, 500))
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function uninstallLsp(id: string): Promise<void> {
|
|
118
|
+
const server = definition(id)
|
|
119
|
+
if (server.install.kind !== 'npm') return
|
|
120
|
+
await rm(join(lspRoot(), id), { recursive: true, force: true })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve the spawn command for a running server, or explain what the user must install. */
|
|
124
|
+
export async function resolveLsp(id: string): Promise<{ command: string; shell: boolean; args: string[] }> {
|
|
125
|
+
const server = definition(id)
|
|
126
|
+
const resolved = await commandFor(server)
|
|
127
|
+
if (resolved === undefined) {
|
|
128
|
+
throw new WorkbenchError('lsp-missing', server.install.kind === 'system' ? server.install.hint : '语言服务器尚未安装', 409)
|
|
129
|
+
}
|
|
130
|
+
return { ...resolved, args: server.args }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Serialize a JSON-RPC message using the Language Server Protocol base framing. */
|
|
134
|
+
export function frameLsp(message: unknown): string {
|
|
135
|
+
const body = JSON.stringify(message)
|
|
136
|
+
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Incremental decoder for LSP frames arriving in arbitrary chunks. */
|
|
140
|
+
export function lspFrameParser(onMessage: (message: unknown) => void): (chunk: Buffer) => void {
|
|
141
|
+
let buffer = Buffer.alloc(0)
|
|
142
|
+
return chunk => {
|
|
143
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
144
|
+
for (;;) {
|
|
145
|
+
const headerEnd = buffer.indexOf('\r\n\r\n')
|
|
146
|
+
if (headerEnd < 0) return
|
|
147
|
+
const header = buffer.subarray(0, headerEnd).toString('ascii')
|
|
148
|
+
const length = /content-length:\s*(\d+)/i.exec(header)?.[1]
|
|
149
|
+
if (length === undefined) { buffer = buffer.subarray(headerEnd + 4); continue }
|
|
150
|
+
const size = Number(length)
|
|
151
|
+
if (buffer.length < headerEnd + 4 + size) return
|
|
152
|
+
const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + size).toString('utf8')
|
|
153
|
+
buffer = buffer.subarray(headerEnd + 4 + size)
|
|
154
|
+
try { onMessage(JSON.parse(body)) } catch { /* ignore malformed frames from a misbehaving server */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Wire a spawned language server's stdio to JSON-RPC frames in both directions. */
|
|
160
|
+
export function pipeLanguageServer(child: ChildProcess, handlers: { onMessage(message: unknown): void; onStderr?(text: string): void; onExit?(code: number | null): void }): { write(text: string): void; kill(): void } {
|
|
161
|
+
const parse = lspFrameParser(message => handlers.onMessage(message))
|
|
162
|
+
child.stdout?.on('data', chunk => parse(chunk as Buffer))
|
|
163
|
+
child.stderr?.on('data', chunk => handlers.onStderr?.(String(chunk)))
|
|
164
|
+
child.stdin?.on('error', () => undefined)
|
|
165
|
+
child.on('exit', code => handlers.onExit?.(code))
|
|
166
|
+
return {
|
|
167
|
+
write(text) { try { child.stdin?.write(frameLsp(JSON.parse(text))) } catch { /* ignore incomplete client frames */ } },
|
|
168
|
+
kill() { child.kill() },
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/office.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Convert Word and PowerPoint documents to PDF with a locally installed LibreOffice, like DSH's own preview. */
|
|
2
|
+
import { execFile, spawn } from 'node:child_process'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { access, mkdir, rename, rm, stat } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { basename, extname, join } from 'node:path'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
import { workspacePath } from './host.ts'
|
|
10
|
+
import { WorkbenchError } from './wire.ts'
|
|
11
|
+
|
|
12
|
+
const CONVERTIBLE = new Set(['.doc', '.docx', '.ppt', '.pptx', '.odt', '.odp', '.rtf'])
|
|
13
|
+
|
|
14
|
+
/** True for extensions the Office preview routes through a PDF conversion. */
|
|
15
|
+
export function isOfficeDocumentPath(path: string): boolean { return CONVERTIBLE.has(extname(path).toLowerCase()) }
|
|
16
|
+
|
|
17
|
+
/** Whether a path is a document that can only be shown through the converter. */
|
|
18
|
+
export function needsOfficeConversion(path: string): boolean {
|
|
19
|
+
const extension = extname(path).toLowerCase()
|
|
20
|
+
return extension === '.doc' || extension === '.docx' || extension === '.ppt' || extension === '.pptx' || extension === '.odt' || extension === '.odp' || extension === '.rtf'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Install guidance mirrors DSH: the preview never downloads an engine itself. */
|
|
24
|
+
export const OFFICE_HINT = '未检测到 LibreOffice。安装 LibreOffice 后可像 DSH 官方预览一样把 Word / PowerPoint 转成 PDF 查看。'
|
|
25
|
+
|
|
26
|
+
function candidates(): string[] {
|
|
27
|
+
const list = [process.env.DVW_SOFFICE]
|
|
28
|
+
if (process.platform === 'win32') {
|
|
29
|
+
list.push('soffice', 'C:\\Program Files\\LibreOffice\\program\\soffice.exe', 'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe')
|
|
30
|
+
} else if (process.platform === 'darwin') {
|
|
31
|
+
list.push('soffice', '/Applications/LibreOffice.app/Contents/MacOS/soffice')
|
|
32
|
+
} else {
|
|
33
|
+
list.push('soffice', 'libreoffice', '/usr/bin/soffice', '/usr/bin/libreoffice', '/snap/bin/libreoffice')
|
|
34
|
+
}
|
|
35
|
+
return list.filter((value): value is string => typeof value === 'string' && value !== '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function which(command: string): Promise<string | undefined> {
|
|
39
|
+
return new Promise(resolve => {
|
|
40
|
+
const child = spawn(process.platform === 'win32' ? 'where' : 'which', [command], { windowsHide: true })
|
|
41
|
+
let output = ''
|
|
42
|
+
child.stdout.on('data', chunk => { output += String(chunk) })
|
|
43
|
+
child.on('error', () => resolve(undefined))
|
|
44
|
+
child.on('close', code => resolve(code === 0 ? output.split(/\r?\n/).map(line => line.trim()).find(Boolean) : undefined))
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function exists(path: string): Promise<boolean> { return access(path).then(() => true, () => false) }
|
|
49
|
+
|
|
50
|
+
let detected: { path?: string; checked: number } | undefined
|
|
51
|
+
|
|
52
|
+
/** Locate LibreOffice without repeatedly probing the system for every open tab. */
|
|
53
|
+
export async function findOfficeConverter(): Promise<string | undefined> {
|
|
54
|
+
if (detected !== undefined && Date.now() - detected.checked < 60_000) return detected.path
|
|
55
|
+
let found: string | undefined
|
|
56
|
+
for (const candidate of candidates()) {
|
|
57
|
+
if ((candidate.includes('/') || candidate.includes('\\')) && await exists(candidate)) { found = candidate; break }
|
|
58
|
+
const resolved = await which(candidate)
|
|
59
|
+
if (resolved !== undefined) { found = resolved; break }
|
|
60
|
+
}
|
|
61
|
+
detected = { path: found, checked: Date.now() }
|
|
62
|
+
return found
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Availability payload for the settings and preview UI. */
|
|
66
|
+
export async function officeStatus(): Promise<{ available: boolean; hint?: string }> {
|
|
67
|
+
const converter = await findOfficeConverter()
|
|
68
|
+
return converter === undefined ? { available: false, hint: OFFICE_HINT } : { available: true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Convert one workspace document to a cached PDF and return its absolute path. */
|
|
72
|
+
export async function officePdf(cwd: string, path: string): Promise<{ path: string }> {
|
|
73
|
+
if (!needsOfficeConversion(path)) throw new WorkbenchError('unsupported-media', 'file is not a Word or PowerPoint document', 415)
|
|
74
|
+
const source = await workspacePath(cwd, path)
|
|
75
|
+
const converter = await findOfficeConverter()
|
|
76
|
+
if (converter === undefined) throw new WorkbenchError('converter-missing', OFFICE_HINT, 503)
|
|
77
|
+
const info = await stat(source.absolute)
|
|
78
|
+
const root = join(tmpdir(), 'dsh-vscode-workbench-office')
|
|
79
|
+
const key = createHash('sha1').update(`${source.absolute}:${info.size}:${info.mtimeMs}`).digest('hex').slice(0, 20)
|
|
80
|
+
const target = join(root, `${key}.pdf`)
|
|
81
|
+
if (await exists(target)) return { path: target }
|
|
82
|
+
await mkdir(root, { recursive: true })
|
|
83
|
+
const output = join(root, `out-${key}`)
|
|
84
|
+
await mkdir(output, { recursive: true })
|
|
85
|
+
const profile = pathToFileURL(join(root, `profile-${key}-${process.pid}-${Date.now()}`)).href
|
|
86
|
+
try {
|
|
87
|
+
await promisify(execFile)(converter, ['--headless', '--norestore', '--invisible', '--convert-to', 'pdf', '--outdir', output, `-env:UserInstallation=${profile}`, source.absolute], { timeout: 120_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
|
|
88
|
+
const produced = join(output, `${basename(source.absolute, extname(source.absolute))}.pdf`)
|
|
89
|
+
if (!await exists(produced)) throw new WorkbenchError('converter-failed', 'LibreOffice 未能生成 PDF', 500)
|
|
90
|
+
await rename(produced, target)
|
|
91
|
+
return { path: target }
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(output, { recursive: true, force: true }).catch(() => undefined)
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface SearchMatch { path: string; line: number; column: number; previ
|
|
|
8
8
|
export interface SearchRequest { query: string; caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean; include?: string; exclude?: string }
|
|
9
9
|
/** One working tree change. */
|
|
10
10
|
export interface GitChange { path: string; originalPath?: string; index: string; worktree: string; staged: boolean; untracked: boolean }
|
|
11
|
+
/** One local branch and the linked worktree that has it checked out. */
|
|
12
|
+
export interface GitBranch { name: string; worktree?: string }
|
|
11
13
|
/** Local repository state. */
|
|
12
14
|
export interface GitStatus { repository: string; branch: string; ahead: number; behind: number; changes: GitChange[] }
|
|
13
15
|
/** One commit displayed by the source-control graph. */
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Exclusion rules for the recursive workspace file watcher. */
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Directory names skipped by default; large dependency and build trees exhaust inotify watches. */
|
|
6
|
+
export const DEFAULT_WATCH_IGNORES = [
|
|
7
|
+
'.git', '.hg', '.svn',
|
|
8
|
+
'node_modules', 'bower_components', '.pnpm-store', '.yarn',
|
|
9
|
+
'.next', '.nuxt', '.turbo', '.vite', '.cache', 'coverage',
|
|
10
|
+
'vendor', 'third_party', 'third-party', 'third_part', 'third-part',
|
|
11
|
+
'build', 'dist', 'out', 'target',
|
|
12
|
+
'.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache', '.ruff_cache',
|
|
13
|
+
'.gradle', '.idea', '.vs', 'Pods',
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
function normalize(entry: string): string {
|
|
17
|
+
return entry.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function segmentsOf(entry: string): string[] {
|
|
21
|
+
return normalize(entry).split('/').filter(part => part !== '' && part !== '.')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse a newline- or comma-separated exclusion list, dropping blanks, comments and unsafe paths. */
|
|
25
|
+
export function parseWatchIgnores(value: string | null | undefined): string[] {
|
|
26
|
+
if (value === null || value === undefined) return []
|
|
27
|
+
const entries = new Set<string>()
|
|
28
|
+
for (const raw of value.split(/[\n,]+/)) {
|
|
29
|
+
const entry = normalize(raw)
|
|
30
|
+
const parts = segmentsOf(entry)
|
|
31
|
+
if (parts.length === 0 || parts.includes('..') || entry.startsWith('#')) continue
|
|
32
|
+
entries.add(parts.slice(0, 8).join('/').slice(0, 200))
|
|
33
|
+
if (entries.size >= 200) break
|
|
34
|
+
}
|
|
35
|
+
return [...entries]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Return true when a workspace-relative path contains one of the excluded segment sequences. */
|
|
39
|
+
export function isIgnoredPath(relativePath: string, patterns: readonly string[]): boolean {
|
|
40
|
+
const segments = relativePath.split(/[\\/]+/).filter(Boolean).map(segment => segment.toLowerCase())
|
|
41
|
+
if (segments.length === 0) return false
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
const parts = segmentsOf(pattern).map(part => part.toLowerCase())
|
|
44
|
+
if (parts.length === 0 || parts.includes('..')) continue
|
|
45
|
+
if (parts.length === 1) {
|
|
46
|
+
if (segments.includes(parts[0]!)) return true
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
for (let index = 0; index + parts.length <= segments.length; index++) {
|
|
50
|
+
if (parts.every((part, offset) => segments[index + offset] === part)) return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Literal directory/path rules from a .gitignore; glob and negation rules are left to git itself. */
|
|
57
|
+
export function parseGitIgnore(text: string): string[] {
|
|
58
|
+
const entries = new Set<string>()
|
|
59
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
60
|
+
const line = raw.trim()
|
|
61
|
+
if (line === '' || line.startsWith('#') || line.startsWith('!') || /[*?[\]{}]/.test(line)) continue
|
|
62
|
+
const parts = segmentsOf(normalize(line).replace(/^\//, ''))
|
|
63
|
+
if (parts.length === 0 || parts.includes('..')) continue
|
|
64
|
+
entries.add(parts.slice(0, 8).join('/').slice(0, 200))
|
|
65
|
+
if (entries.size >= 200) break
|
|
66
|
+
}
|
|
67
|
+
return [...entries]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Ignore patterns derived from the workspace's root .gitignore, without asking the user to type exclusions. */
|
|
71
|
+
export async function workspaceWatchIgnores(workspace: string): Promise<string[]> {
|
|
72
|
+
const text = await readFile(join(workspace, '.gitignore'), 'utf8').catch(() => '')
|
|
73
|
+
return parseGitIgnore(text)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build a chokidar `ignored` predicate for one workspace and the user's extra exclusions. */
|
|
77
|
+
export function watchIgnore(workspace: string, extra: readonly string[]): (path: string) => boolean {
|
|
78
|
+
const patterns = [...DEFAULT_WATCH_IGNORES, ...extra]
|
|
79
|
+
return (path: string) => {
|
|
80
|
+
const path_ = relative(workspace, path)
|
|
81
|
+
if (path_ === '' || path_.startsWith('..') || isAbsolute(path_)) return false
|
|
82
|
+
return isIgnoredPath(path_.split(sep).join('/'), patterns)
|
|
83
|
+
}
|
|
84
|
+
}
|