Skip to content
Closed
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
124 changes: 124 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,130 @@ 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("matches punctuation-ended literal queries as whole words", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" });
yield* writeTextFile(cwd, "src/words.ts", "foo- foo-bar 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).toHaveLength(1);
expect(result.matches[0]).toMatchObject({
path: "src/words.ts",
lineNumber: 1,
matchRanges: [
{ start: 0, end: 4 },
{ start: 13, end: 17 },
],
});
}),
);

it.effect("matches punctuation-ended 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-bar foo-\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-"
expect(result.matches).toHaveLength(1);
expect(result.matches[0]).toMatchObject({
path: "src/words.ts",
lineNumber: 1,
});
expect(result.matches[0]?.matchRanges).toHaveLength(2);
}),
);

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]);
}),
);
});

describe("browse", () => {
it.effect("returns matching directories and excludes files", () =>
Effect.gen(function* () {
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
ProjectListEntriesResult,
ProjectSearchEntriesInput,
ProjectSearchEntriesResult,
ProjectSearchContentsInput,
ProjectSearchContentsResult,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
Expand Down Expand Up @@ -93,6 +95,9 @@ export class WorkspaceEntries extends Context.Service<
readonly search: (
input: ProjectSearchEntriesInput,
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
readonly searchContents: (
input: ProjectSearchContentsInput,
) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;
readonly refresh: (cwd: string) => Effect.Effect<void>;
}
>()("t3/workspace/WorkspaceEntries") {}
Expand Down Expand Up @@ -251,7 +256,17 @@ export const make = Effect.gen(function* () {
},
);

return WorkspaceEntries.of({ browse, list, refresh, search });
const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(
"WorkspaceEntries.searchContents",
)(function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.searchContents(input);
}).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
});

return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });
});

