diff --git a/client/package.json b/client/package.json index dbb228a..85bee1d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,21 +1 @@ -{ - "name": "vscode-css-peek-client-part", - "description": "VSCode part of the language server", - "license": "MIT", - "author": "Pranay Prakash ", - "repository": { - "type": "git", - "url": "https://github.com/pranaygp/vscode-css-peek.git" - }, - "engines": { - "vscode": "^1.80.0" - }, - "scripts": {}, - "dependencies": { - "@vscode/extension-telemetry": "^0.8.1", - "vscode-languageclient": "^8.1.0" - }, - "devDependencies": { - "@types/vscode": "^1.80.0" - } -} +{"name":"vscode-css-peek-client-part","description":"VSCode part of the language server","license":"MIT","author":"Pranay Prakash ","repository":{"type":"git","url":"https://github.com/pranaygp/vscode-css-peek.git"},"engines":{"vscode":"^1.80.0"},"scripts":{},"dependencies":{"@vscode/extension-telemetry":"^0.8.1","minimatch":"^9.0.3","vscode-languageclient":"^8.1.0"},"devDependencies":{"@types/vscode":"^1.80.0"}} diff --git a/client/src/extension.ts b/client/src/extension.ts index 2e9f113..52a1cf6 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import { minimatch } from "minimatch"; import { workspace as Workspace, window as Window, @@ -8,6 +9,7 @@ import { WorkspaceFolder, Uri, WorkspaceConfiguration, + FileSystemWatcher, } from "vscode"; import { @@ -34,6 +36,10 @@ const clients: Map = new Map(); // I/O — otherwise a second matching document opened in the same folder // during that window would spawn a duplicate server (see #154). const pendingClientFolders: Set = new Set(); +// One filesystem watcher per workspace folder. Disposed when the folder is +// removed or the extension is deactivated so we don't keep firing +// notifications at stopped LanguageClients. +const watchers: Map = new Map(); const READ_CONCURRENCY = 16; const utf8Decoder = new TextDecoder("utf-8"); @@ -252,7 +258,37 @@ export function activate(context: ExtensionContext): void { method: "client.start", error: err instanceof Error ? err.message : String(err), }); + return; } + + // Watch the workspace for stylesheet add/delete events so the server's + // StylesheetMap stays in sync without a VSCode restart. We only handle + // create/delete (ignoreChangeEvents=true) because the LSP documents + // sync covers content edits to open files. + const watcher = Workspace.createFileSystemWatcher( + `{${(peekToInclude || []).join(",")}}`, + false, + true, + false + ); + const isExcluded = (uri: Uri): boolean => { + if (uri.scheme !== "file") return true; + return (peekToExclude || []).some( + (glob) => + minimatch(uri.fsPath, glob) || minimatch(uri.toString(), glob) + ); + }; + watcher.onDidDelete((uri) => { + if (isExcluded(uri)) return; + client.sendNotification("cssPeek/stylesheetDeleted", uri.toString()); + }); + watcher.onDidCreate(async (uri) => { + if (isExcluded(uri)) return; + const stylesheet = await readStylesheet(uri); + if (!stylesheet) return; + client.sendNotification("cssPeek/stylesheetCreated", stylesheet); + }); + watchers.set(folderKey, watcher); } finally { pendingClientFolders.delete(folderKey); } @@ -353,6 +389,11 @@ export function activate(context: ExtensionContext): void { // continuation in startClientForFolder bails out before constructing a // client. pendingClientFolders.delete(folderKey); + const watcher = watchers.get(folderKey); + if (watcher) { + watcher.dispose(); + watchers.delete(folderKey); + } const client = clients.get(folderKey); if (client) { sendTelemetryEvent("Workspace Folder Closed", { @@ -377,6 +418,10 @@ export function deactivate(): Thenable { if (defaultClient) { promises.push(defaultClient.stop()); } + for (const watcher of watchers.values()) { + watcher.dispose(); + } + watchers.clear(); for (const client of clients.values()) { promises.push(client.stop()); } diff --git a/client/yarn.lock b/client/yarn.lock index 495d92a..e4cabce 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -320,6 +320,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^2.0.2: + version "2.1.0" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.0.tgz#4f41a41190216ee36067ec381526fe9539c4f0ae" + integrity sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w== + dependencies: + balanced-match "^1.0.0" + cls-hooked@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" @@ -453,6 +460,13 @@ minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.3: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" diff --git a/server/src/server.ts b/server/src/server.ts index 3d00c10..0f562c6 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -195,6 +195,13 @@ documents.onDidClose((e) => { documentSettings.delete(e.document.uri); }); +function loadStylesheet(file: Stylesheet): void { + const document = TextDocument.create(file.uri, file.languageId, 1, file.text); + styleSheets[file.uri] = { + document, + }; +} + function setupInitialStyleMap(params: InitializeParams) { const styleFiles: Stylesheet[] = params.initializationOptions.stylesheets; @@ -207,19 +214,26 @@ function setupInitialStyleMap(params: InitializeParams) { // Stylesheet contents are read on the client (via `vscode.workspace.fs`) // and shipped over LSP so the server never touches the host file system. // This keeps the server compatible with virtual workspaces and the web build. - styleFiles.forEach((file) => { - const document = TextDocument.create( - file.uri, - file.languageId, - 1, - file.text - ); - styleSheets[file.uri] = { - document, - }; - }); + styleFiles.forEach(loadStylesheet); } +// Keep the StylesheetMap in sync with the workspace filesystem. The client +// pushes these notifications when a stylesheet is created or deleted on disk, +// so peek results don't reference files that no longer exist. +connection.onNotification("cssPeek/stylesheetDeleted", (uri: string) => { + connection.console.log( + `[Server(${process.pid})] Stylesheet deleted: ${path.basename(uri)}` + ); + delete styleSheets[uri]; +}); + +connection.onNotification("cssPeek/stylesheetCreated", (file: Stylesheet) => { + connection.console.log( + `[Server(${process.pid})] Stylesheet created: ${path.basename(file.uri)}` + ); + loadStylesheet(file); +}); + connection.onDefinition( async ( textDocumentPositon: TextDocumentPositionParams diff --git a/tests/src/stylesheetLifecycle.test.ts b/tests/src/stylesheetLifecycle.test.ts new file mode 100644 index 0000000..829c171 --- /dev/null +++ b/tests/src/stylesheetLifecycle.test.ts @@ -0,0 +1,83 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import { TextDocument as ServerTextDocument } from "vscode-languageserver"; + +import { findDefinition } from "../../server/out/core/findDefinition"; +import { create } from "../../server/out/logger"; +import type { Stylesheet, StylesheetMap, Selector } from "../../server/src/types"; + +// This test mirrors the *logic* of the cssPeek/stylesheetCreated and +// cssPeek/stylesheetDeleted handlers (load -> confirm peek -> delete entry -> +// confirm peek empty); it does not invoke the server handlers directly. +// Cross-check `server/src/server.ts` `loadStylesheet` if behavior changes. +function loadStylesheet(map: StylesheetMap, file: Stylesheet): void { + const document = ServerTextDocument.create( + file.uri, + file.languageId, + 1, + file.text + ); + map[file.uri] = { document }; +} + +const utf8Decoder = new TextDecoder("utf-8"); + +suite("Stylesheet create/delete lifecycle", () => { + create(console as any); + + test("removes deleted stylesheets from peek results", async () => { + const map: StylesheetMap = {}; + + // 1. Create a temporary CSS fixture file + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "css-peek-test-")); + const fsPath = path.join(tmpDir, "ephemeral.css"); + fs.writeFileSync(fsPath, ".ephemeral-class { color: rebeccapurple; }\n"); + const uri = vscode.Uri.file(fsPath); + + try { + // Simulate the client reading the file and pushing the payload to the + // server via cssPeek/stylesheetCreated. + const bytes = await vscode.workspace.fs.readFile(uri); + const stylesheet: Stylesheet = { + uri: uri.toString(), + languageId: "css", + text: utf8Decoder.decode(bytes), + }; + loadStylesheet(map, stylesheet); + + // 2. Confirm peek finds a symbol in it + const selector: Selector = { + attribute: "class", + value: "ephemeral-class", + }; + const before = findDefinition(selector, map); + assert.strictEqual( + before.length, + 1, + "Expected to find the class before deletion" + ); + assert.strictEqual(before[0].uri, uri.toString()); + + // 3. Delete the fixture from disk + prune the server's cache + // (this is what the cssPeek/stylesheetDeleted notification handler does) + fs.unlinkSync(fsPath); + delete map[uri.toString()]; + + // 4. Confirm peek no longer finds the symbol + const after = findDefinition(selector, map); + assert.strictEqual( + after.length, + 0, + "Expected no results after stylesheet deletion" + ); + } finally { + if (fs.existsSync(fsPath)) { + fs.unlinkSync(fsPath); + } + fs.rmdirSync(tmpDir); + } + }); +});