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
17 changes: 3 additions & 14 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -152,16 +151,6 @@ function isMissingPathError(cause: unknown): boolean {
return code === "ENOENT" || code === "ENOTDIR";
}

function expandHomePath(input: string, path: Path.Path): string {
if (input === "~") {
return NodeOS.homedir();
}
if (input.startsWith("~/") || input.startsWith("~\\")) {
return path.join(NodeOS.homedir(), input.slice(2));
}
return input;
}

const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
input: FilesystemBrowseInput,
path: Path.Path,
Expand All @@ -176,15 +165,15 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu
}

if (!isExplicitRelativePath(input.partialPath)) {
return path.resolve(expandHomePath(input.partialPath, path));
return path.resolve(WorkspacePaths.expandHomePath(input.partialPath, path));
}

if (!input.cwd) {
return yield* new WorkspaceEntriesCurrentProjectRequiredError({
partialPath: input.partialPath,
});
}
return path.resolve(expandHomePath(input.cwd, path), input.partialPath);
return path.resolve(WorkspacePaths.expandHomePath(input.cwd, path), input.partialPath);
});

export const make = Effect.gen(function* () {
Expand Down Expand Up @@ -301,7 +290,7 @@ export const make = Effect.gen(function* () {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.list();
return yield* searchIndex.list(input.showDotfiles ?? false);
}).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
},
);
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,19 +135,20 @@ export const make = Effect.gen(function* () {
const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
"WorkspaceFileSystem.readFile",
)(function* (input) {
const workspaceRoot = path.resolve(WorkspacePaths.expandHomePath(input.cwd.trim(), path));
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
workspaceRoot,
relativePath: input.relativePath,
});

const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
try: () => NodeFSP.realpath(workspaceRoot),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operationPath: workspaceRoot,
operation: "realpath-workspace-root",
cause,
}),
Expand Down Expand Up @@ -262,8 +263,9 @@ export const make = Effect.gen(function* () {
const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
"WorkspaceFileSystem.writeFile",
)(function* (input) {
const workspaceRoot = path.resolve(WorkspacePaths.expandHomePath(input.cwd.trim(), path));
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
workspaceRoot,
relativePath: input.relativePath,
});

Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/workspace/WorkspacePaths.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeOS from "node:os";
import { it, describe, expect } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -168,6 +170,27 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
});

describe("resolveRelativePathWithinRoot", () => {
it.effect("expands home-relative workspace roots", () =>
Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const path = yield* Path.Path;

const homeRoot = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: "~",
relativePath: "notes/todo.md",
});
const homeSubdirectory = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: "~/projects",
relativePath: "README.md",
});

expect(homeRoot.absolutePath).toBe(path.join(NodeOS.homedir(), "notes/todo.md"));
expect(homeSubdirectory.absolutePath).toBe(
path.join(NodeOS.homedir(), "projects/README.md"),
);
}),
);

it.effect("resolves relative paths inside the workspace root", () =>
Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/workspace/WorkspacePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function toPosixRelativePath(input: string): string {
return input.replaceAll("\\", "/");
}

function expandHomePath(input: string, path: Path.Path): string {
export function expandHomePath(input: string, path: Path.Path): string {
if (input === "~") {
return NodeOS.homedir();
}
Expand Down Expand Up @@ -209,8 +209,9 @@ export const make = Effect.gen(function* () {
});
}

const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
const workspaceRoot = path.resolve(expandHomePath(input.workspaceRoot.trim(), path));
const absolutePath = path.resolve(workspaceRoot, normalizedInputPath);
const relativeToRoot = toPosixRelativePath(path.relative(workspaceRoot, absolutePath));
if (
relativeToRoot.length === 0 ||
relativeToRoot === "." ||
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { FileFinder } from "@ff-labs/fff-node";
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { afterEach, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -157,3 +161,42 @@ it.effect("keeps returned search diagnostics out of the cause chain", () =>
}),
),
);

it.effect("walks dot-directories without descending into .git", () =>
Effect.scoped(
Effect.gen(function* () {
const cwd = yield* Effect.acquireRelease(
Effect.tryPromise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-dotfiles-"))),
(directory) =>
Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })),
);
yield* Effect.promise(async () => {
await NodeFSP.mkdir(NodePath.join(cwd, ".github", "workflows"), { recursive: true });
await NodeFSP.mkdir(NodePath.join(cwd, ".git"), { recursive: true });
await NodeFSP.writeFile(NodePath.join(cwd, ".github", "workflows", "ci.yml"), "name: CI");
await NodeFSP.writeFile(NodePath.join(cwd, ".git", "config"), "secret");
await NodeFSP.writeFile(NodePath.join(cwd, ".env"), "TOKEN=test");
});
const finder = {
destroy: vi.fn(),
isScanning: vi.fn(() => false),
mixedSearch: vi.fn(() => ({
ok: true,
value: { items: [], totalMatched: 0 },
})),
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

const searchIndex = yield* WorkspaceSearchIndex.make(cwd);
const result = yield* searchIndex.list(true);

expect(result.entries).toEqual([
{ path: ".env", kind: "file" },
{ path: ".github", kind: "directory" },
{ path: ".github/workflows", kind: "directory" },
{ path: ".github/workflows/ci.yml", kind: "file" },
]);
expect(result.truncated).toBe(false);
}),
),
);
79 changes: 75 additions & 4 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node";
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -17,6 +20,7 @@ const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
const WORKSPACE_INDEX_IDLE_TTL = "15 minutes";
const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis";
const DOTFILE_SCAN_DENYLIST = new Set([".git", "node_modules"]);