export const layer = Layer.effect(WorkspaceEntries, make).pipe(
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,16 @@ it.effect("preserves search and refresh failures with operation context", () =>
Effect.gen(function* () {
const searchCause = new Error("native search failed");
const refreshCause = new Error("native scan failed");
const contentSearchCause = new Error("native grep failed");
const finder = {
destroy: vi.fn(),
isScanning: vi.fn(() => false),
mixedSearch: vi.fn(() => {
throw searchCause;
}),
grep: vi.fn(() => {
throw contentSearchCause;
}),
scanFiles: vi.fn(() => {
throw refreshCause;
}),
Expand All @@ -100,6 +104,15 @@ it.effect("preserves search and refresh failures with operation context", () =>
const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
const query = "authorization: Bearer secret-token";
const searchError = yield* Effect.flip(searchIndex.search(query, 3));
const contentSearchError = yield* Effect.flip(
searchIndex.searchContents({
query,
limit: 3,
caseSensitive: false,
wholeWord: false,
useRegex: false,
}),
);
const refreshError = yield* Effect.flip(searchIndex.refresh());

expect(searchError).toMatchObject({
Expand All @@ -112,6 +125,15 @@ it.effect("preserves search and refresh failures with operation context", () =>
});
expect(searchError).not.toHaveProperty("query");
expect(searchError.message).not.toMatch(/Bearer|secret-token/);
expect(contentSearchError).toMatchObject({
_tag: "WorkspaceSearchIndexSearchFailed",
cwd: "/workspace/project",
queryLength: query.length,
pageSize: 3,
reason: "FileFinder.grep threw unexpectedly.",
cause: contentSearchCause,
});
expect(contentSearchError).not.toHaveProperty("query");
expect(refreshError).toMatchObject({
_tag: "WorkspaceSearchIndexRefreshFailed",
cwd: "/workspace/project",
Expand Down
88 changes: 86 additions & 2 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import * as Schema from "effect/Schema";
import type {
ProjectEntry,
ProjectListEntriesResult,
ProjectSearchContentsInput,
ProjectSearchContentsResult,
ProjectSearchEntriesResult,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -97,6 +99,9 @@ export class WorkspaceSearchIndex extends Context.Service<
query: string,
limit: number,
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
readonly searchContents: (
input: Omit<ProjectSearchContentsInput, "cwd">,
) => Effect.Effect<ProjectSearchContentsResult, WorkspaceSearchIndexSearchFailed>;
readonly refresh: () => Effect.Effect<
void,
WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut
Expand Down Expand Up @@ -175,7 +180,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c
FileFinder.create({
basePath: cwd,
disableMmapCache: true,
disableContentIndexing: true,
disableContentIndexing: false,
aiMode: false,
enableFsRootScanning: true,
enableHomeDirScanning: true,
Expand Down Expand Up @@ -307,7 +312,86 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
return mapMixedSearchResult(result, limit);
});

return WorkspaceSearchIndex.of({ list, refresh, search });
const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn(
"WorkspaceSearchIndex.searchContents",
)(function* (input) {
const escapedQuery = input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const wordCharacter = /[\p{Letter}\p{Mark}\p{Number}_]/u;
const firstCharacter = Array.from(input.query).at(0) ?? "";
const lastCharacter = Array.from(input.query).at(-1) ?? "";
// Use \b only when the pattern edge is a word character; otherwise \b cannot
// match (e.g. query "foo-" → \b(?:foo-)\b never matches "foo- "). Fall back to
// (?:^|\W)/(?:$|\W) for non-word edges, same as the literal whole-word path.
const wrapWholeWord = (pattern: string) =>
`${wordCharacter.test(firstCharacter) ? "\\b" : "(?:^|\\W)"}(?:${pattern})${wordCharacter.test(lastCharacter) ? "\\b" : "(?:$|\\W)"}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use non-consuming whole-word boundaries

When whole-word search is enabled for a query that both starts and ends with non-word characters, these fallback boundaries consume the separator characters around each hit. In a line like -foo- -foo-, searching whole-word for -foo- matches the first hit plus the trailing space, then the regex engine resumes at the second - where the leading (?:^|\W) can no longer see the separator, so the second hit is skipped. Use lookaround-style/non-consuming boundaries or otherwise avoid consuming delimiters before passing the pattern to grep.

Useful? React with 👍 / 👎.

const query = input.wholeWord
? wrapWholeWord(input.useRegex ? input.query : escapedQuery)
: input.query;
const regexMode = input.useRegex || input.wholeWord;
const searchQuery = input.caseSensitive
? query
: regexMode
? `(?i:${query})`
: query.toLowerCase();
const result = yield* Effect.try({
try: () =>
finder.grep(searchQuery, {
mode: regexMode ? "regex" : "plain",
smartCase: !input.caseSensitive && !regexMode,
maxMatchesPerFile: input.limit,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report truncation when one file hits the match cap

When a single file has more than limit matching lines, this per-file cap silently drops the rest, but truncated is computed only from nextCursor. If that capped file is the last or only eligible file, the cursor can still be null, so the dialog reports an untruncated result set even though matches were omitted. Avoid a separate per-file cap here or include this cap in the truncation signal.

Useful? React with 👍 / 👎.

pageSize: input.limit,
Comment on lines +338 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep search matches within previewable bytes

When FFF uses its default grep file-size cap (10 MB), this can return matches from bytes that the app cannot display: WorkspaceFileSystem only reads the first 1 MB for ProjectReadFileResult, and FilePreviewPanel then renders a truncated preview and clamps reveal requests to the loaded content. In files between 1 MB and 10 MB with a match after the first megabyte, selecting a search result opens the file but reveals the wrong/truncated location. Cap maxFileSize to the preview read limit or make the preview load enough content for searched matches.

Useful? React with 👍 / 👎.

timeBudgetMs: 250,
}),
catch: (cause) =>
new WorkspaceSearchIndexSearchFailed({
cwd,
queryLength: input.query.length,
pageSize: input.limit,
reason: "FileFinder.grep threw unexpectedly.",
cause,
}),
});
if (!result.ok) {
return yield* new WorkspaceSearchIndexSearchFailed({
cwd,
queryLength: input.query.length,
pageSize: input.limit,
reason: result.error,
});
}

const byteOffsetToStringIndex = (line: string, byteOffset: number): number =>
Buffer.from(line).subarray(0, byteOffset).toString().length;
const mapMatchRange = (line: string, startByte: number, endByte: number) => {
const start = byteOffsetToStringIndex(line, startByte);
const end = byteOffsetToStringIndex(line, endByte);
if (!input.wholeWord || input.useRegex) return { start, end };
Comment thread
cursor[bot] marked this conversation as resolved.

const matchedText = line.slice(start, end);
const queryIndex = input.caseSensitive
? matchedText.indexOf(input.query)
: matchedText.toLocaleLowerCase().indexOf(input.query.toLocaleLowerCase());
return queryIndex === -1
? { start, end }
: { start: start + queryIndex, end: start + queryIndex + input.query.length };
};
return {
matches: result.value.items.map((match) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Suppress fallback matches for invalid regexes

The fff grep API reports regexFallbackError after falling back to literal matching, but this code still maps result.value.items into normal search results. With regex enabled, an invalid pattern like foo[ can therefore render and open literal foo[ hits under the dialog's “Invalid regular expression” banner, which is not the requested regex result set. Treat regexFallbackError as an empty/error result before exposing matches.

Useful? React with 👍 / 👎.

path: toPosixPath(match.relativePath),
lineNumber: match.lineNumber,
lineContent: match.lineContent,
matchRanges: match.matchRanges.map(([start, end]) =>
mapMatchRange(match.lineContent, start, end),
),
})),
truncated: result.value.nextCursor !== null,
Comment thread
cursor[bot] marked this conversation as resolved.
...(result.value.regexFallbackError
? { regexFallbackError: result.value.regexFallbackError }
: {}),
};
});

return WorkspaceSearchIndex.of({ list, refresh, search, searchContents });
});

/**
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
type ProjectFileOperation,
ProjectListEntriesError,
ProjectReadFileError,
ProjectSearchContentsError,
ProjectSearchEntriesError,
ProjectWriteFileError,
RelayClientInstallFailedError,
Expand Down Expand Up @@ -314,6 +315,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[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 Expand Up @@ -1632,6 +1634,23 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsSearchContents]: (input) =>
observeRpcEffect(
WS_METHODS.projectsSearchContents,
workspaceEntries.searchContents(input).pipe(
Effect.mapError(
(cause) =>
new ProjectSearchContentsError({
cwd: input.cwd,
queryLength: input.query.length,
limit: input.limit,
...projectEntriesFailureContext(cause),
cause,
}),
),
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsListEntries]: (input) =>
observeRpcEffect(
WS_METHODS.projectsListEntries,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/commandPaletteBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function onOpenCommandPalette(
/** Read at event time so consumers do not subscribe to transient dialog state. */
export function isCommandPaletteOpen(): boolean {
return (
typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null
typeof document !== "undefined" &&
document.querySelector("[data-command-palette], [data-project-search]") !== null
);
}
Loading
Loading