From 12e6f232899f0d1c9661dc3722229e741af3680d Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 16 May 2026 13:32:26 -0700 Subject: [PATCH 1/2] feat(client): respect .gitignore + better exclude defaults (#145) Adds cssPeek.respectGitignore (default true) which merges the workspace root .gitignore into peekToExclude before discovering stylesheets. Also expands the default cssPeek.peekToExclude list with common build artifact directories so peek doesn't surface duplicates from dist/, build/, .next/, out/, coverage/, and .git/. Closes #145 Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/extension.ts | 107 ++++++++++++++++++++---------------- client/src/gitignore.ts | 79 ++++++++++++++++++++++++++ package.json | 14 ++++- tests/fixture/.gitignore | 7 +++ tests/src/gitignore.test.ts | 83 ++++++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 client/src/gitignore.ts create mode 100644 tests/fixture/.gitignore create mode 100644 tests/src/gitignore.test.ts diff --git a/client/src/extension.ts b/client/src/extension.ts index 42d1dcf..177391e 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -22,6 +22,7 @@ import { sendTelemetryErrorEvent, setTelemetryEnabled, } from "./telemetry"; +import { readGitignoreGlobs } from "./gitignore"; const SUPPORTED_EXTENSIONS = ["css", "scss", "less"]; const SUPPORTED_EXTENSION_REGEX = /\.(css|scss|less)$/; @@ -103,6 +104,7 @@ export function activate(context: ExtensionContext): void { "peekToLinkedOnly", false ) as boolean; + const respectGitignore: boolean = config.get("respectGitignore") as boolean; function didOpenTextDocument(document: TextDocument): void { try { @@ -191,54 +193,65 @@ export function activate(context: ExtensionContext): void { telemetryData.workspaceFolder = folder; if (!clients.has(folder.uri.toString())) { - Workspace.findFiles( - `{${(peekToInclude || []).join(",")}}`, - `{${(peekToExclude || []).join(",")}}` - ).then((file_searches) => { - const potentialFiles: Uri[] = file_searches.filter( - (uri: Uri) => uri.scheme === "file" - ); + const gitignorePromise = respectGitignore + ? readGitignoreGlobs(folder.uri) + : Promise.resolve([] as string[]); - const debugOptions = { - execArgv: ["--nolazy", `--inspect=${6011 + clients.size}`], - }; - const serverOptions = { - run: { module, transport: TransportKind.ipc }, - debug: { - module, - transport: TransportKind.ipc, - options: debugOptions, - }, - }; - const clientOptions: LanguageClientOptions = { - documentSelector, - diagnosticCollectionName: "css-peek", - synchronize: { - configurationSection: "cssPeek", - }, - initializationOptions: { - stylesheets: potentialFiles.map((u) => ({ - uri: u.toString(), - // TODO: don't rely on fsPath in a virtual workspace - // https://github.com/microsoft/vscode/wiki/Virtual-Workspaces - fsPath: u.fsPath, - })), - peekFromLanguages, - peekToLinkedOnly, - }, - workspaceFolder: folder, - outputChannel, - }; - const client = new LanguageClient( - "css-peek", - "CSS Peek", - serverOptions, - clientOptions - ); - client.registerProposedFeatures(); - client.start(); - clients.set(folder.uri.toString(), client); - }); + gitignorePromise + .then((gitignoreGlobs) => { + const mergedExcludes = Array.from( + new Set([...(peekToExclude || []), ...gitignoreGlobs]) + ); + return Workspace.findFiles( + `{${(peekToInclude || []).join(",")}}`, + `{${mergedExcludes.join(",")}}` + ); + }) + .then((file_searches) => { + const potentialFiles: Uri[] = file_searches.filter( + (uri: Uri) => uri.scheme === "file" + ); + + const debugOptions = { + execArgv: ["--nolazy", `--inspect=${6011 + clients.size}`], + }; + const serverOptions = { + run: { module, transport: TransportKind.ipc }, + debug: { + module, + transport: TransportKind.ipc, + options: debugOptions, + }, + }; + const clientOptions: LanguageClientOptions = { + documentSelector, + diagnosticCollectionName: "css-peek", + synchronize: { + configurationSection: "cssPeek", + }, + initializationOptions: { + stylesheets: potentialFiles.map((u) => ({ + uri: u.toString(), + // TODO: don't rely on fsPath in a virtual workspace + // https://github.com/microsoft/vscode/wiki/Virtual-Workspaces + fsPath: u.fsPath, + })), + peekFromLanguages, + peekToLinkedOnly, + }, + workspaceFolder: folder, + outputChannel, + }; + const client = new LanguageClient( + "css-peek", + "CSS Peek", + serverOptions, + clientOptions + ); + client.registerProposedFeatures(); + client.start(); + clients.set(folder.uri.toString(), client); + }); } sendTelemetryEvent("Document Opened", telemetryData); } catch (e) { diff --git a/client/src/gitignore.ts b/client/src/gitignore.ts new file mode 100644 index 0000000..b7d35b9 --- /dev/null +++ b/client/src/gitignore.ts @@ -0,0 +1,79 @@ +import { workspace as Workspace, Uri } from "vscode"; + +/** + * Convert a single `.gitignore` pattern line into one or more VS Code glob + * patterns. + * + * Returns an empty array for lines that should be skipped (blank, comment, + * negation, or patterns we can't represent as a simple glob). + * + * Best-effort conversion covering common cases (directory names, file names, + * simple globs). When the line could match either a file or a directory, we + * emit both globs since VS Code globs distinguish files from folders. + * + * Known limitations: negation lines (`!pattern`), character classes, and + * patterns with `**` semantics that differ from gitignore are not handled. + */ +export function gitignoreLineToGlob(rawLine: string): string[] { + const line = rawLine.trim(); + + if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) { + return []; + } + + let pattern = line; + + const isDirectoryPattern = pattern.endsWith("/"); + if (isDirectoryPattern) { + pattern = pattern.slice(0, -1); + } + + const isAnchored = pattern.startsWith("/"); + if (isAnchored) { + pattern = pattern.slice(1); + } + + if (pattern.length === 0) { + return []; + } + + // Patterns with a slash are workspace-root-relative; bare names match + // anywhere in the tree. + const base = isAnchored || pattern.includes("/") ? pattern : `**/${pattern}`; + + // A directory marker (`foo/`) only matches the directory and its contents. + // Without it, gitignore matches both files and directories — emit both. + if (isDirectoryPattern) { + return [`${base}/**`]; + } + return [base, `${base}/**`]; +} + +/** + * Read the workspace root's `.gitignore` and return a set of VS Code glob + * patterns that approximate its semantics. + * + * Uses `vscode.workspace.fs` so it works in virtual workspaces. Returns an + * empty array if the file does not exist or cannot be read. + */ +export async function readGitignoreGlobs( + workspaceRoot: Uri +): Promise { + const gitignoreUri = Uri.joinPath(workspaceRoot, ".gitignore"); + + let bytes: Uint8Array; + try { + bytes = await Workspace.fs.readFile(gitignoreUri); + } catch { + return []; + } + + const content = Buffer.from(bytes).toString("utf-8"); + const globs = new Set(); + for (const line of content.split(/\r?\n/)) { + for (const glob of gitignoreLineToGlob(line)) { + globs.add(glob); + } + } + return Array.from(globs); +} diff --git a/package.json b/package.json index bf60742..309b347 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,13 @@ "type": "array", "default": [ "**/node_modules/**", - "**/bower_components/**" + "**/bower_components/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/out/**", + "**/coverage/**" ], "items": { "type": "string" @@ -119,6 +125,12 @@ "default": false, "description": "When enabled, peek targets are restricted to stylesheets that are referenced from the current source file (e.g. via in HTML or `import './foo.css'` / `@import 'foo.css'` in JS/TS/SCSS). Useful in large monorepos to avoid matching unrelated stylesheets." }, + "cssPeek.respectGitignore": { + "scope": "window", + "type": "boolean", + "default": true, + "description": "When enabled, patterns from the workspace root's .gitignore are merged into cssPeek.peekToExclude so peek does not match files Git ignores. Negation (!pattern) lines and nested .gitignore files are not supported." + }, "cssPeek.trace.server": { "scope": "window", "type": "string", diff --git a/tests/fixture/.gitignore b/tests/fixture/.gitignore new file mode 100644 index 0000000..2bbc8c7 --- /dev/null +++ b/tests/fixture/.gitignore @@ -0,0 +1,7 @@ +# Fixture .gitignore used by gitignore.test.ts to verify +# that readGitignoreGlobs parses a workspace-root .gitignore. +# These paths intentionally do not match any real fixture file. +excluded.css +/anchored-build +dist/ +!unignore.css diff --git a/tests/src/gitignore.test.ts b/tests/src/gitignore.test.ts new file mode 100644 index 0000000..af75327 --- /dev/null +++ b/tests/src/gitignore.test.ts @@ -0,0 +1,83 @@ +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const gitignoreMod = require("../../client/out/gitignore") as { + gitignoreLineToGlob: (line: string) => string[]; + readGitignoreGlobs: (uri: vscode.Uri) => Promise; +}; +const { gitignoreLineToGlob, readGitignoreGlobs } = gitignoreMod; + +suite("gitignoreLineToGlob", () => { + test("skips blank lines and comments", () => { + assert.deepStrictEqual(gitignoreLineToGlob(""), []); + assert.deepStrictEqual(gitignoreLineToGlob(" "), []); + assert.deepStrictEqual(gitignoreLineToGlob("# comment"), []); + }); + + test("skips negation lines", () => { + assert.deepStrictEqual(gitignoreLineToGlob("!keep.css"), []); + }); + + test("unanchored bare names match both files and directories anywhere", () => { + assert.deepStrictEqual(gitignoreLineToGlob("node_modules"), [ + "**/node_modules", + "**/node_modules/**", + ]); + assert.deepStrictEqual(gitignoreLineToGlob("excluded.css"), [ + "**/excluded.css", + "**/excluded.css/**", + ]); + }); + + test("trailing slash matches directories only", () => { + assert.deepStrictEqual(gitignoreLineToGlob("dist/"), ["**/dist/**"]); + }); + + test("leading slash anchors to workspace root", () => { + assert.deepStrictEqual(gitignoreLineToGlob("/build"), [ + "build", + "build/**", + ]); + assert.deepStrictEqual(gitignoreLineToGlob("/build/"), ["build/**"]); + }); + + test("nested paths are relative to workspace root", () => { + assert.deepStrictEqual(gitignoreLineToGlob("packages/foo/dist"), [ + "packages/foo/dist", + "packages/foo/dist/**", + ]); + }); + + test("trims whitespace", () => { + assert.deepStrictEqual(gitignoreLineToGlob(" coverage "), [ + "**/coverage", + "**/coverage/**", + ]); + }); +}); + +suite("readGitignoreGlobs", () => { + test("returns empty array when .gitignore is missing", async () => { + const tmpRoot = vscode.Uri.file( + path.join(__dirname, `__missing_gitignore_${Date.now()}`) + ); + const globs = await readGitignoreGlobs(tmpRoot); + assert.deepStrictEqual(globs, []); + }); + + test("parses .gitignore from the workspace root", async () => { + const root = vscode.workspace.workspaceFolders![0].uri; + const globs = await readGitignoreGlobs(root); + // .gitignore in the test fixture contains `excluded.css`. + assert.ok( + globs.includes("**/excluded.css"), + `expected file glob; got ${JSON.stringify(globs)}` + ); + assert.ok( + globs.includes("**/excluded.css/**"), + `expected dir glob; got ${JSON.stringify(globs)}` + ); + }); +}); From 06f123ed865715e0157d063011b1dc1a1f920964 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 16 May 2026 17:01:21 -0700 Subject: [PATCH 2/2] docs(gitignore): clarify converter behavior and virtual-workspace scope The doc comments previously over-promised: they implied unsupported gitignore constructs would be detected and skipped, and that this file working in virtual workspaces meant the extension as a whole did. Neither is true. Update the comments to accurately describe what is skipped vs. passed through, and to note that virtual-workspace support is not declared at the extension level. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/gitignore.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/client/src/gitignore.ts b/client/src/gitignore.ts index b7d35b9..f95576c 100644 --- a/client/src/gitignore.ts +++ b/client/src/gitignore.ts @@ -4,15 +4,27 @@ import { workspace as Workspace, Uri } from "vscode"; * Convert a single `.gitignore` pattern line into one or more VS Code glob * patterns. * - * Returns an empty array for lines that should be skipped (blank, comment, - * negation, or patterns we can't represent as a simple glob). + * Lines explicitly skipped (returns an empty array): + * - blank lines (after trimming) + * - comments (lines starting with `#`) + * - negation lines (lines starting with `!`) + * - lines that reduce to an empty pattern after stripping a leading `/` + * or trailing `/` * - * Best-effort conversion covering common cases (directory names, file names, - * simple globs). When the line could match either a file or a directory, we - * emit both globs since VS Code globs distinguish files from folders. + * All other lines are passed through with minimal rewriting (anchoring, + * directory-vs-file expansion, bare-name -> `**\/name` lift). This is a + * best-effort conversion covering the common cases (directory names, file + * names, simple `*` globs). When the line could match either a file or a + * directory, we emit both globs since VS Code globs distinguish files from + * folders. * - * Known limitations: negation lines (`!pattern`), character classes, and - * patterns with `**` semantics that differ from gitignore are not handled. + * Unsupported gitignore constructs are NOT detected — they are passed + * through verbatim and may produce wrong or best-effort matches: + * - character classes (e.g. `[abc]`, `[!a-z]`) + * - escape sequences (`\#`, `\!`, `\ `) + * - `**` in positions where gitignore semantics differ from VS Code's + * glob semantics (common shapes like `foo/**` and `**\/foo` work; + * exotic placements may not) */ export function gitignoreLineToGlob(rawLine: string): string[] { const line = rawLine.trim(); @@ -53,8 +65,11 @@ export function gitignoreLineToGlob(rawLine: string): string[] { * Read the workspace root's `.gitignore` and return a set of VS Code glob * patterns that approximate its semantics. * - * Uses `vscode.workspace.fs` so it works in virtual workspaces. Returns an - * empty array if the file does not exist or cannot be read. + * Uses `vscode.workspace.fs` (rather than Node `fs`) so the reader itself + * is not tied to local disk. Note: the extension as a whole does not yet + * declare virtual-workspace support — see `capabilities.virtualWorkspaces` + * in `package.json` and the server's direct `fs` usage. Returns an empty + * array if the file does not exist or cannot be read. */ export async function readGitignoreGlobs( workspaceRoot: Uri