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
+27
View File
@@ -0,0 +1,27 @@
# DSH Cordis plugin
This package is both a normal dual-face Cordis plugin and a DSH profile bundle.
- Host: reads and validates companion snapshots, then serves `GET /coop-with-vs/open-files` to loopback clients.
- Client: registers the `ide-file` source in the existing `@` input-trigger pipeline.
- Pick result: a native composer reference chip whose model projection is `<ide-open-file>{"path":"..."}</ide-open-file>`.
No source content crosses the companion protocol or candidate endpoint.
## Build and test
```powershell
pnpm install
pnpm test
pnpm build
```
`lib/client.js` is wrapped in the `window.__ModuleLoader__.load` format expected by DSH Client Modules.
## Install
```powershell
dsh plugin --profile web add F:\dsh-plugin-coop-with-vs\dsh-plugin
```
The profile manager recognizes `dsh.bundle.patch` and appends this package to the Web profile's bundle list. `cordis.patch.yml` inserts one shared `coop-with-vs` row. Restart DSH after first installation.
+4
View File
@@ -0,0 +1,4 @@
# Profile bundle patch. The package's dsh.client manifest contributes its browser half.
- insert:
- id: coop-with-vs
name: dsh-plugin-coop-with-vs
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@prophet/dsh-plugin-coop-with-vs",
"version": "0.1.0",
"description": "Use files open in VS Code and Visual Studio as DSH @ references",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"repository": { "type": "git", "url": "https://www.prophetk.icu/Prophet/dsh-plugin-coop-with-vs.git", "directory": "dsh-plugin" },
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client.d.ts", "default": "./lib/client.js" },
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", "cordis.patch.yml", "README.md"],
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "inject": ["@deepseek-ai/dsh-client-ui-input-trigger"], "platform": "web" }
},
"scripts": {
"build": "node scripts/build.mjs && tsc -p tsconfig.build.json",
"test": "vitest run",
"watch": "node scripts/build.mjs --watch"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.5 <0.2.0",
"@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.5 <0.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/dsh-client-ui-input-trigger": "0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
"@types/node": "^22.13.4",
"esbuild": "^0.25.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"license": "MIT"
}
+40
View File
@@ -0,0 +1,40 @@
import { build } from 'esbuild'
import { mkdir, rm, writeFile } from 'node:fs/promises'
const watch = process.argv.includes('--watch')
const packageId = '@prophet/dsh-plugin-coop-with-vs'
await rm('lib', { recursive: true, force: true })
await mkdir('lib', { recursive: true })
const common = {
bundle: true,
sourcemap: true,
target: 'es2022',
logLevel: 'info',
}
await build({
...common,
entryPoints: ['src/index.ts'],
outfile: 'lib/index.js',
platform: 'node',
format: 'esm',
packages: 'external',
})
const client = await build({
...common,
entryPoints: ['src/client.ts'],
platform: 'browser',
format: 'cjs',
packages: 'external',
write: false,
})
const body = client.outputFiles[0].text
const wrapped = `window.__ModuleLoader__.load({\n id: ${JSON.stringify(packageId)},\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;\n${body}\n return module.exports;\n },\n});\n`
await writeFile('lib/client.js', wrapped, 'utf8')
if (watch) {
console.error('Watch mode performs one deterministic rebuild; rerun via your file watcher.')
}
+98
View File
@@ -0,0 +1,98 @@
import { basename, isAbsolute } from 'node:path'
import type { IdeSnapshot, OpenFileItem, OpenFileResponse, SnapshotDocument } from './protocol.js'
const MAX_TEXT = 32_768
const MAX_DOCUMENTS = 10_000
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
const keys = new Set(allowed)
return Object.keys(value).every((key) => keys.has(key))
}
function boundedString(value: unknown, max = MAX_TEXT): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\0')
}
function parseDocument(value: unknown): SnapshotDocument | undefined {
if (!isRecord(value) || !hasOnlyKeys(value, ['path', 'label', 'dirty']) || !boundedString(value.path) || !isAbsolute(value.path)) return undefined
if (!boundedString(value.label, 1024) || typeof value.dirty !== 'boolean') return undefined
return { path: value.path, label: value.label, dirty: value.dirty }
}
export function parseSnapshot(value: unknown, now = Date.now(), maxAgeMs = 60_000): IdeSnapshot | undefined {
if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'instanceId', 'ide', 'processId', 'workspace', 'updatedAt', 'documents']) || value.version !== 1) return undefined
if (!boundedString(value.instanceId, 128)) return undefined
if (value.ide !== 'vscode' && value.ide !== 'visualstudio') return undefined
if (!Number.isSafeInteger(value.processId) || (value.processId as number) <= 0) return undefined
if (value.workspace !== undefined && !boundedString(value.workspace)) return undefined
if (!boundedString(value.updatedAt, 128) || !Array.isArray(value.documents) || value.documents.length > MAX_DOCUMENTS) return undefined
const updated = Date.parse(value.updatedAt)
if (!Number.isFinite(updated) || now - updated > maxAgeMs || updated - now > 300_000) return undefined
const documents: SnapshotDocument[] = []
const seen = new Set<string>()
for (const raw of value.documents) {
const document = parseDocument(raw)
if (!document) continue
const key = document.path.toLocaleLowerCase('en-US')
if (seen.has(key)) continue
seen.add(key)
documents.push(document)
}
return {
version: 1,
instanceId: value.instanceId,
ide: value.ide,
processId: value.processId as number,
...(value.workspace === undefined ? {} : { workspace: value.workspace }),
updatedAt: value.updatedAt,
documents,
}
}
function score(item: OpenFileItem, query: string): number {
if (!query) return 0
const q = query.toLocaleLowerCase('en-US')
const label = item.label.toLocaleLowerCase('en-US')
const path = item.path.toLocaleLowerCase('en-US')
if (label === q) return 0
if (label.startsWith(q)) return 1
if (label.includes(q)) return 2
if (path.includes(q)) return 3
return 10
}
export function combineSnapshots(snapshots: readonly IdeSnapshot[], query = '', limit = 100): OpenFileResponse {
const normalizedQuery = query.trim().slice(0, 256)
const byPath = new Map<string, OpenFileItem>()
for (const snapshot of snapshots) {
for (const document of snapshot.documents) {
const key = document.path.toLocaleLowerCase('en-US')
const current = byPath.get(key)
const next: OpenFileItem = {
...document,
ide: snapshot.ide,
instanceId: snapshot.instanceId,
...(snapshot.workspace === undefined ? {} : { workspace: snapshot.workspace }),
instanceOpenCount: snapshot.documents.length,
}
if (!current || (!current.dirty && next.dirty)) byPath.set(key, next)
}
}
const matching = [...byPath.values()].filter((item) => score(item, normalizedQuery) < 10)
matching.sort((a, b) => score(a, normalizedQuery) - score(b, normalizedQuery)
|| a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|| a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }))
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? Math.floor(limit) : 100, 200))
return { total: matching.length, instances: snapshots.length, items: matching.slice(0, safeLimit) }
}
export function displayLabel(path: string): string {
return basename(path) || path
}
+73
View File
@@ -0,0 +1,73 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
InputTriggerCandidate,
InputTriggerPick,
InputTriggerSource,
ReferenceInsert,
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { OpenFileItem, OpenFileResponse } from './protocol.js'
interface IdeCandidate extends InputTriggerCandidate {
path: string
label: string
}
function isOpenFileItem(value: unknown): value is OpenFileItem {
if (typeof value !== 'object' || value === null) return false
const item = value as Partial<OpenFileItem>
return typeof item.path === 'string' && item.path.length > 0
&& typeof item.label === 'string' && item.label.length > 0
&& (item.ide === 'vscode' || item.ide === 'visualstudio')
&& typeof item.instanceOpenCount === 'number'
&& typeof item.dirty === 'boolean'
}
function ideName(ide: OpenFileItem['ide']): string {
return ide === 'vscode' ? 'VS Code' : 'Visual Studio'
}
export function candidateFromItem(item: OpenFileItem): IdeCandidate {
const details = [ideName(item.ide), item.workspace, `${item.instanceOpenCount} open`, item.dirty ? 'unsaved changes' : undefined]
.filter((value): value is string => Boolean(value))
return { name: item.label, description: details.join(' 璺?'), hint: item.path, path: item.path, label: item.label }
}
export function serializeIdeReference(path: string): string {
return `<ide-open-file>${JSON.stringify({ path })}</ide-open-file>`
}
export function referenceFromPick(pick: InputTriggerPick): ReferenceInsert | undefined {
const candidate = pick.candidate as Partial<IdeCandidate>
if (typeof candidate.path !== 'string' || typeof candidate.label !== 'string') return undefined
return { source: 'ide-file', ref: candidate.path, label: candidate.label, clipboardText: candidate.path }
}
export const inject = ['inputTriggers']
export function apply(ctx: Context): void {
const source: InputTriggerSource = {
trigger: '@',
name: 'ide-file',
order: 20,
async candidates(_session, request) {
const params = new URLSearchParams({ q: request.query, limit: '100' })
const response = await fetch(`/coop-with-vs/open-files?${params}`, {
method: 'GET',
signal: request.signal,
headers: { accept: 'application/json' },
})
if (!response.ok) return []
const payload = await response.json() as Partial<OpenFileResponse>
return Array.isArray(payload.items) ? payload.items.filter(isOpenFileItem).map(candidateFromItem) : []
},
onPick(pick) {
const insert = referenceFromPick(pick)
return insert ? { insert } : undefined
},
codec: {
clipboardText: (path) => path,
serialize: (path) => Promise.resolve(serializeIdeReference(path)),
},
}
ctx.effect(() => ctx.inputTriggers.registerSource(source), 'coop-with-vs: IDE open files')
}
+79
View File
@@ -0,0 +1,79 @@
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
import { combineSnapshots, parseSnapshot } from './catalog.js'
import type { IdeSnapshot } from './protocol.js'
const ROUTE = '/coop-with-vs/open-files'
interface HostContext extends Context {
webServer: WebServer
}
export function snapshotDirectory(localAppData = process.env.LOCALAPPDATA): string | undefined {
return localAppData ? join(localAppData, 'Prophet', 'dsh-plugin-coop-with-vs', 'v1', 'instances') : undefined
}
export function isLoopbackAddress(address: string | undefined): boolean {
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
}
export async function loadSnapshots(directory: string | undefined, now = Date.now()): Promise<IdeSnapshot[]> {
if (!directory) return []
let names: string[]
try {
names = await readdir(directory)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
const snapshots: IdeSnapshot[] = []
await Promise.all(names.filter((name) => /^(vscode|visualstudio)-.+\.json$/i.test(name)).map(async (name) => {
try {
const text = await readFile(join(directory, name), 'utf8')
if (text.length > 8 * 1024 * 1024) return
const snapshot = parseSnapshot(JSON.parse(text) as unknown, now)
if (snapshot) snapshots.push(snapshot)
} catch {
// One partially written or stale instance must not hide healthy IDEs.
}
}))
return snapshots
}
export const inject = ['webServer']
export function apply(ctx: HostContext): void {
const directory = snapshotDirectory()
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: ROUTE,
async handler(req, res) {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'loopback-required' }))
return
}
if (req.method !== 'GET') {
res.writeHead(405, { allow: 'GET', 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'method-not-allowed' }))
return
}
try {
const url = new URL(req.url ?? ROUTE, 'http://127.0.0.1')
const query = (url.searchParams.get('q') ?? '').slice(0, 256)
const requestedLimit = Number(url.searchParams.get('limit') ?? '100')
const snapshots = await loadSnapshots(directory)
const result = combineSnapshots(snapshots, query, requestedLimit)
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(result))
} catch {
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'snapshot-read-failed' }))
}
},
}))
}
+30
View File
@@ -0,0 +1,30 @@
export type IdeKind = 'vscode' | 'visualstudio'
export interface SnapshotDocument {
path: string
label: string
dirty: boolean
}
export interface IdeSnapshot {
version: 1
instanceId: string
ide: IdeKind
processId: number
workspace?: string
updatedAt: string
documents: SnapshotDocument[]
}
export interface OpenFileItem extends SnapshotDocument {
ide: IdeKind
instanceId: string
workspace?: string
instanceOpenCount: number
}
export interface OpenFileResponse {
total: number
instances: number
items: OpenFileItem[]
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { combineSnapshots, parseSnapshot } from '../src/catalog.js'
const now = Date.parse('2026-01-01T00:00:30.000Z')
function snapshot(path: string, overrides: Record<string, unknown> = {}) {
return {
version: 1,
instanceId: 'vscode-a',
ide: 'vscode',
processId: 10,
workspace: 'F:\\project',
updatedAt: '2026-01-01T00:00:00.000Z',
documents: [{ path, label: 'main.ts', dirty: false }],
...overrides,
}
}
describe('parseSnapshot', () => {
it('accepts valid absolute files', () => {
expect(parseSnapshot(snapshot('F:\\project\\main.ts'), now)?.documents).toHaveLength(1)
})
it('rejects stale and unknown versions', () => {
expect(parseSnapshot(snapshot('F:\\project\\main.ts'), now + 61_000)).toBeUndefined()
expect(parseSnapshot(snapshot('F:\\project\\main.ts', { version: 2 }), now)).toBeUndefined()
})
it('rejects unknown v1 fields', () => {
expect(parseSnapshot(snapshot('F:\\\\project\\\\main.ts', { extra: true }), now)).toBeUndefined()
})
it('drops invalid and duplicate documents without dropping the instance', () => {
const value = snapshot('F:\\project\\main.ts', {
documents: [
{ path: 'relative.ts', label: 'relative.ts', dirty: false },
{ path: 'F:\\project\\main.ts', label: 'main.ts', dirty: false },
{ path: 'f:\\PROJECT\\MAIN.ts', label: 'MAIN.ts', dirty: true },
],
})
expect(parseSnapshot(value, now)?.documents).toHaveLength(1)
})
})
describe('combineSnapshots', () => {
it('deduplicates paths and prefers dirty metadata', () => {
const clean = parseSnapshot(snapshot('F:\\project\\main.ts'), now)!
const dirty = parseSnapshot(snapshot('f:\\PROJECT\\main.ts', {
instanceId: 'visualstudio-b', ide: 'visualstudio', documents: [{ path: 'f:\\PROJECT\\main.ts', label: 'main.ts', dirty: true }],
}), now)!
const result = combineSnapshots([clean, dirty])
expect(result.total).toBe(1)
expect(result.items[0]?.dirty).toBe(true)
})
it('matches basename before path and caps the result', () => {
const parsed = parseSnapshot(snapshot('F:\\project\\main.ts', {
documents: [
{ path: 'F:\\project\\src\\main.ts', label: 'main.ts', dirty: false },
{ path: 'F:\\project\\main-helper.ts', label: 'main-helper.ts', dirty: false },
],
}), now)!
expect(combineSnapshots([parsed], 'main', 1).items).toHaveLength(1)
expect(combineSnapshots([parsed], 'main', 10).total).toBe(2)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { candidateFromItem, serializeIdeReference } from '../src/client.js'
const item = {
path: 'F:\\project\\a&b.ts',
label: 'a&b.ts',
dirty: true,
ide: 'vscode' as const,
instanceId: 'one',
workspace: 'F:\\project',
instanceOpenCount: 4,
}
describe('client reference projection', () => {
it('keeps the path in a structured JSON payload', () => {
expect(serializeIdeReference(item.path)).toBe('<ide-open-file>{"path":"F:\\\\project\\\\a&b.ts"}</ide-open-file>')
})
it('shows IDE, count and dirty state', () => {
const candidate = candidateFromItem(item)
expect(candidate.description).toContain('VS Code')
expect(candidate.description).toContain('4 open')
expect(candidate.description).toContain('unsaved changes')
})
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "lib/types"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}