Skip to content

Commit cb80b01

Browse files
committed
feat(directory-browser): add custom dialog for project and folder select
- Add DirectoryBrowserDialog component with breadcrumb navigation, filtering, and keyboard shortcuts - Implement browseDirectories server endpoint for listing directory contents - Replace native picker with custom dialog in add project and add folder flows - Add addProjectBaseDirectory setting to remember last project path - Simplify Sidebar state management by consolidating add-project logic - Support keyboard shortcuts (↑/↓ to navigate, Enter to select, Backspace/Cmd+Enter for navigation) - Add integration guard test for directory browser
1 parent b0ca20c commit cb80b01

12 files changed

Lines changed: 680 additions & 211 deletions

apps/server/src/serverSettings.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {
209209
}).pipe(Effect.provide(makeServerSettingsLayer())),
210210
);
211211

212+
it.effect("round-trips addProjectBaseDirectory through patch + decode", () =>
213+
Effect.gen(function* () {
214+
const serverSettings = yield* ServerSettingsService;
215+
216+
const next = yield* serverSettings.updateSettings({
217+
addProjectBaseDirectory: "/Users/tester/Projects",
218+
});
219+
220+
assert.equal(next.addProjectBaseDirectory, "/Users/tester/Projects");
221+
222+
const followup = yield* serverSettings.updateSettings({
223+
addProjectBaseDirectory: "A",
224+
});
225+
226+
assert.equal(followup.addProjectBaseDirectory, "A");
227+
}).pipe(Effect.provide(makeServerSettingsLayer())),
228+
);
229+
212230
it.effect("writes only non-default server settings to disk", () =>
213231
Effect.gen(function* () {
214232
const serverSettings = yield* ServerSettingsService;

apps/server/src/workspaceEntries.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
66

77
import { afterEach, assert, describe, it, vi } from "vitest";
88

9-
import { searchWorkspaceEntries } from "./workspaceEntries";
9+
import { browseDirectories, searchWorkspaceEntries } from "./workspaceEntries";
1010

1111
const tempDirs: string[] = [];
1212

@@ -200,3 +200,80 @@ describe("searchWorkspaceEntries", () => {
200200
assert.isAtMost(peakReads, 32);
201201
});
202202
});
203+
204+
describe("browseDirectories", () => {
205+
afterEach(() => {
206+
vi.restoreAllMocks();
207+
for (const dir of tempDirs.splice(0, tempDirs.length)) {
208+
fs.rmSync(dir, { recursive: true, force: true });
209+
}
210+
});
211+
212+
it("returns directory entries relative to cwd with resolvedParent", async () => {
213+
const cwd = makeTempDir("marcode-browse-basic-");
214+
fs.mkdirSync(path.join(cwd, "alpha"));
215+
fs.mkdirSync(path.join(cwd, "beta"));
216+
writeFile(cwd, "readme.md", "");
217+
218+
const result = await browseDirectories({ cwd, pathQuery: "", limit: 100 });
219+
const names = result.entries.map((entry) => entry.path);
220+
221+
assert.sameMembers(names, ["alpha", "beta"]);
222+
assert.equal(result.resolvedParent, path.resolve(cwd));
223+
assert.isFalse(result.truncated);
224+
});
225+
226+
it("resolves absolute paths in pathQuery regardless of cwd", async () => {
227+
const cwd = makeTempDir("marcode-browse-cwd-");
228+
const other = makeTempDir("marcode-browse-abs-");
229+
fs.mkdirSync(path.join(other, "nested"));
230+
231+
const result = await browseDirectories({ cwd, pathQuery: `${other}/`, limit: 100 });
232+
const names = result.entries.map((entry) => entry.path);
233+
234+
assert.equal(result.resolvedParent, path.resolve(other));
235+
assert.include(
236+
names.map((name) => path.basename(name)),
237+
"nested",
238+
);
239+
});
240+
241+
it("expands ~/ in cwd to the user's home directory", async () => {
242+
const homeDir = os.homedir();
243+
const sentinel = `marcode-browse-home-sentinel-${process.pid}-${Date.now()}`;
244+
const sentinelPath = path.join(homeDir, sentinel);
245+
fs.mkdirSync(sentinelPath);
246+
try {
247+
const result = await browseDirectories({ cwd: "~/", pathQuery: "", limit: 100 });
248+
249+
assert.equal(result.resolvedParent, path.resolve(homeDir));
250+
assert.isTrue(result.entries.some((entry) => path.basename(entry.path) === sentinel));
251+
} finally {
252+
fs.rmSync(sentinelPath, { recursive: true, force: true });
253+
}
254+
});
255+
256+
it("resolves ../ relative to cwd", async () => {
257+
const parent = makeTempDir("marcode-browse-parent-");
258+
const child = path.join(parent, "child");
259+
fs.mkdirSync(child);
260+
fs.mkdirSync(path.join(parent, "sibling"));
261+
262+
const result = await browseDirectories({ cwd: child, pathQuery: "../", limit: 100 });
263+
264+
assert.equal(result.resolvedParent, path.resolve(parent));
265+
const names = result.entries.map((entry) => path.basename(entry.path));
266+
assert.includeMembers(names, ["child", "sibling"]);
267+
});
268+
269+
it("returns empty entries and resolvedParent when directory does not exist", async () => {
270+
const cwd = makeTempDir("marcode-browse-missing-");
271+
const missing = path.join(cwd, "does-not-exist");
272+
273+
const result = await browseDirectories({ cwd: missing, pathQuery: "", limit: 100 });
274+
275+
assert.deepEqual([...result.entries], []);
276+
assert.equal(result.resolvedParent, path.resolve(missing));
277+
assert.isFalse(result.truncated);
278+
});
279+
});

apps/server/src/workspaceEntries.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs/promises";
22
import type { Dirent } from "node:fs";
3+
import os from "node:os";
34
import path from "node:path";
45
import { runProcess } from "./processRunner";
56

@@ -580,19 +581,29 @@ function parsePathQueryComponents(pathQuery: string): { parentDir: string; nameP
580581
};
581582
}
582583