export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()(
"WorkspaceSearchIndexCreateFailed",
Expand Down Expand Up @@ -92,7 +96,9 @@ export type WorkspaceSearchIndexError =
export class WorkspaceSearchIndex extends Context.Service<
WorkspaceSearchIndex,
{
readonly list: () => Effect.Effect<ProjectListEntriesResult, WorkspaceSearchIndexSearchFailed>;
readonly list: (
showDotfiles: boolean,
) => Effect.Effect<ProjectListEntriesResult, WorkspaceSearchIndexSearchFailed>;
readonly search: (
query: string,
limit: number,
Expand Down Expand Up @@ -169,6 +175,66 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn
return [...entryByPath.values()];
}

const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")(function* (
cwd: string,
entries: ReadonlyArray<ProjectEntry>,
) {
const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
const queue = [
{ path: "", walkContents: false },
...entries
.filter((entry) => entry.kind === "directory")
.map((entry) => ({ path: entry.path, walkContents: false })),
];
const queuedPaths = new Set(queue.map((entry) => entry.path));
let queueIndex = 0;
let truncated = false;

while (queueIndex < queue.length && entryByPath.size < WORKSPACE_INDEX_MAX_ENTRIES) {
const batch = queue.slice(queueIndex, queueIndex + 16);
queueIndex += batch.length;
const batchEntries = yield* Effect.forEach(
batch,
(directory) =>
Effect.tryPromise(() =>
NodeFSP.readdir(NodePath.join(cwd, directory.path), { withFileTypes: true }),
).pipe(Effect.orElseSucceed(() => [])),
{ concurrency: "unbounded" },
);

for (let index = 0; index < batch.length; index += 1) {
const directory = batch[index];
if (!directory) continue;
for (const dirent of batchEntries[index] ?? []) {
if (DOTFILE_SCAN_DENYLIST.has(dirent.name)) continue;
if (!directory.walkContents && !dirent.name.startsWith(".")) continue;
const entryPath = toPosixPath(NodePath.join(directory.path, dirent.name));
if (!entryByPath.has(entryPath)) {
if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) {
truncated = true;
break;
}
entryByPath.set(entryPath, {
path: entryPath,
kind: dirent.isDirectory() ? "directory" : "file",
});
}
if (dirent.isDirectory() && !queuedPaths.has(entryPath)) {
if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) {
truncated = true;
break;
}
queuedPaths.add(entryPath);
queue.push({ path: entryPath, walkContents: true });
}
}
if (truncated) break;
}
}
if (queueIndex < queue.length) truncated = true;
return { entries: [...entryByPath.values()], truncated };
});

const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {
const result = yield* Effect.try({
try: () =>
Expand Down Expand Up @@ -286,16 +352,21 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
});

const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
function* () {
function* (showDotfiles) {
const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE);
const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
const entriesWithAncestors = withDirectoryAncestors(mapped.entries);
const supplemented = showDotfiles
? yield* supplementDotfiles(cwd, entriesWithAncestors)
: { entries: entriesWithAncestors, truncated: false };
const sortedEntries = supplemented.entries.toSorted((left, right) =>
left.path.localeCompare(right.path),
);
const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES);
return {
entries,
truncated: mapped.truncated || entries.length < sortedEntries.length,
truncated:
mapped.truncated || supplemented.truncated || entries.length < sortedEntries.length,
};
},
);
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
import {
fileSurfaceId,
selectActiveRightPanel,
selectActiveRightPanelSurface,
selectThreadRightPanelState,
Expand Down Expand Up @@ -1431,11 +1432,11 @@ function ChatViewContent(props: ChatViewProps) {
? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS)
: EMPTY_PENDING_FILE_SURFACE_IDS;
const handleFilePendingChange = useCallback(
(relativePath: string, pending: boolean) => {
(relativePath: string, cwd: string, pending: boolean) => {
if (!activeProjectKey) return;
setPendingFileSurfaceIdsByProject((currentByProject) => {
const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS;
const surfaceId = `file:${relativePath}`;
const surfaceId = fileSurfaceId(relativePath, cwd);
if (current.has(surfaceId) === pending) return currentByProject;
const next = new Set(current);
if (pending) next.add(surfaceId);
Expand Down Expand Up @@ -2816,9 +2817,9 @@ function ChatViewContent(props: ChatViewProps) {
useRightPanelStore.getState().open(activeThreadRef, "files");
}, [activeProject, activeThreadRef]);
const openFileSurface = useCallback(
(relativePath: string) => {
(relativePath: string, cwd: string) => {
if (!activeThreadRef || !activeProject) return;
useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
useRightPanelStore.getState().openFile(activeThreadRef, relativePath, undefined, cwd);
},
[activeProject, activeThreadRef],
);
Expand Down Expand Up @@ -5026,7 +5027,8 @@ function ChatViewContent(props: ChatViewProps) {
<FilePreviewPanel
key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
environmentId={activeProject.environmentId}
cwd={activeWorkspaceRoot}
cwd={activeFileSurface?.cwd ?? activeWorkspaceRoot}
projectRoot={activeWorkspaceRoot}
projectName={activeProject.title}
threadRef={activeThreadRef}
composerDraftTarget={composerDraftTarget}
Expand Down
Loading
Loading