feat: add DSH IDE open-file references
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user