feat: add DSH IDE open-file references

This commit is contained in:
2026-08-17 11:52:48 +08:00
commit d96c11ae3d
42 changed files with 6991 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 Prophet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+20
View File
@@ -0,0 +1,20 @@
# DSH Open File Bridge for VS Code
This companion publishes the local disk files represented by all VS Code tabs. It uses `window.tabGroups.all`, so background tabs are included; text diff tabs contribute both local sides. Untitled and non-file URI schemes are excluded.
## Development
```powershell
pnpm install
pnpm test
pnpm build
pnpm package
```
Install the generated `.vsix` with **Extensions: Install from VSIX...** or:
```powershell
code --install-extension prophet-coop-with-dsh-0.1.0.vsix
```
The extension writes no source content. It updates a per-instance JSON snapshot under `%LOCALAPPDATA%\Prophet\dsh-plugin-coop-with-vs\v1\instances`, heartbeats every 15 seconds, and removes the file on clean deactivation.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "prophet-coop-with-dsh",
"displayName": "DSH Open File Bridge",
"description": "Publishes VS Code open file metadata for DeepSeek Harness @ references",
"version": "0.1.0",
"publisher": "prophet",
"license": "MIT",
"repository": { "type": "git", "url": "https://www.prophetk.icu/Prophet/dsh-plugin-coop-with-vs.git", "directory": "vscode-extension" },
"engines": { "vscode": "^1.85.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"files": ["dist/**", "README.md", "LICENSE"],
"scripts": {
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"test": "vitest run",
"package": "corepack pnpm build && node node_modules/@vscode/vsce/vsce package --no-dependencies"
},
"devDependencies": {
"@types/node": "^22.13.4",
"@types/vscode": "^1.85.0",
"@vscode/vsce": "^3.2.2",
"esbuild": "^0.25.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}
+22
View File
@@ -0,0 +1,22 @@
import * as esbuild from 'esbuild'
const watch = process.argv.includes('--watch')
const options = {
entryPoints: ['src/extension.ts'],
bundle: true,
outfile: 'dist/extension.js',
external: ['vscode'],
format: 'cjs',
platform: 'node',
target: 'node20',
sourcemap: true,
logLevel: 'info',
}
if (watch) {
const context = await esbuild.context(options)
await context.watch()
console.log('Watching VS Code extension sources...')
} else {
await esbuild.build(options)
}
+91
View File
@@ -0,0 +1,91 @@
import { basename } from 'node:path'
import * as vscode from 'vscode'
import { createSnapshot, type OpenDocument } from './model.js'
import { SnapshotPublisher } from './publisher.js'
const HEARTBEAT_MS = 15_000
const DEBOUNCE_MS = 150
function localFile(uri: vscode.Uri): string | undefined {
return uri.scheme === 'file' && uri.fsPath ? uri.fsPath : undefined
}
function tabDocuments(tab: vscode.Tab): OpenDocument[] {
const input = tab.input
const paths: string[] = []
if (input instanceof vscode.TabInputText) {
const path = localFile(input.uri)
if (path) paths.push(path)
} else if (input instanceof vscode.TabInputTextDiff) {
const original = localFile(input.original)
const modified = localFile(input.modified)
if (original) paths.push(original)
if (modified) paths.push(modified)
}
return paths.map((path) => ({ path, label: basename(path), dirty: tab.isDirty }))
}
export function collectOpenDocuments(): OpenDocument[] {
return vscode.window.tabGroups.all.flatMap((group) => group.tabs.flatMap(tabDocuments))
}
function workspaceLabel(): string | undefined {
const workspaceFile = vscode.workspace.workspaceFile
if (workspaceFile?.scheme === 'file') return workspaceFile.fsPath
const folders = vscode.workspace.workspaceFolders
if (folders?.length === 1 && folders[0]?.uri.scheme === 'file') return folders[0].uri.fsPath
return vscode.workspace.name
}
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const output = vscode.window.createOutputChannel('DSH Open File Bridge', { log: true })
const publisher = new SnapshotPublisher()
let debounce: NodeJS.Timeout | undefined
let disposed = false
const publish = async () => {
if (disposed) return
const snapshot = createSnapshot({
instanceId: publisher.instanceId,
processId: process.pid,
workspace: workspaceLabel(),
documents: collectOpenDocuments(),
})
try {
await publisher.publish(snapshot)
output.debug(`Published ${snapshot.documents.length} open files`)
} catch (error) {
output.error(`Snapshot publish failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
const schedule = () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => void publish(), DEBOUNCE_MS)
}
context.subscriptions.push(
output,
vscode.window.tabGroups.onDidChangeTabs(schedule),
vscode.window.onDidChangeActiveTextEditor(schedule),
vscode.workspace.onDidChangeWorkspaceFolders(schedule),
)
const heartbeat = setInterval(() => void publish(), HEARTBEAT_MS)
const onExit = () => publisher.disposeSync()
process.once('exit', onExit)
context.subscriptions.push({
dispose() {
disposed = true
if (debounce) clearTimeout(debounce)
clearInterval(heartbeat)
process.off('exit', onExit)
void publisher.dispose()
},
})
await publish()
}
export function deactivate(): void {
// ExtensionContext disposal owns cleanup.
}
+46
View File
@@ -0,0 +1,46 @@
export type IdeKind = 'vscode'
export interface OpenDocument {
path: string
label: string
dirty: boolean
}
export interface OpenFileSnapshot {
version: 1
instanceId: string
ide: IdeKind
processId: number
workspace?: string
updatedAt: string
documents: OpenDocument[]
}
export function deduplicateDocuments(documents: readonly OpenDocument[]): OpenDocument[] {
const result = new Map<string, OpenDocument>()
for (const document of documents) {
const key = document.path.toLocaleLowerCase('en-US')
const current = result.get(key)
if (!current || (!current.dirty && document.dirty)) result.set(key, document)
}
return [...result.values()].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|| a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }))
}
export function createSnapshot(input: {
instanceId: string
processId: number
workspace?: string
documents: readonly OpenDocument[]
now?: Date
}): OpenFileSnapshot {
return {
version: 1,
instanceId: input.instanceId,
ide: 'vscode',
processId: input.processId,
...(input.workspace === undefined ? {} : { workspace: input.workspace }),
updatedAt: (input.now ?? new Date()).toISOString(),
documents: deduplicateDocuments(input.documents),
}
}
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import type { OpenFileSnapshot } from './model.js'
export function snapshotRoot(localAppData = process.env.LOCALAPPDATA): string {
if (!localAppData) throw new Error('LOCALAPPDATA is not available')
return join(localAppData, 'Prophet', 'dsh-plugin-coop-with-vs', 'v1', 'instances')
}
export class SnapshotPublisher {
readonly instanceId = randomUUID()
readonly filePath: string
private pending = Promise.resolve()
constructor(private readonly directory = snapshotRoot()) {
this.filePath = join(directory, `vscode-${this.instanceId}.json`)
}
publish(snapshot: OpenFileSnapshot): Promise<void> {
this.pending = this.pending.then(async () => {
await mkdir(this.directory, { recursive: true })
const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, { encoding: 'utf8', mode: 0o600 })
try {
await rename(temporary, this.filePath)
} catch (error) {
await rm(this.filePath, { force: true })
await rename(temporary, this.filePath).catch(async (renameError) => {
await rm(temporary, { force: true })
throw renameError
})
}
})
return this.pending
}
async dispose(): Promise<void> {
await this.pending.catch(() => undefined)
await rm(this.filePath, { force: true }).catch(() => undefined)
}
disposeSync(): void {
try { rmSync(this.filePath, { force: true }) } catch { /* best effort during process exit */ }
}
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { createSnapshot, deduplicateDocuments } from '../src/model.js'
describe('deduplicateDocuments', () => {
it('deduplicates Windows paths and preserves dirty state', () => {
const result = deduplicateDocuments([
{ path: 'F:\\Project\\main.ts', label: 'main.ts', dirty: false },
{ path: 'f:\\project\\MAIN.ts', label: 'MAIN.ts', dirty: true },
])
expect(result).toEqual([{ path: 'f:\\project\\MAIN.ts', label: 'MAIN.ts', dirty: true }])
})
})
describe('createSnapshot', () => {
it('creates the frozen v1 wire shape', () => {
const snapshot = createSnapshot({
instanceId: 'one', processId: 12, workspace: 'F:\\Project',
documents: [{ path: 'F:\\Project\\main.ts', label: 'main.ts', dirty: false }],
now: new Date('2026-01-01T00:00:00.000Z'),
})
expect(snapshot).toMatchObject({ version: 1, ide: 'vscode', updatedAt: '2026-01-01T00:00:00.000Z' })
})
})
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}