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