diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 923b6f1a3d1..3d4a997cbc4 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -209,6 +209,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("round-trips addProjectBaseDirectory through patch + decode", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "/Users/tester/Projects", + }); + + assert.equal(next.addProjectBaseDirectory, "/Users/tester/Projects"); + + const followup = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "A", + }); + + assert.equal(followup.addProjectBaseDirectory, "A"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("writes only non-default server settings to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index 3bc95280c00..3bf9e174bd7 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { afterEach, assert, describe, it, vi } from "vitest"; -import { searchWorkspaceEntries } from "./workspaceEntries"; +import { browseDirectories, searchWorkspaceEntries } from "./workspaceEntries"; const tempDirs: string[] = []; @@ -200,3 +200,80 @@ describe("searchWorkspaceEntries", () => { assert.isAtMost(peakReads, 32); }); }); + +describe("browseDirectories", () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns directory entries relative to cwd with resolvedParent", async () => { + const cwd = makeTempDir("marcode-browse-basic-"); + fs.mkdirSync(path.join(cwd, "alpha")); + fs.mkdirSync(path.join(cwd, "beta")); + writeFile(cwd, "readme.md", ""); + + const result = await browseDirectories({ cwd, pathQuery: "", limit: 100 }); + const names = result.entries.map((entry) => entry.path); + + assert.sameMembers(names, ["alpha", "beta"]); + assert.equal(result.resolvedParent, path.resolve(cwd)); + assert.isFalse(result.truncated); + }); + + it("resolves absolute paths in pathQuery regardless of cwd", async () => { + const cwd = makeTempDir("marcode-browse-cwd-"); + const other = makeTempDir("marcode-browse-abs-"); + fs.mkdirSync(path.join(other, "nested")); + + const result = await browseDirectories({ cwd, pathQuery: `${other}/`, limit: 100 }); + const names = result.entries.map((entry) => entry.path); + + assert.equal(result.resolvedParent, path.resolve(other)); + assert.include( + names.map((name) => path.basename(name)), + "nested", + ); + }); + + it("expands ~/ in cwd to the user's home directory", async () => { + const homeDir = os.homedir(); + const sentinel = `marcode-browse-home-sentinel-${process.pid}-${Date.now()}`; + const sentinelPath = path.join(homeDir, sentinel); + fs.mkdirSync(sentinelPath); + try { + const result = await browseDirectories({ cwd: "~/", pathQuery: "", limit: 100 }); + + assert.equal(result.resolvedParent, path.resolve(homeDir)); + assert.isTrue(result.entries.some((entry) => path.basename(entry.path) === sentinel)); + } finally { + fs.rmSync(sentinelPath, { recursive: true, force: true }); + } + }); + + it("resolves ../ relative to cwd", async () => { + const parent = makeTempDir("marcode-browse-parent-"); + const child = path.join(parent, "child"); + fs.mkdirSync(child); + fs.mkdirSync(path.join(parent, "sibling")); + + const result = await browseDirectories({ cwd: child, pathQuery: "../", limit: 100 }); + + assert.equal(result.resolvedParent, path.resolve(parent)); + const names = result.entries.map((entry) => path.basename(entry.path)); + assert.includeMembers(names, ["child", "sibling"]); + }); + + it("returns empty entries and resolvedParent when directory does not exist", async () => { + const cwd = makeTempDir("marcode-browse-missing-"); + const missing = path.join(cwd, "does-not-exist"); + + const result = await browseDirectories({ cwd: missing, pathQuery: "", limit: 100 }); + + assert.deepEqual([...result.entries], []); + assert.equal(result.resolvedParent, path.resolve(missing)); + assert.isFalse(result.truncated); + }); +}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index c4ab076847c..bc8d254d164 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import type { Dirent } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { runProcess } from "./processRunner"; @@ -580,11 +581,21 @@ function parsePathQueryComponents(pathQuery: string): { parentDir: string; nameP }; } +function expandHomePath(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + export async function browseDirectories( input: ProjectBrowseDirectoriesInput, ): Promise { - const { parentDir, namePrefix } = parsePathQueryComponents(input.pathQuery); - const resolvedParent = path.resolve(input.cwd, parentDir); + const expandedCwd = expandHomePath(input.cwd); + const expandedPathQuery = expandHomePath(input.pathQuery); + const { parentDir, namePrefix } = parsePathQueryComponents(expandedPathQuery); + const resolvedParent = path.resolve(expandedCwd, parentDir); const limit = Math.max(0, Math.floor(input.limit)); const lowerPrefix = namePrefix.toLowerCase(); @@ -592,7 +603,7 @@ export async function browseDirectories( try { dirents = await fs.readdir(resolvedParent, { withFileTypes: true }); } catch { - return { entries: [], truncated: false }; + return { entries: [], truncated: false, resolvedParent }; } dirents.sort((a, b) => a.name.localeCompare(b.name)); @@ -627,5 +638,5 @@ export async function browseDirectories( }); } - return { entries, truncated }; + return { entries, truncated, resolvedParent }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 75850affbdd..94764223da1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5171,6 +5171,8 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: > ["updateSettings"]; - shouldShowProjectPathEntry: boolean; handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2115,20 +2102,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder, threadSortOrder, updateSettings, - shouldShowProjectPathEntry, handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2167,27 +2141,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); - }, - [setAddProjectError, setNewCwd], - ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }, - [handleAddProject, setAddProjectError, setAddingProject], - ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); - return ( @@ -2253,68 +2206,18 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( render={ - )} -
- - -
- {addProjectError && ( -

- {addProjectError} -

- )} - - )} {isManualProjectSorting ? ( )} - {projectsLength === 0 && !shouldShowProjectPathEntry && ( + {projectsLength === 0 && (
No projects yet
@@ -2413,6 +2316,7 @@ export default function Sidebar() { const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const addProjectBaseDirectory = useSettings((s) => s.addProjectBaseDirectory); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2422,12 +2326,8 @@ export default function Sidebar() { }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); + const [isAddProjectDialogOpen, setIsAddProjectDialogOpen] = useState(false); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -2439,10 +2339,7 @@ export default function Sidebar() { const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2634,12 +2531,6 @@ export default function Sidebar() { if (!api) return; setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; const existing = projects.find((project) => project.cwd === cwd); if (existing) { @@ -2647,7 +2538,7 @@ export default function Sidebar() { environmentId: existing.environmentId, projectId: existing.id, }); - finishAddingProject(); + setIsAddingProject(false); return; } @@ -2674,19 +2565,14 @@ export default function Sidebar() { } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } finally { setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; } - finishAddingProject(); }, [ focusMostRecentThreadForProject, @@ -2694,43 +2580,20 @@ export default function Sidebar() { handleNewThread, isAddingProject, projects, - shouldBrowseForProjectImmediately, defaultThreadEnvMode, ], ); - const handleAddProject = () => { - void addProjectFromInput(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromInput(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; + const handleStartAddProject = useCallback(() => { + setIsAddProjectDialogOpen(true); + }, []); - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; + const handleConfirmAddProject = useCallback( + async (absolutePath: string) => { + await addProjectFromInput(absolutePath); + }, + [addProjectFromInput], + ); const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { @@ -3223,20 +3086,7 @@ export default function Sidebar() { projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} updateSettings={updateSettings} - shouldShowProjectPathEntry={shouldShowProjectPathEntry} handleStartAddProject={handleStartAddProject} - isElectron={isElectron} - isPickingFolder={isPickingFolder} - isAddingProject={isAddingProject} - handlePickFolder={handlePickFolder} - addProjectInputRef={addProjectInputRef} - addProjectError={addProjectError} - newCwd={newCwd} - setNewCwd={setNewCwd} - setAddProjectError={setAddProjectError} - handleAddProject={handleAddProject} - setAddingProject={setAddingProject} - canAddProject={canAddProject} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} projectCollisionDetection={projectCollisionDetection} @@ -3268,6 +3118,20 @@ export default function Sidebar() { )} + + 0 + ? addProjectBaseDirectory + : "~/" + } + title="Add Project" + confirmLabel="Add Project" + onConfirm={handleConfirmAddProject} + /> ); } diff --git a/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx b/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx index 172f4c81ffa..4a91dfbe572 100644 --- a/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { FolderIcon, FolderPlusIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"; -import type { ThreadId, RuntimeMode } from "@marcode/contracts"; +import type { EnvironmentId, ThreadId, RuntimeMode } from "@marcode/contracts"; import { Button } from "../ui/button"; import { Menu, @@ -18,9 +18,12 @@ import { readNativeApi } from "~/nativeApi"; import { newCommandId } from "~/lib/utils"; import { basenameOfPath } from "~/vscode-icons"; import { toastManager } from "~/components/ui/toast"; +import { DirectoryBrowserDialog } from "./DirectoryBrowserDialog"; interface ComposerAttachmentsPopoverProps { threadId: ThreadId; + environmentId: EnvironmentId | null; + projectCwd: string | null; additionalDirectories: readonly string[]; onLocalDirectoriesChange?: ((directories: string[]) => void) | undefined; runtimeMode: RuntimeMode; @@ -31,6 +34,8 @@ interface ComposerAttachmentsPopoverProps { export function ComposerAttachmentsPopover({ threadId, + environmentId, + projectCwd, additionalDirectories, onLocalDirectoriesChange, runtimeMode, @@ -40,7 +45,7 @@ export function ComposerAttachmentsPopover({ }: ComposerAttachmentsPopoverProps) { const fileInputRef = useRef(null); const menuHandleRef = useRef(MenuCreateHandle()); - const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isDirBrowserOpen, setIsDirBrowserOpen] = useState(false); const count = additionalDirectories.length; @@ -73,32 +78,35 @@ export function ComposerAttachmentsPopover({ [threadId, onLocalDirectoriesChange], ); - const pickingRef = useRef(false); - const handlePickFolder = useCallback(async () => { - const api = readNativeApi(); - if (!api || pickingRef.current) return; - pickingRef.current = true; - setIsPickingFolder(true); - try { - menuHandleRef.current.close(); - const pickedPath = await api.dialogs.pickFolder(); - if (pickedPath && !additionalDirectories.includes(pickedPath)) { - await dispatchMetaUpdate([...additionalDirectories, pickedPath]); + const handleOpenDirBrowser = useCallback(() => { + menuHandleRef.current.close(); + setIsDirBrowserOpen(true); + }, []); + + const handleConfirmDirectory = useCallback( + async (absolutePath: string) => { + try { + if (!additionalDirectories.includes(absolutePath)) { + await dispatchMetaUpdate([...additionalDirectories, absolutePath]); + } + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add folder", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred while adding the folder.", + }); } - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to add folder", - description: - error instanceof Error - ? error.message - : "An unexpected error occurred while adding the folder.", - }); - } finally { - pickingRef.current = false; - setIsPickingFolder(false); - } - }, [additionalDirectories, dispatchMetaUpdate]); + }, + [additionalDirectories, dispatchMetaUpdate], + ); + + // Initial path for the Add folder dialog: project cwd with "/../" so the + // browser opens at the parent of the project directory (e.g. project + // "/personal/marcode" → dialog opens at "/personal"). + const dirBrowserInitialPath = projectCwd ? `${projectCwd.replace(/\/+$/, "")}/../` : "~/"; const removeDirectory = useCallback( (path: string) => { @@ -136,11 +144,7 @@ export function ComposerAttachmentsPopover({ Attach image - void handlePickFolder()} - disabled={isPickingFolder} - > + Add folder @@ -195,6 +199,16 @@ export function ComposerAttachmentsPopover({ className="hidden" onChange={handleFileChange} /> + + ); } diff --git a/apps/web/src/components/chat/DirectoryBrowserDialog.tsx b/apps/web/src/components/chat/DirectoryBrowserDialog.tsx new file mode 100644 index 00000000000..d299397fde9 --- /dev/null +++ b/apps/web/src/components/chat/DirectoryBrowserDialog.tsx @@ -0,0 +1,386 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { + ArrowUpIcon, + ChevronRightIcon, + FolderOpenIcon, + HomeIcon, + MonitorIcon, + SearchIcon, +} from "lucide-react"; + +import type { EnvironmentId, ProjectEntry } from "@marcode/contracts"; +import { Button } from "../ui/button"; +import { Dialog, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { Kbd, KbdGroup } from "../ui/kbd"; +import { projectBrowseDirectoriesQueryOptions } from "~/lib/projectReactQuery"; +import { readLocalApi } from "~/localApi"; +import { useTheme } from "~/hooks/useTheme"; +import { basenameOfPath } from "~/vscode-icons"; +import { cn, isMacPlatform } from "~/lib/utils"; +import { VscodeEntryIcon } from "./VscodeEntryIcon"; + +const FILTER_DEBOUNCE_MS = 120; + +interface DirectoryBrowserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + environmentId: EnvironmentId | null; + initialPath: string; + title: string; + confirmLabel: string; + onConfirm: (absolutePath: string) => void | Promise; + allowNativePicker?: boolean; +} + +function normalizeTrailingSlash(value: string): string { + if (value.length <= 1) return value; + return value.replace(/\/+$/, ""); +} + +function joinPath(parent: string, child: string): string { + const cleanParent = normalizeTrailingSlash(parent); + const cleanChild = child.replace(/^\/+/, ""); + if (cleanParent === "") return `/${cleanChild}`; + return `${cleanParent}/${cleanChild}`; +} + +function parentOfPath(absolutePath: string): string | null { + const normalized = normalizeTrailingSlash(absolutePath); + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash < 0) return null; + if (lastSlash === 0) return normalized === "/" ? null : "/"; + return normalized.slice(0, lastSlash); +} + +function buildBreadcrumbSegments(absolutePath: string): Array<{ label: string; path: string }> { + const normalized = normalizeTrailingSlash(absolutePath); + if (!normalized || normalized === "/") { + return [{ label: "/", path: "/" }]; + } + const parts = normalized.split("/").filter(Boolean); + const segments: Array<{ label: string; path: string }> = [{ label: "/", path: "/" }]; + let accumulator = ""; + for (const part of parts) { + accumulator = `${accumulator}/${part}`; + segments.push({ label: part, path: accumulator }); + } + return segments; +} + +export function DirectoryBrowserDialog({ + open, + onOpenChange, + environmentId, + initialPath, + title, + confirmLabel, + onConfirm, + allowNativePicker = true, +}: DirectoryBrowserDialogProps) { + const { resolvedTheme } = useTheme(); + const [currentPath, setCurrentPath] = useState(initialPath); + const [filterInput, setFilterInput] = useState(""); + const [debouncedFilter] = useDebouncedValue(filterInput, { wait: FILTER_DEBOUNCE_MS }); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [isConfirming, setIsConfirming] = useState(false); + const listRef = useRef(null); + const filterInputRef = useRef(null); + + useEffect(() => { + if (open) { + setCurrentPath(initialPath); + setFilterInput(""); + setHighlightedIndex(-1); + setIsConfirming(false); + } + }, [open, initialPath]); + + const browseQuery = useQuery( + projectBrowseDirectoriesQueryOptions({ + environmentId, + cwd: currentPath, + pathQuery: "", + enabled: open && environmentId !== null && currentPath.length > 0, + }), + ); + + const entries = browseQuery.data?.entries; + const resolvedParent = browseQuery.data?.resolvedParent ?? ""; + + const filteredEntries = useMemo(() => { + if (!entries) return []; + const lower = debouncedFilter.trim().toLowerCase(); + const showHidden = lower.startsWith("."); + return entries.filter((entry) => { + const name = basenameOfPath(entry.path).toLowerCase(); + if (!showHidden && name.startsWith(".")) return false; + if (lower.length === 0) return true; + return name.includes(lower); + }); + }, [entries, debouncedFilter]); + + useEffect(() => { + setHighlightedIndex(filteredEntries.length > 0 ? 0 : -1); + }, [filteredEntries]); + + useEffect(() => { + if (highlightedIndex < 0 || !listRef.current) return; + const el = listRef.current.children[highlightedIndex] as HTMLElement | undefined; + el?.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex]); + + const breadcrumbSegments = useMemo( + () => buildBreadcrumbSegments(resolvedParent || currentPath), + [resolvedParent, currentPath], + ); + + const navigateInto = useCallback( + (entry: ProjectEntry) => { + const base = resolvedParent || currentPath; + const name = basenameOfPath(entry.path); + setCurrentPath(joinPath(base, name)); + setFilterInput(""); + filterInputRef.current?.focus(); + }, + [resolvedParent, currentPath], + ); + + const canGoUp = useMemo(() => { + const base = resolvedParent || currentPath; + return parentOfPath(base) !== null; + }, [resolvedParent, currentPath]); + + const goUp = useCallback(() => { + const base = resolvedParent || currentPath; + const parent = parentOfPath(base); + if (parent !== null) { + setCurrentPath(parent); + setFilterInput(""); + } + }, [resolvedParent, currentPath]); + + const goHome = useCallback(() => { + setCurrentPath("~/"); + setFilterInput(""); + }, []); + + const handleConfirm = useCallback( + async (absolutePath: string) => { + if (!absolutePath || isConfirming) return; + setIsConfirming(true); + try { + await onConfirm(absolutePath); + onOpenChange(false); + } finally { + setIsConfirming(false); + } + }, + [onConfirm, onOpenChange, isConfirming], + ); + + const handleNativePicker = useCallback(async () => { + const api = readLocalApi(); + if (!api) return; + let picked: string | null = null; + try { + picked = await api.dialogs.pickFolder(); + } catch { + return; + } + if (picked) { + await handleConfirm(picked); + } + }, [handleConfirm]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + if (filteredEntries.length === 0) return; + event.preventDefault(); + setHighlightedIndex((prev) => (prev + 1) % filteredEntries.length); + } else if (event.key === "ArrowUp") { + if (filteredEntries.length === 0) return; + event.preventDefault(); + setHighlightedIndex((prev) => (prev <= 0 ? filteredEntries.length - 1 : prev - 1)); + } else if (event.key === "Enter") { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + void handleConfirm(resolvedParent || currentPath); + return; + } + if (highlightedIndex >= 0 && filteredEntries[highlightedIndex]) { + event.preventDefault(); + navigateInto(filteredEntries[highlightedIndex]); + } + } else if (event.key === "Backspace" && filterInput === "" && canGoUp) { + event.preventDefault(); + goUp(); + } + }, + [ + filteredEntries, + highlightedIndex, + navigateInto, + handleConfirm, + resolvedParent, + currentPath, + filterInput, + canGoUp, + goUp, + ], + ); + + const canConfirm = (resolvedParent || currentPath).length > 0 && !isConfirming; + const isDesktop = typeof window !== "undefined" && Boolean(window.desktopBridge); + const isMac = typeof navigator !== "undefined" ? isMacPlatform(navigator.platform) : false; + const modKeyLabel = isMac ? "⌘" : "Ctrl"; + + return ( + + + + {title} +
+ {breadcrumbSegments.map((segment, index) => ( +
+ {index > 0 && } + +
+ ))} +
+
+ +
+
+ + +
+ + setFilterInput(e.target.value)} + autoFocus + /> +
+
+ +
+ {browseQuery.isFetching && (entries?.length ?? 0) === 0 ? ( + Loading… + ) : filteredEntries.length === 0 ? ( + + {browseQuery.isError + ? "Unable to read this directory." + : (entries?.length ?? 0) === 0 + ? "This directory is empty or inaccessible." + : "No folders match the filter."} + + ) : ( + filteredEntries.map((entry, index) => { + const isHighlighted = index === highlightedIndex; + return ( + + ); + }) + )} +
+
+ + + {allowNativePicker && isDesktop ? ( + + ) : null} + + + +
+
+ ); +} diff --git a/apps/web/src/components/chat/directoryBrowser.integration-guard.test.ts b/apps/web/src/components/chat/directoryBrowser.integration-guard.test.ts new file mode 100644 index 00000000000..c9203e9d9df --- /dev/null +++ b/apps/web/src/components/chat/directoryBrowser.integration-guard.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const COMPOSER_POPOVER_PATH = path.resolve(__dirname, "ComposerAttachmentsPopover.tsx"); +const CHATVIEW_PATH = path.resolve(__dirname, "..", "ChatView.tsx"); +const SIDEBAR_PATH = path.resolve(__dirname, "..", "Sidebar.tsx"); + +function readSource(filePath: string): string { + return fs.readFileSync(filePath, "utf-8"); +} + +describe("Directory browser integration guard", () => { + it("ComposerAttachmentsPopover imports and renders DirectoryBrowserDialog", () => { + const src = readSource(COMPOSER_POPOVER_PATH); + expect(src).toMatch( + /import\s*\{\s*DirectoryBrowserDialog\s*\}\s*from\s*"\.\/DirectoryBrowserDialog"/, + ); + expect(src).toContain(" { + const src = readSource(COMPOSER_POPOVER_PATH); + expect(src).toMatch(/projectCwd:\s*string\s*\|\s*null/); + expect(src).toMatch(/environmentId:\s*EnvironmentId\s*\|\s*null/); + }); + + it("ChatView passes projectCwd and environmentId to ComposerAttachmentsPopover", () => { + const src = readSource(CHATVIEW_PATH); + const composerUsage = src.match(//); + expect(composerUsage).not.toBeNull(); + expect(composerUsage?.[0]).toContain("projectCwd={activeProjectCwd}"); + expect(composerUsage?.[0]).toContain("environmentId={activeThreadEnvironmentId ?? null}"); + }); + + it("Sidebar opens DirectoryBrowserDialog for the Add Project button", () => { + const src = readSource(SIDEBAR_PATH); + expect(src).toMatch( + /import\s*\{\s*DirectoryBrowserDialog\s*\}\s*from\s*"\.\/chat\/DirectoryBrowserDialog"/, + ); + expect(src).toContain(" { + const src = readSource(SIDEBAR_PATH); + expect(src).toContain("addProjectBaseDirectory"); + expect(src).toMatch(/useSettings\(\(s\)\s*=>\s*s\.addProjectBaseDirectory\)/); + expect(src).toMatch( + /addProjectBaseDirectory\.trim\(\)\.length\s*>\s*0\s*[\s\S]*?addProjectBaseDirectory\s*:\s*"~\/"/, + ); + }); + + it("DirectoryBrowserDialog initial path uses projectCwd + /../ for Add folder", () => { + const src = readSource(COMPOSER_POPOVER_PATH); + expect(src).toContain('`${projectCwd.replace(/\\/+$/, "")}/../`'); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d4eefc0e832..373c78db3c4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -390,6 +390,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory + ? ["Add Project base directory"] + : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), @@ -402,6 +405,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.addProjectBaseDirectory, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -1325,6 +1329,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ addProjectBaseDirectory: event.target.value })} + placeholder="~/" + spellCheck={false} + aria-label="Add Project base directory" + /> + } + /> +