Files
dsh-plugin-coop-with-vs/dsh-plugin/src/catalog.ts
T

99 lines
4.2 KiB
TypeScript
Raw Normal View History

2026-08-17 11:52:48 +08:00
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
}