From 0a6daf475545129aa01650fc7e7cf8800bb96545 Mon Sep 17 00:00:00 2001 From: Addis Date: Wed, 10 Jun 2026 23:56:44 -0500 Subject: [PATCH] Improve command palette project picker --- apps/web/src/components/Sidebar.browser.tsx | 156 +++++- apps/web/src/components/Sidebar.tsx | 166 +++--- .../SidebarSearchPalette.logic.test.ts | 72 +++ .../components/SidebarSearchPalette.logic.ts | 172 ++++++ .../src/components/SidebarSearchPalette.tsx | 510 ++++++++++++++++-- apps/web/src/components/ui/autocomplete.tsx | 11 + 6 files changed, 947 insertions(+), 140 deletions(-) create mode 100644 apps/web/src/components/SidebarSearchPalette.logic.test.ts diff --git a/apps/web/src/components/Sidebar.browser.tsx b/apps/web/src/components/Sidebar.browser.tsx index be6c5769ea7..d1689f19d22 100644 --- a/apps/web/src/components/Sidebar.browser.tsx +++ b/apps/web/src/components/Sidebar.browser.tsx @@ -8,6 +8,7 @@ import { type ClientOrchestrationCommand, type DesktopBridge, type OrchestrationReadModel, + type ProjectDirectoryEntry, type ProjectId, type ServerConfig, type ThreadId, @@ -51,6 +52,13 @@ interface TestFixture { let fixture: TestFixture; let nextSequence = 1; +let directoryEntriesByCwd: Record = {}; +let dispatchedCommands: ClientOrchestrationCommand[] = []; +let gitCloneRequests: Array<{ + repositoryUrl: string; + parentDirectory: string; + directoryName: string; +}> = []; const wsLink = ws.link(/ws(s)?:\/\/.*/); function createFixture(): TestFixture { @@ -117,7 +125,23 @@ function createFixture(): TestFixture { serverConfig: { cwd: "C:\\Users\\Addis\\source\\repos\\t3code-main", keybindingsConfigPath: "C:\\Users\\Addis\\.t3\\userdata\\keybindings.json", - keybindings: [], + keybindings: [ + { + command: "sidebar.search", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], issues: [], providers: [ { @@ -174,6 +198,7 @@ function createFixture(): TestFixture { availableEditors: [], serverPort: 0, serverAuthEnabled: false, + homeDirectory: "C:\\Users\\Addis", }, welcome: { cwd: "C:\\Users\\Addis\\source\\repos\\t3code-main", @@ -233,7 +258,8 @@ function makeThreadEntry( }; } -function resolveWsRpc(tag: string): unknown { +function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { + const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } @@ -274,6 +300,24 @@ function resolveWsRpc(tag: string): unknown { truncated: false, }; } + if (tag === WS_METHODS.projectsListDirectory) { + const cwd = typeof body.cwd === "string" ? body.cwd : ""; + return { + relativePath: null, + entries: directoryEntriesByCwd[cwd] ?? [], + }; + } + if (tag === WS_METHODS.gitClone) { + const repositoryUrl = typeof body.repositoryUrl === "string" ? body.repositoryUrl : ""; + const parentDirectory = typeof body.parentDirectory === "string" ? body.parentDirectory : ""; + const directoryName = typeof body.directoryName === "string" ? body.directoryName : ""; + gitCloneRequests.push({ repositoryUrl, parentDirectory, directoryName }); + return { + cwd: `${parentDirectory}\\${directoryName}`, + repositoryUrl, + directoryName, + }; + } return {}; } @@ -348,6 +392,7 @@ const worker = setupWorker( if (method === ORCHESTRATION_WS_METHODS.dispatchCommand) { const command = request.body.command as ClientOrchestrationCommand | undefined; if (command) { + dispatchedCommands.push(command); const result = applyDispatchCommand(command); client.send( JSON.stringify({ @@ -421,7 +466,7 @@ const worker = setupWorker( client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); @@ -468,6 +513,17 @@ function projectOrderLabels(): string[] { ].map((element) => element.getAttribute("aria-label") ?? ""); } +function dispatchCtrlK(): void { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); +} + function elementOpacityByTestId(testId: string): number | null { const element = document.querySelector(`[data-testid='${testId}']`); if (!element) { @@ -955,6 +1011,20 @@ afterAll(async () => { beforeEach(() => { fixture = createFixture(); nextSequence = fixture.snapshot.snapshotSequence + 1; + directoryEntriesByCwd = { + "C:\\Users\\Addis": [ + { path: "source", name: "source", kind: "directory" }, + { path: "Downloads", name: "Downloads", kind: "directory" }, + { path: "notes.txt", name: "notes.txt", kind: "file" }, + ], + "C:\\Users\\Addis\\source": [{ path: "repos", name: "repos", kind: "directory" }], + "C:\\Users\\Addis\\source\\repos": [ + { path: "Acode", name: "Acode", kind: "directory" }, + { path: "t3code-main", name: "t3code-main", kind: "directory" }, + ], + }; + dispatchedCommands = []; + gitCloneRequests = []; window.desktopBridge = { getWsUrl: () => `ws://${window.location.host}`, getWindowChromeMetrics: () => ({ @@ -965,7 +1035,7 @@ beforeEach(() => { captionButtonLaneWidthPx: 104, }), openExternal: async () => true, - pickFolder: async () => null, + pickFolder: vi.fn(async () => null), confirm: async () => true, } as unknown as DesktopBridge; window.localStorage.clear(); @@ -1062,6 +1132,84 @@ describe("Sidebar browser", () => { await mounted.cleanup(); }); + it("opens the command palette with Ctrl+K", async () => { + const mounted = await mountSidebarApp(); + + dispatchCtrlK(); + + await expect.element(page.getByTestId("sidebar-search-palette")).toBeVisible(); + await expect.element(page.getByText("Suggested", { exact: true })).toBeVisible(); + + await mounted.cleanup(); + }); + + it("opens folder mode from the command palette Open folder action", async () => { + const mounted = await mountSidebarApp(); + + dispatchCtrlK(); + await page.getByText("Open folder", { exact: true }).click(); + + await expect.element(page.getByText("Quick roots", { exact: true })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Use folder" })).toBeVisible(); + + await mounted.cleanup(); + }); + + it("opens the same folder picker from the sidebar Add project button", async () => { + const mounted = await mountSidebarApp(); + + await page.getByRole("button", { name: "Add project" }).click(); + + await expect.element(page.getByText("Open folder", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Quick roots", { exact: true })).toBeVisible(); + + await mounted.cleanup(); + }); + + it("adds a project from the in-app folder picker", async () => { + const mounted = await mountSidebarApp(); + + await page.getByRole("button", { name: "Add project" }).click(); + await page.getByRole("button", { name: "Repos" }).click(); + await page.getByText("Acode", { exact: true }).first().click(); + await page.getByRole("button", { name: "Use folder" }).click(); + + await expect + .poll(() => + dispatchedCommands.some( + (command) => + command.type === "project.create" && + command.workspaceRoot === "C:\\Users\\Addis\\source\\repos\\Acode", + ), + ) + .toBe(true); + + await mounted.cleanup(); + }); + + it("chooses clone destination in-app without opening the native folder picker", async () => { + const mounted = await mountSidebarApp(); + + dispatchCtrlK(); + await page.getByText("Clone git Repository", { exact: true }).click(); + await page.getByPlaceholder("Enter URL").fill("https://github.com/openai/codex"); + document + .querySelector('input[placeholder="Enter URL"]') + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await page.getByRole("button", { name: "Repos" }).click(); + await page.getByRole("button", { name: "Clone here" }).click(); + + await expect.poll(() => gitCloneRequests.length).toBe(1); + expect(gitCloneRequests[0]).toMatchObject({ + repositoryUrl: "https://github.com/openai/codex", + parentDirectory: "C:\\Users\\Addis\\source\\repos", + directoryName: "codex", + }); + expect(window.desktopBridge?.pickFolder).not.toHaveBeenCalled(); + + await mounted.cleanup(); + }); + it("uses a dedicated settings sidebar layout on the settings route", async () => { const mounted = await mountSidebarApp({ initialEntries: ["/settings"] }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b7f30645894..79114ddfa17 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,8 +121,9 @@ import type { ProviderKind } from "@t3tools/contracts"; import { useThreadHandoff } from "../hooks/useThreadHandoff"; import { ProjectSidebarIcon } from "./ProjectSidebarIcon"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; -import { SidebarSearchPalette } from "./SidebarSearchPalette"; +import { SidebarSearchPalette, type SidebarSearchPaletteMode } from "./SidebarSearchPalette"; import { + buildSidebarFolderRoots, type SidebarSearchAction, type SidebarSearchProject, type SidebarSearchThread, @@ -907,9 +908,8 @@ export default function Sidebar() { const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const [settingsPopoverOpen, setSettingsPopoverOpen] = useState(false); const [searchPaletteOpen, setSearchPaletteOpen] = useState(false); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); + const [searchPaletteMode, setSearchPaletteMode] = + useState("search"); const [isAddingProject, setIsAddingProject] = useState(false); const [projectsSectionExpanded, setProjectsSectionExpanded] = useState(true); const [chatsSectionExpanded, setChatsSectionExpanded] = useState(true); @@ -925,6 +925,18 @@ export default function Sidebar() { const [draggedProjectId, setDraggedProjectId] = useState(null); const [dropTargetProjectId, setDropTargetProjectId] = useState(null); const [dropTargetPosition, setDropTargetPosition] = useState<"before" | "after" | null>(null); + + const openSearchPalette = useCallback((mode: SidebarSearchPaletteMode = "search") => { + setSearchPaletteMode(mode); + setSearchPaletteOpen(true); + }, []); + + const handleSearchPaletteOpenChange = useCallback((open: boolean) => { + setSearchPaletteOpen(open); + if (!open) { + setSearchPaletteMode("search"); + } + }, []); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -1228,7 +1240,13 @@ export default function Sidebar() { useEffect(() => { return onToggleSidebarSearchPalette(() => { - setSearchPaletteOpen((existing) => !existing); + setSearchPaletteOpen((existing) => { + const nextOpen = !existing; + if (nextOpen) { + setSearchPaletteMode("search"); + } + return nextOpen; + }); }); }, []); @@ -1414,12 +1432,8 @@ export default function Sidebar() { } setIsAddingProject(true); - const finishAddingProject = (options?: { closeComposer?: boolean }) => { + const finishAddingProject = () => { setIsAddingProject(false); - if (options?.closeComposer ?? true) { - setNewCwd(""); - setAddingProject(false); - } }; const existing = projects.find((project) => project.cwd === cwd); @@ -1462,33 +1476,13 @@ export default function Sidebar() { "The project could not be created. Check that the app is connected and try again.", ), }); - finishAddingProject({ closeComposer: false }); + finishAddingProject(); return false; } }, [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], ); - const handleAddProject = () => { - void addProjectFromPath(newCwd); - }; - - const handlePickFolder = useCallback(async () => { - const api = readNativeApi(); - 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 addProjectFromPath(pickedPath); - } - setIsPickingFolder(false); - }, [addProjectFromPath, isPickingFolder]); - const getOrCreateHomeProjectId = useCallback(async (): Promise => { const workspaceRoot = chatWorkspaceRoot ?? homeDirectory; if (!workspaceRoot) { @@ -1535,7 +1529,7 @@ export default function Sidebar() { const fallbackProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? firstVisibleProjectId; if (!fallbackProjectId) { - setAddingProject(true); + openSearchPalette("folder"); return; } @@ -1553,6 +1547,7 @@ export default function Sidebar() { firstVisibleProjectId, getOrCreateHomeProjectId, handleNewThread, + openSearchPalette, ]); const handleOpenOrchestrate = useCallback(() => { @@ -2407,6 +2402,14 @@ export default function Sidebar() { })), [workspaceProjects], ); + const searchPaletteFolderRoots = useMemo( + () => + buildSidebarFolderRoots({ + homeDirectory, + projects: searchPaletteProjects, + }), + [homeDirectory, searchPaletteProjects], + ); const searchPaletteThreads = useMemo(() => { if (!searchPaletteOpen) { return []; @@ -3406,7 +3409,7 @@ export default function Sidebar() { aria-label="Search chats" data-testid="sidebar-search-chats" onClick={() => { - setSearchPaletteOpen(true); + openSearchPalette("search"); }} > @@ -3418,63 +3421,24 @@ export default function Sidebar() { - - - - - -
-

Add project

- setNewCwd(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") setAddingProject(false); + + { + openSearchPalette("folder"); }} - /> - {isElectron ? ( - - ) : null} -
- - -
-
-
-
+ > + + + } + /> + Add project + {shouldShowProjectGroups && workspaceProjects.length > 0 ? ( @@ -3852,25 +3816,23 @@ export default function Sidebar() { { - setAddingProject(true); + openSearchPalette("folder"); }} - onCloneRepository={async ({ repositoryUrl, directoryName }) => { + onCloneRepository={async ({ repositoryUrl, directoryName, parentDirectory }) => { const api = readNativeApi(); if (!api) { throw new Error("Native API is unavailable."); } - const parentDirectory = await api.dialogs.pickFolder(); - if (!parentDirectory) { - return null; - } - return api.git.clone({ repositoryUrl, parentDirectory, @@ -3878,6 +3840,16 @@ export default function Sidebar() { }); }} onAddProjectFromPath={addProjectFromPath} + onListDirectory={async (cwd) => { + const api = readNativeApi(); + if (!api) { + throw new Error("Native API is unavailable."); + } + return api.projects.listDirectory({ + cwd, + relativePath: null, + }); + }} onOpenSettings={() => { void navigate({ to: "/settings", diff --git a/apps/web/src/components/SidebarSearchPalette.logic.test.ts b/apps/web/src/components/SidebarSearchPalette.logic.test.ts new file mode 100644 index 00000000000..38cc1e469fd --- /dev/null +++ b/apps/web/src/components/SidebarSearchPalette.logic.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + buildSidebarFolderRoots, + filterSidebarFolderEntries, + isLikelyAbsoluteFolderPath, + joinClientPath, + parentClientPath, + type SidebarSearchProject, +} from "./SidebarSearchPalette.logic"; + +describe("SidebarSearchPalette folder picker logic", () => { + it("builds stable folder roots from home and projects", () => { + const projects: SidebarSearchProject[] = [ + { + id: "project-1", + name: "Acode", + cwd: "C:\\Users\\Addis\\source\\repos\\Acode", + }, + { + id: "project-2", + name: "Home duplicate", + cwd: "C:\\Users\\Addis", + }, + ]; + + expect( + buildSidebarFolderRoots({ + homeDirectory: "C:\\Users\\Addis", + projects, + }), + ).toEqual([ + { id: "home", label: "Home", path: "C:\\Users\\Addis" }, + { id: "source-repos", label: "Repos", path: "C:\\Users\\Addis\\source\\repos" }, + { id: "project:project-1", label: "Acode", path: "C:\\Users\\Addis\\source\\repos\\Acode" }, + { id: "drive-root", label: "Drive", path: "C:\\" }, + ]); + }); + + it("filters visible entries to matching directories", () => { + expect( + filterSidebarFolderEntries( + [ + { path: "Acode", name: "Acode", kind: "directory" }, + { path: "react", name: "react", kind: "directory" }, + { path: "README.md", name: "README.md", kind: "file" }, + ], + "aco", + ), + ).toEqual([{ path: "Acode", name: "Acode", kind: "directory" }]); + }); + + it("does not treat pasted absolute paths as folder filters", () => { + expect( + filterSidebarFolderEntries( + [ + { path: "Acode", name: "Acode", kind: "directory" }, + { path: "react", name: "react", kind: "directory" }, + ], + "C:\\Users\\Addis\\source\\repos", + ), + ).toHaveLength(2); + expect(isLikelyAbsoluteFolderPath("/Users/Addis/source/repos")).toBe(true); + expect(isLikelyAbsoluteFolderPath("repos")).toBe(false); + }); + + it("joins and climbs client paths across Windows and POSIX inputs", () => { + expect(joinClientPath("C:\\Users\\Addis", "source")).toBe("C:\\Users\\Addis\\source"); + expect(parentClientPath("C:\\Users\\Addis\\source")).toBe("C:\\Users\\Addis"); + expect(joinClientPath("/Users/Addis", "source")).toBe("/Users/Addis/source"); + expect(parentClientPath("/Users/Addis/source")).toBe("/Users/Addis"); + }); +}); diff --git a/apps/web/src/components/SidebarSearchPalette.logic.ts b/apps/web/src/components/SidebarSearchPalette.logic.ts index 24b3f89d61f..15cf5c18453 100644 --- a/apps/web/src/components/SidebarSearchPalette.logic.ts +++ b/apps/web/src/components/SidebarSearchPalette.logic.ts @@ -20,6 +20,18 @@ export interface SidebarSearchProjectMatch { project: SidebarSearchProject; } +export interface SidebarFolderRoot { + id: string; + label: string; + path: string; +} + +export interface SidebarFolderEntry { + path: string; + name: string; + kind: "file" | "directory"; +} + export interface SidebarSearchThread { id: string; title: string; @@ -45,6 +57,10 @@ function normalizeText(value: string): string { return value.trim().replaceAll(/\s+/g, " ").toLowerCase(); } +function normalizePathKey(value: string): string { + return value.trim().replaceAll("/", "\\").replaceAll(/\\+/g, "\\").toLowerCase(); +} + function normalizeDisplayText(value: string): string { return value.trim().replaceAll(/\s+/g, " "); } @@ -188,6 +204,162 @@ function scoreProject(project: SidebarSearchProject, query: string): number | nu return null; } +export function isLikelyAbsoluteFolderPath(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed.startsWith("/") || + trimmed.startsWith("\\\\") || + /^[a-zA-Z]:[\\/]/.test(trimmed) + ); +} + +export function joinClientPath(parentPath: string, childName: string): string { + const trimmedChild = childName.replaceAll(/^[\\/]+|[\\/]+$/g, ""); + const usesWindowsSeparators = parentPath.includes("\\") || /^[a-zA-Z]:/.test(parentPath); + const separator = usesWindowsSeparators ? "\\" : "/"; + const windowsDriveRootMatch = parentPath.match(/^([a-zA-Z]:)[\\/]?$/); + if (windowsDriveRootMatch) { + return `${windowsDriveRootMatch[1]}\\${trimmedChild}`; + } + if (parentPath === "/" || parentPath === "\\") { + return `${separator}${trimmedChild}`; + } + return `${parentPath.replaceAll(/[\\/]+$/g, "")}${separator}${trimmedChild}`; +} + +export function parentClientPath(inputPath: string): string | null { + const trimmed = inputPath.trim().replaceAll(/[\\/]+$/g, ""); + if (!trimmed) return null; + if (/^[a-zA-Z]:$/.test(trimmed)) return null; + + const separatorIndex = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/")); + if (separatorIndex < 0) return null; + if (separatorIndex === 0) return "/"; + if (separatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}\\`; + } + return trimmed.slice(0, separatorIndex); +} + +function driveRootOf(inputPath: string): string | null { + const driveMatch = inputPath.trim().match(/^([a-zA-Z]:)[\\/]/); + if (driveMatch) { + return `${driveMatch[1]}\\`; + } + if (inputPath.trim().startsWith("/")) { + return "/"; + } + return null; +} + +function pushUniqueFolderRoot( + roots: SidebarFolderRoot[], + seenPaths: Set, + root: SidebarFolderRoot | null, +): void { + if (!root || root.path.trim().length === 0) return; + const key = normalizePathKey(root.path); + if (seenPaths.has(key)) return; + seenPaths.add(key); + roots.push(root); +} + +function sidebarRootLabelExists(roots: readonly SidebarFolderRoot[], label: string): boolean { + const normalizedLabel = normalizeText(label); + return roots.some((root) => normalizeText(root.label) === normalizedLabel); +} + +function uniqueProjectRootLabel( + roots: readonly SidebarFolderRoot[], + label: string, +): string { + if (!sidebarRootLabelExists(roots, label)) { + return label; + } + + const prefixedLabel = `Project: ${label}`; + if (!sidebarRootLabelExists(roots, prefixedLabel)) { + return prefixedLabel; + } + + let index = 2; + let candidate = `${prefixedLabel} ${index}`; + while (sidebarRootLabelExists(roots, candidate)) { + index += 1; + candidate = `${prefixedLabel} ${index}`; + } + return candidate; +} + +export function buildSidebarFolderRoots(input: { + homeDirectory: string | null; + projects: readonly SidebarSearchProject[]; +}): SidebarFolderRoot[] { + const roots: SidebarFolderRoot[] = []; + const seenPaths = new Set(); + const homeDirectory = input.homeDirectory?.trim() || null; + + pushUniqueFolderRoot( + roots, + seenPaths, + homeDirectory ? { id: "home", label: "Home", path: homeDirectory } : null, + ); + pushUniqueFolderRoot( + roots, + seenPaths, + homeDirectory + ? { + id: "source-repos", + label: "Repos", + path: joinClientPath(joinClientPath(homeDirectory, "source"), "repos"), + } + : null, + ); + + for (const project of input.projects) { + const projectLabel = project.name || basenameOfPath(project.cwd) || "Project"; + pushUniqueFolderRoot(roots, seenPaths, { + id: `project:${project.id}`, + label: uniqueProjectRootLabel(roots, projectLabel), + path: project.cwd, + }); + } + + pushUniqueFolderRoot( + roots, + seenPaths, + homeDirectory ? { id: "drive-root", label: "Drive", path: driveRootOf(homeDirectory) ?? "" } : null, + ); + + return roots; +} + +export function filterSidebarFolderEntries( + entries: readonly SidebarFolderEntry[], + query: string, + limit = 80, +): SidebarFolderEntry[] { + const normalizedQuery = normalizeText(query); + const shouldFilter = normalizedQuery.length > 0 && !isLikelyAbsoluteFolderPath(query); + + return entries + .filter((entry) => entry.kind === "directory") + .filter((entry) => { + if (!shouldFilter) return true; + return ( + normalizeText(entry.name).includes(normalizedQuery) || + normalizeText(entry.path).includes(normalizedQuery) + ); + }) + .toSorted((left, right) => + left.name.localeCompare(right.name, undefined, { + numeric: true, + sensitivity: "base", + }), + ) + .slice(0, limit); +} + export function matchSidebarSearchActions( actions: readonly SidebarSearchAction[], query: string, diff --git a/apps/web/src/components/SidebarSearchPalette.tsx b/apps/web/src/components/SidebarSearchPalette.tsx index 684bc588077..dab5535c78d 100644 --- a/apps/web/src/components/SidebarSearchPalette.tsx +++ b/apps/web/src/components/SidebarSearchPalette.tsx @@ -1,22 +1,36 @@ import { + ArrowLeftIcon, + ArrowUpIcon, CheckCircle2Icon, + CheckIcon, + ChevronRightIcon, + FolderIcon, GitForkIcon, + HardDriveIcon, + HomeIcon, LoaderCircleIcon, SearchIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; +import type { ProjectListDirectoryResult } from "@t3tools/contracts"; import { type ComponentType, useEffect, useMemo, useRef, useState } from "react"; import { TbFolderPlus } from "react-icons/tb"; import { ClaudeAI, OpenAI, OpenCodeIcon } from "./Icons"; import { + type SidebarFolderEntry, + type SidebarFolderRoot, type SidebarSearchAction, type SidebarSearchProject, type SidebarSearchThread, + filterSidebarFolderEntries, hasSidebarSearchResults, + isLikelyAbsoluteFolderPath, + joinClientPath, matchSidebarSearchActions, matchSidebarSearchProjects, matchSidebarSearchThreads, + parentClientPath, } from "./SidebarSearchPalette.logic"; import { deriveRepositoryDirectoryName } from "../lib/gitClone"; import { @@ -34,19 +48,27 @@ import { CommandSeparator, } from "./ui/command"; +export type SidebarSearchPaletteMode = "search" | "folder"; +type FolderPickerPurpose = "add-project" | "clone-parent"; + interface SidebarSearchPaletteProps { open: boolean; onOpenChange: (open: boolean) => void; + mode: SidebarSearchPaletteMode; + onModeChange: (mode: SidebarSearchPaletteMode) => void; actions: readonly SidebarSearchAction[]; projects: readonly SidebarSearchProject[]; threads: readonly SidebarSearchThread[]; + folderRoots: readonly SidebarFolderRoot[]; onCreateThread: () => void; onAddProject: () => void; onCloneRepository: (input: { repositoryUrl: string; directoryName: string; + parentDirectory: string; }) => Promise<{ cwd: string; directoryName: string } | null>; onAddProjectFromPath: (cwd: string) => Promise; + onListDirectory: (cwd: string) => Promise; onOpenSettings: () => void; onOpenProject: (projectId: string) => void; onOpenThread: (threadId: string) => void; @@ -188,7 +210,56 @@ function ShortcutBadge(props: { label: string }) { ); } +function folderRootIcon(root: SidebarFolderRoot): IconComponent { + if (root.id === "home") return HomeIcon; + if (root.id === "drive-root") return HardDriveIcon; + return FolderIcon; +} + +function basenameOfClientPath(value: string): string { + const trimmed = value.trim().replaceAll(/[\\/]+$/g, ""); + if (!trimmed) return value; + const separatorIndex = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/")); + if (separatorIndex < 0) return trimmed; + return trimmed.slice(separatorIndex + 1) || trimmed; +} + +function folderBreadcrumbs(value: string): Array<{ label: string; path: string }> { + const trimmed = value.trim(); + if (!trimmed) return []; + + const windowsDriveMatch = trimmed.match(/^([a-zA-Z]:)[\\/]*(.*)$/); + if (windowsDriveMatch) { + const drive = windowsDriveMatch[1] ?? ""; + if (!drive) return []; + const rest = windowsDriveMatch[2] ?? ""; + const segments = rest.split(/[\\/]+/).filter((segment) => segment.length > 0); + let currentPath = `${drive}\\`; + return [ + { label: drive, path: currentPath }, + ...segments.map((segment) => { + currentPath = joinClientPath(currentPath, segment); + return { label: segment, path: currentPath }; + }), + ]; + } + + const isRooted = trimmed.startsWith("/"); + const segments = trimmed.split(/[\\/]+/).filter((segment) => segment.length > 0); + let currentPath = isRooted ? "/" : ""; + const breadcrumbs = isRooted ? [{ label: "Root", path: "/" }] : []; + for (const segment of segments) { + currentPath = currentPath ? joinClientPath(currentPath, segment) : segment; + breadcrumbs.push({ label: segment, path: currentPath }); + } + return breadcrumbs; +} + export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { + const open = props.open; + const mode = props.mode; + const onListDirectory = props.onListDirectory; + const onModeChange = props.onModeChange; const [query, setQuery] = useState(""); const [cloneMode, setCloneMode] = useState<"idle" | "editing" | "submitting" | "success">( "idle", @@ -198,17 +269,36 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { const [clonedProject, setClonedProject] = useState<{ cwd: string; directoryName: string } | null>( null, ); + const [pendingClone, setPendingClone] = useState<{ + repositoryUrl: string; + directoryName: string; + } | null>(null); + const [folderPurpose, setFolderPurpose] = useState("add-project"); + const [currentFolder, setCurrentFolder] = useState(null); + const [folderEntries, setFolderEntries] = useState([]); + const [folderLoading, setFolderLoading] = useState(false); + const [folderSubmitting, setFolderSubmitting] = useState(false); + const [folderError, setFolderError] = useState(null); + const folderEntriesCacheRef = useRef(new Map()); const cloneInputRef = useRef(null); useEffect(() => { - if (!props.open) { + if (!open) { setQuery(""); setCloneMode("idle"); setCloneRepositoryUrl(""); setCloneError(null); setClonedProject(null); + setPendingClone(null); + setFolderPurpose("add-project"); + setFolderEntries([]); + setFolderLoading(false); + setFolderSubmitting(false); + setFolderError(null); + folderEntriesCacheRef.current.clear(); + onModeChange("search"); } - }, [props.open]); + }, [onModeChange, open]); useEffect(() => { if (cloneMode === "editing") { @@ -229,6 +319,18 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { () => matchSidebarSearchThreads(props.threads, query), [props.threads, query], ); + const defaultFolderPath = props.folderRoots[0]?.path ?? null; + const visibleFolderEntries = useMemo( + () => filterSidebarFolderEntries(folderEntries, query), + [folderEntries, query], + ); + const trimmedFolderQuery = query.trim(); + const hasTypedAbsoluteFolderPath = isLikelyAbsoluteFolderPath(trimmedFolderQuery); + const shouldShowFolderQuickRoots = props.folderRoots.length > 0 && trimmedFolderQuery.length === 0; + const folderEntryScrollClassName = shouldShowFolderQuickRoots ? "max-h-44" : "max-h-80"; + const folderParentPath = currentFolder ? parentClientPath(currentFolder) : null; + const currentFolderLabel = currentFolder ? basenameOfClientPath(currentFolder) : "Folder"; + const currentFolderBreadcrumbs = currentFolder ? folderBreadcrumbs(currentFolder) : []; const hasResults = hasSidebarSearchResults({ actions: matchedActions, projects: matchedProjects, @@ -242,6 +344,29 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { setCloneRepositoryUrl(""); setCloneError(null); setClonedProject(null); + setPendingClone(null); + }; + + const closeFolderPicker = () => { + onModeChange("search"); + setQuery(""); + setFolderError(null); + setFolderSubmitting(false); + if (folderPurpose === "clone-parent" && pendingClone) { + setCloneMode("editing"); + } + setFolderPurpose("add-project"); + }; + + const openFolderPicker = (purpose: FolderPickerPurpose) => { + setFolderPurpose(purpose); + setQuery(""); + setFolderError(null); + setFolderSubmitting(false); + if (!currentFolder && defaultFolderPath) { + setCurrentFolder(defaultFolderPath); + } + onModeChange("folder"); }; const openInlineClone = () => { @@ -265,23 +390,9 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { return; } - setCloneMode("submitting"); setCloneError(null); - try { - const cloned = await props.onCloneRepository({ - repositoryUrl, - directoryName, - }); - if (!cloned) { - setCloneMode("editing"); - return; - } - setClonedProject(cloned); - setCloneMode("success"); - } catch (error) { - setCloneError(error instanceof Error ? error.message : "Failed to clone repository."); - setCloneMode("editing"); - } + setPendingClone({ repositoryUrl, directoryName }); + openFolderPicker("clone-parent"); }; const confirmAddClonedProject = async () => { @@ -299,6 +410,125 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { setCloneMode("success"); }; + const navigateToFolder = (path: string) => { + setCurrentFolder(path); + setQuery(""); + setFolderError(null); + }; + + const handleFolderPrimaryAction = () => { + if (hasTypedAbsoluteFolderPath) { + navigateToFolder(trimmedFolderQuery); + return; + } + void confirmCurrentFolder(); + }; + + const confirmCurrentFolder = async () => { + if (!currentFolder || folderSubmitting) { + return; + } + + setFolderSubmitting(true); + setFolderError(null); + try { + if (folderPurpose === "clone-parent") { + if (!pendingClone) { + setFolderError("Enter a Git repository URL first."); + return; + } + setCloneMode("submitting"); + const cloned = await props.onCloneRepository({ + ...pendingClone, + parentDirectory: currentFolder, + }); + if (!cloned) { + setCloneMode("editing"); + return; + } + setClonedProject(cloned); + setPendingClone(null); + setCloneMode("success"); + onModeChange("search"); + setQuery(""); + return; + } + + const added = await props.onAddProjectFromPath(currentFolder); + if (added) { + props.onOpenChange(false); + } + } catch (error) { + setFolderError(error instanceof Error ? error.message : "Unable to use this folder."); + if (folderPurpose === "clone-parent") { + setCloneMode("editing"); + } + } finally { + setFolderSubmitting(false); + } + }; + + const handleFolderInputEnter = () => { + if (trimmedFolderQuery.length === 0) { + void confirmCurrentFolder(); + return; + } + if (hasTypedAbsoluteFolderPath) { + navigateToFolder(trimmedFolderQuery); + return; + } + if (visibleFolderEntries.length === 1 && currentFolder) { + navigateToFolder(joinClientPath(currentFolder, visibleFolderEntries[0]?.name ?? "")); + } + }; + + useEffect(() => { + if (!open || mode !== "folder" || currentFolder || !defaultFolderPath) { + return; + } + setCurrentFolder(defaultFolderPath); + }, [currentFolder, defaultFolderPath, mode, open]); + + useEffect(() => { + if (!open || mode !== "folder" || !currentFolder) { + return; + } + + let disposed = false; + const cachedEntries = folderEntriesCacheRef.current.get(currentFolder); + if (cachedEntries) { + setFolderEntries(cachedEntries); + setFolderLoading(false); + setFolderError(null); + return; + } + + setFolderLoading(true); + setFolderError(null); + + void onListDirectory(currentFolder) + .then((result) => { + if (disposed) return; + const entries = [...result.entries]; + folderEntriesCacheRef.current.set(currentFolder, entries); + setFolderEntries(entries); + }) + .catch((error) => { + if (disposed) return; + setFolderEntries([]); + setFolderError(error instanceof Error ? error.message : "Unable to open this folder."); + }) + .finally(() => { + if (!disposed) { + setFolderLoading(false); + } + }); + + return () => { + disposed = true; + }; + }, [currentFolder, mode, onListDirectory, open]); + const renderCloneInlineRow = () => (
@@ -389,26 +619,214 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { {cloneError ? cloneError : cloneMode === "submitting" - ? "Choose a parent folder to continue cloning." + ? "Cloning repository..." : "Paste repository URL and press Enter."}
)}
); + const renderFolderPicker = () => ( + <> + + + {folderPurpose === "clone-parent" ? "Clone into" : "Open folder"} + +
+
+
+ {folderParentPath ? ( + + ) : null} +
+ {currentFolderBreadcrumbs.map((breadcrumb, index) => ( + + {index > 0 ? : null} + + + ))} + {currentFolderBreadcrumbs.length === 0 ? ( + {currentFolder ?? "Choose a starting folder"} + ) : null} +
+ +
+
+
+
+ + {shouldShowFolderQuickRoots ? ( + + + Quick roots + +
+ {props.folderRoots.map((root) => { + const Icon = folderRootIcon(root); + return ( + + ); + })} +
+
+ ) : null} + + + + + + {currentFolderLabel} + + {folderLoading ? ( +
+ + Loading folders... +
+ ) : folderError ? ( +
{folderError}
+ ) : visibleFolderEntries.length > 0 && currentFolder ? ( +
+ {visibleFolderEntries.map((entry) => { + const entryPathAddsContext = + entry.path.trim().length > 0 && + entry.path.trim().toLowerCase() !== entry.name.trim().toLowerCase(); + return ( + { + event.preventDefault(); + }} + onClick={() => navigateToFolder(joinClientPath(currentFolder, entry.name))} + > + +
+
{entry.name}
+ {entryPathAddsContext ? ( +
+ {entry.path} +
+ ) : null} +
+
+ ); + })} +
+ ) : ( +
+ {query.trim() ? "No matching folders" : "No folders here"} +
+ )} +
+ + ); + return ( - + { + if (props.mode !== "folder" || event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + closeFolderPicker(); + }} + > setQuery(event.currentTarget.value)} - startAddon={} + onKeyDown={(event) => { + if (props.mode !== "folder") return; + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + handleFolderInputEnter(); + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeFolderPicker(); + } + }} + startAddon={ + props.mode === "folder" ? ( + + ) : ( + + ) + } + endAddon={ + props.mode === "folder" ? ( + + ) : null + } /> - - {hasSuggestedActions ? ( + + {props.mode === "folder" ? renderFolderPicker() : null} + {props.mode === "search" && hasSuggestedActions ? ( Suggested @@ -445,14 +863,18 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { key={action.id} value={`action:${action.id}`} className="cursor-pointer items-center gap-2 rounded-md px-2 py-0.5" - onMouseDown={(event) => { - event.preventDefault(); - }} - onClick={() => { - props.onOpenChange(false); - onSelect(); - }} - > + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={() => { + if (action.id === "add-project") { + onSelect(); + return; + } + props.onOpenChange(false); + onSelect(); + }} + > {Icon ? : null} {action.label} @@ -468,12 +890,13 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : null} - {hasSuggestedActions && + {props.mode === "search" && + hasSuggestedActions && (matchedThreads.length > 0 || matchedProjects.length > 0) ? ( ) : null} - {matchedThreads.length > 0 ? ( + {props.mode === "search" && matchedThreads.length > 0 ? ( {query ? "Chat" : "Recent"} @@ -534,11 +957,11 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : null} - {matchedThreads.length > 0 && matchedProjects.length > 0 ? ( + {props.mode === "search" && matchedThreads.length > 0 && matchedProjects.length > 0 ? ( ) : null} - {matchedProjects.length > 0 ? ( + {props.mode === "search" && matchedProjects.length > 0 ? ( Projects @@ -570,7 +993,7 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : null} - {!hasVisibleResults ? ( + {props.mode === "search" && !hasVisibleResults ? (
@@ -581,8 +1004,17 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { - Jump to threads, projects, and actions - {cloneMode === "idle" ? "Enter to open" : "Enter to continue"} + {props.mode === "folder" ? ( + <> + {folderPurpose === "clone-parent" ? "Choose clone destination" : "Choose project folder"} + {hasTypedAbsoluteFolderPath ? "Enter to go" : "Enter to use"} + + ) : ( + <> + Jump to threads, projects, and actions + {cloneMode === "idle" ? "Enter to open" : "Enter to continue"} + + )} diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 7bb0452baca..bb1071522a0 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -14,12 +14,14 @@ function AutocompleteInput({ showTrigger = false, showClear = false, startAddon, + endAddon, size, ...props }: Omit & { showTrigger?: boolean; showClear?: boolean; startAddon?: React.ReactNode; + endAddon?: React.ReactNode; size?: "sm" | "default" | "lg" | number; ref?: React.Ref; }) { @@ -40,6 +42,7 @@ function AutocompleteInput({ className={cn( startAddon && "data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=autocomplete-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(8)-1px)]", + endAddon && "*:data-[slot=autocomplete-input]:pe-28 sm:*:data-[slot=autocomplete-input]:pe-24", sizeValue === "sm" ? "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-6.5" : "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-7", @@ -49,6 +52,14 @@ function AutocompleteInput({ render={} {...props} /> + {endAddon && ( +
+ {endAddon} +
+ )} {showTrigger && (