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
1 change: 1 addition & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ 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("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
Expand Down
25 changes: 24 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,24 @@ 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("excludes gitignored paths for git repositories", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";

import * as WorkspacePaths from "./WorkspacePaths.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
Expand Down Expand Up @@ -230,13 +231,12 @@ export const make = Effect.gen(function* () {
const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(
function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
const normalizedQuery = input.query
.trim()
.toLowerCase()
.replace(/^[@./]+/, "");
const normalizedQuery = normalizeSearchQuery(input.query, {
trimLeadingPattern: /^[@./]+/,
});
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.search(normalizedQuery, input.limit);
return yield* searchIndex.search(normalizedQuery, input.limit, input.kind);
}).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
},
);
Expand Down
83 changes: 75 additions & 8 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node";
import {
type DirItem,
type DirSearchResult,
type FileItem,
FileFinder,
type MixedItem,
type MixedSearchResult,
type Result,
type SearchResult,
} from "@ff-labs/fff-node";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -8,6 +17,7 @@ import * as Schema from "effect/Schema";

import type {
ProjectEntry,
ProjectEntryKind,
ProjectListEntriesResult,
ProjectSearchEntriesResult,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -96,6 +106,7 @@ export class WorkspaceSearchIndex extends Context.Service<
readonly search: (
query: string,
limit: number,
kind?: ProjectEntryKind,
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
readonly refresh: () => Effect.Effect<
void,
Expand Down Expand Up @@ -129,6 +140,16 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null {
};
}

function toFileEntry(item: FileItem): ProjectEntry | null {
const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
return normalizedPath ? { path: normalizedPath, kind: "file" } : null;
}

function toDirectoryEntry(item: DirItem): ProjectEntry | null {
const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;
}

function mapMixedSearchResult(
result: MixedSearchResult,
limit: number,
Expand All @@ -155,6 +176,33 @@ function mapMixedSearchResult(
};
}

function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult {
return {
entries: result.items
.flatMap((item) => {
const entry = toFileEntry(item);
return entry ? [entry] : [];
})
.slice(0, limit),
truncated: result.totalMatched > limit,
};
}

function mapDirectorySearchResult(
result: DirSearchResult,
limit: number,
): ProjectSearchEntriesResult {
const entries = result.items.flatMap((item) => {
const entry = toDirectoryEntry(item);
return entry ? [entry] : [];
});
const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0;
return {
entries: entries.slice(0, limit),
truncated: result.totalMatched - rootDirectoryCount > limit,
};
}

function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] {
const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
for (const entry of entries) {
Expand Down Expand Up @@ -229,18 +277,20 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
}),
);

const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(
query: string,
pageSize: number,
) {
operation: "directorySearch" | "fileSearch" | "mixedSearch",
execute: () => Result<A>,
): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> {
const result = yield* Effect.try({
try: () => finder.mixedSearch(query, { pageSize }),
try: execute,
catch: (cause) =>
new WorkspaceSearchIndexSearchFailed({
cwd,
queryLength: query.length,
pageSize,
reason: "FileFinder.mixedSearch threw unexpectedly.",
reason: `FileFinder.${operation} threw unexpectedly.`,
cause,
}),
});
Expand Down Expand Up @@ -287,7 +337,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin

const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
function* () {
const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE);
const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () =>
finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }),
);
const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
left.path.localeCompare(right.path),
Expand All @@ -302,8 +354,23 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin

const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
"WorkspaceSearchIndex.search",
)(function* (query, limit) {
const result = yield* runMixedSearch(query, Math.max(1, limit + 1));
)(function* (query, limit, kind) {
const pageSize = Math.max(1, limit + 1);
if (kind === "file") {
const result = yield* runSearch(query, pageSize, "fileSearch", () =>
finder.fileSearch(query, { pageSize }),
);
return mapFileSearchResult(result, limit);
}
if (kind === "directory") {
const result = yield* runSearch(query, pageSize, "directorySearch", () =>
finder.directorySearch(query, { pageSize }),
);
return mapDirectorySearchResult(result, limit);
}
const result = yield* runSearch(query, pageSize, "mixedSearch", () =>
finder.mixedSearch(query, { pageSize }),
);
return mapMixedSearchResult(result, limit);
});

Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,55 @@ import type { Thread } from "../types";
import {
buildThreadActionItems,
filterCommandPaletteGroups,
reduceCommandPaletteUiState,
type CommandPaletteGroup,
} from "./CommandPalette.logic";

const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const PROJECT_ID = ProjectId.make("project-1");

describe("reduceCommandPaletteUiState", () => {
const closedState = { open: false, mode: "command", openIntent: null } as const;

it("opens, switches, and closes command and file-picker modes", () => {
const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" });
expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null });

const commandOpen = reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleCommand" });
expect(commandOpen).toEqual({ open: true, mode: "command", openIntent: null });

expect(reduceCommandPaletteUiState(commandOpen, { _tag: "ToggleCommand" })).toEqual({
open: false,
mode: "command",
openIntent: null,
});
});

it("routes add-project requests back to command mode", () => {
const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" });
expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({
open: true,
mode: "command",
openIntent: { kind: "add-project" },
});
});

it("resets file-picker mode for dialog trigger opens and closes", () => {
const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" });

expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({
open: false,
mode: "command",
openIntent: null,
});
expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({
open: true,
mode: "command",
openIntent: null,
});
});
});

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: ThreadId.make("thread-1"),
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,55 @@ export const RECENT_THREAD_LIMIT = 12;
export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
export const ADDON_ICON_CLASS = "size-4";

export interface CommandPaletteOpenIntent {
readonly kind: "add-project";
}

export interface CommandPaletteUiState {
readonly open: boolean;
readonly mode: "command" | "files";
readonly openIntent: CommandPaletteOpenIntent | null;
}

export type CommandPaletteUiAction =
| { readonly _tag: "SetOpen"; readonly open: boolean }
| { readonly _tag: "ToggleCommand" }
| { readonly _tag: "ToggleFiles" }
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "ClearOpenIntent" };

export function reduceCommandPaletteUiState(
state: CommandPaletteUiState,
action: CommandPaletteUiAction,
): CommandPaletteUiState {
switch (action._tag) {
case "SetOpen":
return {
open: action.open,
mode: "command",
openIntent: action.open ? state.openIntent : null,
Comment thread
jakeleventhal marked this conversation as resolved.
};
case "ToggleCommand":
return state.open && state.mode === "command"
? { ...state, open: false, openIntent: null }
: { open: true, mode: "command", openIntent: null };
case "ToggleFiles":
return state.open && state.mode === "files"
? { ...state, open: false, openIntent: null }
: { open: true, mode: "files", openIntent: null };
case "OpenAddProject":
return { open: true, mode: "command", openIntent: { kind: "add-project" } };
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
}

export interface CommandPaletteItem {
readonly kind: "action" | "submenu";
readonly value: string;
readonly searchTerms: ReadonlyArray<string>;
readonly title: ReactNode;
readonly description?: string;
readonly description?: ReactNode;
readonly timestamp?: string;
readonly icon: ReactNode;
readonly disabled?: boolean;
Expand Down
Loading
Loading