584+
function expandHomePath(value: string): string {
585+
if (value === "~") return os.homedir();
586+
if (value.startsWith("~/") || value.startsWith("~\\")) {
587+
return path.join(os.homedir(), value.slice(2));
588+
}
589+
return value;
590+
}
591+
583592
export async function browseDirectories(
584593
input: ProjectBrowseDirectoriesInput,
585594
): Promise<ProjectBrowseDirectoriesResult> {
586-
const { parentDir, namePrefix } = parsePathQueryComponents(input.pathQuery);
587-
const resolvedParent = path.resolve(input.cwd, parentDir);
595+
const expandedCwd = expandHomePath(input.cwd);
596+
const expandedPathQuery = expandHomePath(input.pathQuery);
597+
const { parentDir, namePrefix } = parsePathQueryComponents(expandedPathQuery);
598+
const resolvedParent = path.resolve(expandedCwd, parentDir);
588599
const limit = Math.max(0, Math.floor(input.limit));
589600
const lowerPrefix = namePrefix.toLowerCase();
590601

591602
let dirents: Dirent[];
592603
try {
593604
dirents = await fs.readdir(resolvedParent, { withFileTypes: true });
594605
} catch {
595-
return { entries: [], truncated: false };
606+
return { entries: [], truncated: false, resolvedParent };
596607
}
597608

598609
dirents.sort((a, b) => a.name.localeCompare(b.name));
@@ -627,5 +638,5 @@ export async function browseDirectories(
627638
});
628639
}
629640

630-
return { entries, truncated };
641+
return { entries, truncated, resolvedParent };
631642
}

apps/web/src/components/ChatView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5171,6 +5171,8 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
51715171
>
51725172
<ComposerAttachmentsPopover
51735173
threadId={activeThread.id}
5174+
environmentId={activeThreadEnvironmentId ?? null}
5175+
projectCwd={activeProjectCwd}
51745176
additionalDirectories={activeThread.additionalDirectories}
51755177
onLocalDirectoriesChange={
51765178
isLocalDraftThread ? setDraftAdditionalDirectories : undefined

0 commit comments

Comments
 (0)