Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions client/package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1 @@
{
"name": "vscode-css-peek-client-part",
"description": "VSCode part of the language server",
"license": "MIT",
"author": "Pranay Prakash <pranay.gp@gmail.com>",
"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 <pranay.gp@gmail.com>","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"}}
45 changes: 45 additions & 0 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as path from "path";
import { minimatch } from "minimatch";
import {
workspace as Workspace,
window as Window,
Expand All @@ -8,6 +9,7 @@ import {
WorkspaceFolder,
Uri,
WorkspaceConfiguration,
FileSystemWatcher,
} from "vscode";

import {
Expand All @@ -34,6 +36,10 @@ const clients: Map<string, LanguageClient> = 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<string> = 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<string, FileSystemWatcher> = new Map();

const READ_CONCURRENCY = 16;
const utf8Decoder = new TextDecoder("utf-8");
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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", {
Expand All @@ -377,6 +418,10 @@ export function deactivate(): Thenable<void> {
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());
}
Expand Down
14 changes: 14 additions & 0 deletions client/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
36 changes: 25 additions & 11 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/src/stylesheetLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading