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
107 changes: 60 additions & 47 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
94 changes: 94 additions & 0 deletions client/src/gitignore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { workspace as Workspace, Uri } from "vscode";

/**
* Convert a single `.gitignore` pattern line into one or more VS Code glob
* patterns.
*
* 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 `/`
*
* 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.
*
* 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();

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` (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
): Promise<string[]> {
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<string>();
for (const line of content.split(/\r?\n/)) {
for (const glob of gitignoreLineToGlob(line)) {
globs.add(glob);
}
}
return Array.from(globs);
}
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@
"type": "array",
"default": [
"**/node_modules/**",
"**/bower_components/**"
"**/bower_components/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/out/**",
"**/coverage/**"
],
"items": {
"type": "string"
Expand All @@ -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 <link rel=\"stylesheet\" href=\"...\"> 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."
},
Comment on lines +128 to +133
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 98aa492. I removed the virtual-workspace claim from the PR body and from the readGitignoreGlobs doc comment, and now explicitly note that capabilities.virtualWorkspaces.supported: false and the server's Node fs usage still gate full virtual-workspace support. The .gitignore reader uses vscode.workspace.fs so it isn't the blocker, but I'm not claiming the extension supports virtual workspaces today.

"cssPeek.trace.server": {
"scope": "window",
"type": "string",
Expand Down
7 changes: 7 additions & 0 deletions tests/fixture/.gitignore
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions tests/src/gitignore.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>;
};
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)}`
);
});
});
Loading