From 0ff9477b5dd8710134acbb25451e4fd9e99f1027 Mon Sep 17 00:00:00 2001 From: JinkeZheng Date: Mon, 17 Aug 2026 13:55:12 +0800 Subject: [PATCH] feat: filter IDE reference candidates --- README.md | 8 ++++- dsh-plugin/README.md | 19 +++++++++- dsh-plugin/cordis.patch.yml | 9 +++++ dsh-plugin/package.json | 3 ++ dsh-plugin/src/catalog.ts | 24 ++++++++++--- dsh-plugin/src/client.ts | 2 +- dsh-plugin/src/index.ts | 16 +++++++-- dsh-plugin/test/catalog.test.ts | 61 +++++++++++++++++++++++++++++++++ dsh-plugin/test/client.test.ts | 7 ++-- pnpm-lock.yaml | 4 +++ 10 files changed, 138 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 44c90ab..bf2bbdb 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,16 @@ $DshCli = 'F:\deepseek-harness-desktop\.artifacts\windows-electron\win-unpacked\ node $DshCli plugin --profile web add -w 'F:\dsh-plugin-coop-with-vs\dsh-plugin' ``` -The package's `cordis.patch.yml` inserts its own shared Host/Client row. Fully quit and restart the matching DSH process after the first install. During development, run the DSH package watcher and the Harness `pnpm run dev:web` watcher when Client HMR is required. +The package's `cordis.patch.yml` inserts its own shared Host/Client row. After the first install or any change to that bundle patch, rerun the install command, fully quit the matching DSH desktop process, and restart it. During development, run the DSH package watcher and the Harness `pnpm run dev:web` watcher when Client HMR is required. Install the generated VS Code `.vsix` and Visual Studio `.vsix` from their component output directories. Once either IDE companion is active, type `@` in the DSH composer and select from the `IDE open files` group. +## Candidate filtering + +The DSH Host filters IDE project metadata before it builds the shared VS Code and Visual Studio candidate list. `excludedExtensions` defaults to `.csproj`, `.fsproj`, `.vbproj`, `.vcxproj`, `.vsproj`, `.sln`, and `.slnx`. Matching is case-insensitive, and configured values may include or omit the leading dot. + +Set `excludedExtensions: []` in `dsh-plugin/cordis.patch.yml` to expose every open document. After changing the bundle patch, reinstall the local plugin into the target profile, fully quit the desktop app, and restart it so the Host receives the new configuration. + ## Data and security boundary Snapshots are stored under `%LOCALAPPDATA%\Prophet\dsh-plugin-coop-with-vs\v1\instances`. Writers update atomically and heartbeat every 15 seconds; the Host ignores snapshots older than 60 seconds. The DSH endpoint accepts loopback GET requests only, validates all snapshot fields, limits results, and never reads file contents while producing candidates. diff --git a/dsh-plugin/README.md b/dsh-plugin/README.md index d8e8ec4..4128ae6 100644 --- a/dsh-plugin/README.md +++ b/dsh-plugin/README.md @@ -18,10 +18,27 @@ pnpm build `lib/client.js` is wrapped in the `window.__ModuleLoader__.load` format expected by DSH Client Modules. +## Configuration + +The Host applies `excludedExtensions` to the combined VS Code and Visual Studio candidate catalog. It defaults to: + +```yaml +excludedExtensions: + - .csproj + - .fsproj + - .vbproj + - .vcxproj + - .vsproj + - .sln + - .slnx +``` + +Matching is case-insensitive, and each value may include or omit its leading dot. Set `excludedExtensions: []` to disable filtering and expose all open documents. `instanceOpenCount` always reflects the number of documents remaining in that IDE instance after filtering. + ## 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. +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. After the first install or any change to the bundle patch, rerun the install command, fully quit the DSH desktop app, and restart it. diff --git a/dsh-plugin/cordis.patch.yml b/dsh-plugin/cordis.patch.yml index a8a1e04..d02c514 100644 --- a/dsh-plugin/cordis.patch.yml +++ b/dsh-plugin/cordis.patch.yml @@ -2,3 +2,12 @@ - insert: - id: coop-with-vs name: '@prophet/dsh-plugin-coop-with-vs' + config: + excludedExtensions: + - .csproj + - .fsproj + - .vbproj + - .vcxproj + - .vsproj + - .sln + - .slnx diff --git a/dsh-plugin/package.json b/dsh-plugin/package.json index 9e3b7de..9fd2830 100644 --- a/dsh-plugin/package.json +++ b/dsh-plugin/package.json @@ -22,6 +22,9 @@ "test": "vitest run", "watch": "node scripts/build.mjs --watch" }, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.5 <0.2.0", diff --git a/dsh-plugin/src/catalog.ts b/dsh-plugin/src/catalog.ts index dc4ab58..dc1e187 100644 --- a/dsh-plugin/src/catalog.ts +++ b/dsh-plugin/src/catalog.ts @@ -1,9 +1,11 @@ -import { basename, isAbsolute } from 'node:path' +import { basename, extname, isAbsolute } from 'node:path' import type { IdeSnapshot, OpenFileItem, OpenFileResponse, SnapshotDocument } from './protocol.js' const MAX_TEXT = 32_768 const MAX_DOCUMENTS = 10_000 +export const DEFAULT_EXCLUDED_EXTENSIONS = ['.csproj', '.fsproj', '.vbproj', '.vcxproj', '.vsproj', '.sln', '.slnx'] as const + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -67,11 +69,25 @@ function score(item: OpenFileItem, query: string): number { return 10 } -export function combineSnapshots(snapshots: readonly IdeSnapshot[], query = '', limit = 100): OpenFileResponse { +function normalizeExcludedExtensions(extensions: readonly string[]): Set { + return new Set(extensions + .map((extension) => extension.trim().toLocaleLowerCase('en-US')) + .filter(Boolean) + .map((extension) => extension.startsWith('.') ? extension : `.${extension}`)) +} + +export function combineSnapshots( + snapshots: readonly IdeSnapshot[], + query = '', + limit = 100, + excludedExtensions: readonly string[] = DEFAULT_EXCLUDED_EXTENSIONS, +): OpenFileResponse { const normalizedQuery = query.trim().slice(0, 256) + const excluded = normalizeExcludedExtensions(excludedExtensions) const byPath = new Map() for (const snapshot of snapshots) { - for (const document of snapshot.documents) { + const documents = snapshot.documents.filter((document) => !excluded.has(extname(document.path).toLocaleLowerCase('en-US'))) + for (const document of documents) { const key = document.path.toLocaleLowerCase('en-US') const current = byPath.get(key) const next: OpenFileItem = { @@ -79,7 +95,7 @@ export function combineSnapshots(snapshots: readonly IdeSnapshot[], query = '', ide: snapshot.ide, instanceId: snapshot.instanceId, ...(snapshot.workspace === undefined ? {} : { workspace: snapshot.workspace }), - instanceOpenCount: snapshot.documents.length, + instanceOpenCount: documents.length, } if (!current || (!current.dirty && next.dirty)) byPath.set(key, next) } diff --git a/dsh-plugin/src/client.ts b/dsh-plugin/src/client.ts index 3821aaf..ae28a3b 100644 --- a/dsh-plugin/src/client.ts +++ b/dsh-plugin/src/client.ts @@ -29,7 +29,7 @@ function ideName(ide: OpenFileItem['ide']): string { 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 } + return { name: item.label, description: details.join(' | '), hint: item.path, path: item.path, label: item.label } } export function serializeIdeReference(path: string): string { diff --git a/dsh-plugin/src/index.ts b/dsh-plugin/src/index.ts index 8b72eb4..6c7bd02 100644 --- a/dsh-plugin/src/index.ts +++ b/dsh-plugin/src/index.ts @@ -2,11 +2,20 @@ 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 z from '@deepseek-ai/schemastery' +import { combineSnapshots, DEFAULT_EXCLUDED_EXTENSIONS, parseSnapshot } from './catalog.js' import type { IdeSnapshot } from './protocol.js' const ROUTE = '/coop-with-vs/open-files' +export interface Config { + excludedExtensions?: string[] +} + +export const Config: z = z.object({ + excludedExtensions: z.array(z.string()).default([...DEFAULT_EXCLUDED_EXTENSIONS]), +}) + interface HostContext extends Context { webServer: WebServer } @@ -45,8 +54,9 @@ export async function loadSnapshots(directory: string | undefined, now = Date.no export const inject = ['webServer'] -export function apply(ctx: HostContext): void { +export function apply(ctx: HostContext, config: Config = {}): void { const directory = snapshotDirectory() + const excludedExtensions = config.excludedExtensions ?? DEFAULT_EXCLUDED_EXTENSIONS ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: ROUTE, @@ -67,7 +77,7 @@ export function apply(ctx: HostContext): void { 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) + const result = combineSnapshots(snapshots, query, requestedLimit, excludedExtensions) res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) res.end(JSON.stringify(result)) } catch { diff --git a/dsh-plugin/test/catalog.test.ts b/dsh-plugin/test/catalog.test.ts index 414c83e..744f7be 100644 --- a/dsh-plugin/test/catalog.test.ts +++ b/dsh-plugin/test/catalog.test.ts @@ -63,4 +63,65 @@ describe('combineSnapshots', () => { expect(combineSnapshots([parsed], 'main', 1).items).toHaveLength(1) expect(combineSnapshots([parsed], 'main', 10).total).toBe(2) }) + + it('excludes Visual Studio project and solution files by default', () => { + const parsed = parseSnapshot(snapshot('F:\\project\\main.cs', { + documents: [ + { path: 'F:\\project\\main.cs', label: 'main.cs', dirty: false }, + { path: 'F:\\project\\app.csproj', label: 'app.csproj', dirty: false }, + { path: 'F:\\project\\library.fsproj', label: 'library.fsproj', dirty: false }, + { path: 'F:\\project\\tools.vbproj', label: 'tools.vbproj', dirty: false }, + { path: 'F:\\project\\native.vcxproj', label: 'native.vcxproj', dirty: false }, + { path: 'F:\\project\\legacy.vsproj', label: 'legacy.vsproj', dirty: false }, + { path: 'F:\\project\\app.sln', label: 'app.sln', dirty: false }, + { path: 'F:\\project\\app.slnx', label: 'app.slnx', dirty: false }, + ], + }), now)! + + expect(combineSnapshots([parsed]).items.map((item) => item.label)).toEqual(['main.cs']) + }) + + it('matches configured extensions without case sensitivity', () => { + const parsed = parseSnapshot(snapshot('F:\\project\\main.cs', { + documents: [ + { path: 'F:\\project\\main.cs', label: 'main.cs', dirty: false }, + { path: 'F:\\project\\APP.CSPROJ', label: 'APP.CSPROJ', dirty: false }, + ], + }), now)! + + expect(combineSnapshots([parsed], '', 100, ['.csproj']).items.map((item) => item.label)).toEqual(['main.cs']) + }) + + it('accepts configured extensions without a leading dot', () => { + const parsed = parseSnapshot(snapshot('F:\\project\\main.cs', { + documents: [ + { path: 'F:\\project\\main.cs', label: 'main.cs', dirty: false }, + { path: 'F:\\project\\app.slnx', label: 'app.slnx', dirty: false }, + ], + }), now)! + + expect(combineSnapshots([parsed], '', 100, ['slnx']).items.map((item) => item.label)).toEqual(['main.cs']) + }) + + it('disables filtering when excludedExtensions is empty', () => { + const parsed = parseSnapshot(snapshot('F:\\project\\main.cs', { + documents: [ + { path: 'F:\\project\\main.cs', label: 'main.cs', dirty: false }, + { path: 'F:\\project\\app.csproj', label: 'app.csproj', dirty: false }, + ], + }), now)! + + expect(combineSnapshots([parsed], '', 100, []).total).toBe(2) + }) + + it('reports the filtered document count for each instance', () => { + const parsed = parseSnapshot(snapshot('F:\\project\\main.cs', { + documents: [ + { path: 'F:\\project\\main.cs', label: 'main.cs', dirty: false }, + { path: 'F:\\project\\app.csproj', label: 'app.csproj', dirty: false }, + ], + }), now)! + + expect(combineSnapshots([parsed]).items[0]?.instanceOpenCount).toBe(1) + }) }) diff --git a/dsh-plugin/test/client.test.ts b/dsh-plugin/test/client.test.ts index 0785bf4..20605db 100644 --- a/dsh-plugin/test/client.test.ts +++ b/dsh-plugin/test/client.test.ts @@ -16,10 +16,7 @@ describe('client reference projection', () => { expect(serializeIdeReference(item.path)).toBe('{"path":"F:\\\\project\\\\a&b.ts"}') }) - 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') + it('shows IDE, workspace, count and dirty state with ASCII separators', () => { + expect(candidateFromItem(item).description).toBe('VS Code | F:\\project | 4 open | unsaved changes') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85afe1a..6a948b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,10 @@ importers: .: {} dsh-plugin: + dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.1 + version: 3.18.1 devDependencies: '@deepseek-ai/cordis': specifier: 4.0.1