Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
Expand Down
21 changes: 13 additions & 8 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,19 +547,24 @@ const make = Effect.gen(function* () {
});
}

const nextConfig = [...customConfig, ...missingDefaults];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
: nextConfig;
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
yield* Effect.logWarning("truncating keybindings config to max entries", {
// Startup backfill must never evict persisted user rules: append only
// the defaults that fit and skip the rest.
const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length);
const defaultsToAppend = missingDefaults.slice(0, availableSlots);
const skippedDefaults = missingDefaults.slice(availableSlots);
if (skippedDefaults.length > 0) {
yield* Effect.logWarning("skipping default keybinding backfill at max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
commands: skippedDefaults.map((rule) => rule.command),
});
}
if (defaultsToAppend.length === 0) {
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
return;
}

yield* writeConfigAtomically(cappedConfig);
yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]);
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
}),
);
Expand Down
324 changes: 323 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv)
return result.stdout.trim();
});

const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) =>
const searchWorkspaceEntries = (input: {
cwd: string;
query: string;
limit: number;
kind?: "file" | "directory";
}) =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
return yield* workspaceEntries.search(input);
Expand Down Expand Up @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
}),
);

it.effect("applies the file filter before limiting search results", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" });
yield* writeTextFile(cwd, "src/index.ts");
yield* writeTextFile(cwd, "src/internal.ts");

const result = yield* searchWorkspaceEntries({
cwd,
query: "src",
limit: 1,
kind: "file",
});

expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]);
expect(result.truncated).toBe(true);
}),
);

it.effect("answers an empty file-filtered query with a bounded file listing", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" });
yield* writeTextFile(cwd, "src/index.ts");
yield* writeTextFile(cwd, "README.md");

const result = yield* searchWorkspaceEntries({
cwd,
query: "",
limit: 10,
kind: "file",
});

const paths = result.entries.map((entry) => entry.path);
expect(paths).toHaveLength(2);
expect(paths).toContain("src/index.ts");
expect(paths).toContain("README.md");
expect(result.entries.every((entry) => entry.kind === "file")).toBe(true);
}),
);

it.effect("returns only directories for the directory filter", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" });
yield* writeTextFile(cwd, "src/index.ts");

const result = yield* searchWorkspaceEntries({
cwd,
query: "src",
limit: 10,
kind: "directory",
});

expect(result.entries).toEqual([{ path: "src", kind: "directory" }]);
expect(result.truncated).toBe(false);
}),
);

it.effect("excludes gitignored paths for git repositories", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
Expand Down Expand Up @@ -292,6 +353,267 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
);
});

describe("searchContents", () => {
it.effect("returns content matches with file paths, line numbers, and ranges", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" });
yield* writeTextFile(
cwd,
"src/shapes.ts",
"export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n",
);
yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "Square",
limit: 100,
caseSensitive: false,
wholeWord: true,
useRegex: false,
});

expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([
["src/shapes.ts", 1],
["src/shapes.ts", 2],
]);
expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]);
expect(result.truncated).toBe(false);
}),
);

it.effect("honors case sensitivity and gitignore rules", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true });
yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n");
yield* writeTextFile(cwd, "ignored.txt", "Square\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "Square",
limit: 100,
caseSensitive: true,
wholeWord: false,
useRegex: false,
});

expect(result.matches).toHaveLength(1);
expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 });
}),
);

it.effect("filters whole-word matches by word boundaries without widening ranges", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" });
yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "note",
limit: 100,
caseSensitive: true,
wholeWord: true,
useRegex: false,
});

// "notes", "denote", and "footnote" are word-adjacent and excluded;
// ranges cover exactly the query, never boundary characters.
expect(result.matches).toEqual([
expect.objectContaining({
path: "src/words.ts",
lineNumber: 1,
matchRanges: [{ start: 0, end: 4 }],
}),
expect.objectContaining({
path: "src/words.ts",
lineNumber: 2,
matchRanges: [{ start: 9, end: 13 }],
}),
]);
}),
);

it.effect("finds later whole-word matches in a file after rejected raw matches", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" });
yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`);

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "foo",
limit: 1,
caseSensitive: true,
wholeWord: true,
useRegex: false,
});

expect(result.matches).toEqual([
expect.objectContaining({
path: "src/words.ts",
lineNumber: 11,
matchRanges: [{ start: 0, end: 3 }],
}),
]);
}),
);

it.effect("treats astral-plane letters as whole word characters", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" });
yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "foo",
limit: 100,
caseSensitive: true,
wholeWord: true,
useRegex: false,
});

expect(result.matches).toEqual([
expect.objectContaining({
path: "src/words.ts",
lineNumber: 1,
matchRanges: [{ start: 6, end: 9 }],
}),
]);
}),
);

it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" });
yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "-foo-",
limit: 100,
caseSensitive: true,
wholeWord: true,
useRegex: false,
});

// Consuming-boundary regex would swallow the separating spaces and
// drop the middle occurrence; boundary post-filtering keeps all three.
expect(result.matches).toHaveLength(1);
expect(result.matches[0]).toMatchObject({
path: "src/words.ts",
lineNumber: 1,
matchRanges: [
{ start: 0, end: 5 },
{ start: 6, end: 11 },
{ start: 12, end: 17 },
],
});
}),
);

it.effect("matches punctuation-edged regex queries as whole words", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" });
yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "foo-",
limit: 100,
caseSensitive: true,
wholeWord: true,
useRegex: true,
});

// wholeWord + useRegex must not silently drop non-word-edged patterns
// like "foo-", and "afoo-" is excluded because 'a'/'f' are both word
// characters at the match's left edge.
expect(result.matches).toHaveLength(1);
expect(result.matches[0]).toMatchObject({
path: "src/words.ts",
lineNumber: 1,
matchRanges: [
{ start: 0, end: 4 },
{ start: 5, end: 9 },
],
});
}),
);

it.effect("caps matches per file so one dense file cannot fill the page", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" });
yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300));
yield* writeTextFile(cwd, "src/other.ts", "needle\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "needle",
limit: 500,
caseSensitive: true,
wholeWord: false,
useRegex: false,
});

const byPath = new Map<string, number>();
for (const match of result.matches) {
byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1);
}
expect(byPath.get("src/dense.ts")).toBe(100);
expect(byPath.get("src/other.ts")).toBe(1);
}),
);

it.effect("preserves regex escapes during case-insensitive searches", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" });
yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "\\SQUARE",
limit: 100,
caseSensitive: false,
wholeWord: false,
useRegex: true,
});

expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]);
}),
);

it.effect("maps multi-byte lines to string-indexed ranges", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" });
yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n');

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.searchContents({
cwd,
query: "wörld",
limit: 100,
caseSensitive: true,
wholeWord: false,
useRegex: false,
});

expect(result.matches).toHaveLength(1);
const match = result.matches[0]!;
const range = match.matchRanges[0]!;
expect(match.lineContent.slice(range.start, range.end)).toBe("wörld");
}),
);
});

describe("browse", () => {
it.effect("returns matching directories and excludes files", () =>
Effect.gen(function* () {
Expand Down
Loading
Loading