feat: filter IDE reference candidates

This commit is contained in:
2026-08-17 13:55:12 +08:00
parent 35163e12b4
commit 0ff9477b5d
10 changed files with 138 additions and 15 deletions
+18 -1
View File
@@ -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.
+9
View File
@@ -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
+3
View File
@@ -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",
+20 -4
View File
@@ -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<string, unknown> {
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<string> {
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<string, OpenFileItem>()
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)
}
+1 -1
View File
@@ -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 {
+13 -3
View File
@@ -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<Config> = 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 {
+61
View File
@@ -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)
})
})
+2 -5
View File
@@ -16,10 +16,7 @@ describe('client reference projection', () => {
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')
it('shows IDE, workspace, count and dirty state with ASCII separators', () => {
expect(candidateFromItem(item).description).toBe('VS Code | F:\\project | 4 open | unsaved changes')
})
})