diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index 3f4c5e00eb..f830bac778 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Dialog, DialogContent, @@ -8,9 +8,13 @@ import { DialogFooter, } from "@/browser/components/ui/dialog"; import { Button } from "@/browser/components/ui/button"; +import { Input } from "@/browser/components/ui/input"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { DirectoryTree } from "./DirectoryTree"; import { useAPI } from "@/browser/contexts/API"; +import { formatKeybind, isMac } from "@/browser/utils/ui/keybinds"; + +const OPEN_KEYBIND = { key: "o", ctrl: true }; interface DirectoryPickerModalProps { isOpen: boolean; @@ -29,6 +33,9 @@ export const DirectoryPickerModal: React.FC = ({ const [root, setRoot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [pathInput, setPathInput] = useState(initialPath || ""); + const [selectedIndex, setSelectedIndex] = useState(0); + const treeRef = useRef(null); const loadDirectory = useCallback( async (path: string) => { @@ -50,6 +57,8 @@ export const DirectoryPickerModal: React.FC = ({ } setRoot(result.data); + setPathInput(result.data.path); + setSelectedIndex(0); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(`Failed to load directory: ${message}`); @@ -96,36 +105,77 @@ export const DirectoryPickerModal: React.FC = ({ [isLoading, onClose] ); + const handlePathInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void loadDirectory(pathInput); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + // Focus the tree and start navigation + const treeContainer = treeRef.current?.querySelector("[tabindex]"); + if (treeContainer instanceof HTMLElement) { + treeContainer.focus(); + } + } else if ((e.ctrlKey || e.metaKey) && e.key === "o") { + e.preventDefault(); + if (!isLoading && root) { + handleConfirm(); + } + } + }, + [pathInput, loadDirectory, handleConfirm, isLoading, root] + ); + const entries = root?.children .filter((child) => child.isDirectory) .map((child) => ({ name: child.name, path: child.path })) ?? []; + const shortcutLabel = isMac() ? "⌘O" : formatKeybind(OPEN_KEYBIND); + return ( Select Project Directory - - {root ? root.path : "Select a directory to use as your project root"} - + Navigate to select a directory for your project +
+ setPathInput(e.target.value)} + onKeyDown={handlePathInputKeyDown} + placeholder="Enter path..." + className="bg-modal-bg border-border-medium h-9 font-mono text-sm" + /> +
{error &&
{error}
} -
+
- diff --git a/src/browser/components/DirectoryTree.tsx b/src/browser/components/DirectoryTree.tsx index 5f18cffa49..34c970e3a6 100644 --- a/src/browser/components/DirectoryTree.tsx +++ b/src/browser/components/DirectoryTree.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Folder, FolderUp } from "lucide-react"; interface DirectoryTreeEntry { @@ -12,29 +12,151 @@ interface DirectoryTreeProps { isLoading?: boolean; onNavigateTo: (path: string) => void; onNavigateParent: () => void; + onConfirm: () => void; + selectedIndex: number; + onSelectedIndexChange: (index: number) => void; } export const DirectoryTree: React.FC = (props) => { - const { currentPath, entries, isLoading = false, onNavigateTo, onNavigateParent } = props; + const { + currentPath, + entries, + isLoading = false, + onNavigateTo, + onNavigateParent, + onConfirm, + selectedIndex, + onSelectedIndexChange, + } = props; const hasEntries = entries.length > 0; - const containerRef = React.useRef(null); + const containerRef = useRef(null); + const selectedItemRef = useRef(null); + const [typeAheadBuffer, setTypeAheadBuffer] = useState(""); + const typeAheadTimeoutRef = useRef | null>(null); - React.useEffect(() => { + // Total navigable items: parent (..) + entries + const totalItems = (currentPath ? 1 : 0) + entries.length; + + // Scroll container to top when path changes + useEffect(() => { if (containerRef.current) { containerRef.current.scrollTop = 0; } }, [currentPath]); + // Scroll selected item into view + useEffect(() => { + if (selectedItemRef.current) { + selectedItemRef.current.scrollIntoView({ block: "nearest" }); + } + }, [selectedIndex]); + + // Clear type-ahead buffer after 500ms of inactivity + const resetTypeAhead = useCallback(() => { + if (typeAheadTimeoutRef.current) { + clearTimeout(typeAheadTimeoutRef.current); + } + typeAheadTimeoutRef.current = setTimeout(() => { + setTypeAheadBuffer(""); + }, 500); + }, []); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Type-ahead search for printable characters + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + const newBuffer = typeAheadBuffer + e.key.toLowerCase(); + setTypeAheadBuffer(newBuffer); + resetTypeAhead(); + + // Find first entry matching the buffer + const matchIndex = entries.findIndex((entry) => + entry.name.toLowerCase().startsWith(newBuffer) + ); + if (matchIndex !== -1) { + // Offset by 1 if parent exists (index 0 is parent) + const actualIndex = currentPath ? matchIndex + 1 : matchIndex; + onSelectedIndexChange(actualIndex); + } + e.preventDefault(); + return; + } + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (totalItems > 0) { + onSelectedIndexChange(selectedIndex <= 0 ? totalItems - 1 : selectedIndex - 1); + } + break; + case "ArrowDown": + e.preventDefault(); + if (totalItems > 0) { + onSelectedIndexChange(selectedIndex >= totalItems - 1 ? 0 : selectedIndex + 1); + } + break; + case "Enter": + e.preventDefault(); + if (selectedIndex === 0 && currentPath) { + // Parent directory selected + onNavigateParent(); + } else if (entries.length > 0) { + // Navigate into selected directory + const entryIndex = currentPath ? selectedIndex - 1 : selectedIndex; + if (entryIndex >= 0 && entryIndex < entries.length) { + onNavigateTo(entries[entryIndex].path); + } + } + break; + case "Backspace": + e.preventDefault(); + if (currentPath) { + onNavigateParent(); + } + break; + case "o": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + onConfirm(); + } + break; + } + }, + [ + selectedIndex, + totalItems, + currentPath, + entries, + onSelectedIndexChange, + onNavigateTo, + onNavigateParent, + onConfirm, + typeAheadBuffer, + resetTypeAhead, + ] + ); + + const isSelected = (index: number) => selectedIndex === index; + return ( -
+
{isLoading && !currentPath ? (
Loading directories...
) : (
    {currentPath && (
  • @@ -46,16 +168,22 @@ export const DirectoryTree: React.FC = (props) => {
  • No subdirectories found
  • ) : null} - {entries.map((entry) => ( -
  • onNavigateTo(entry.path)} - > - - {entry.name} -
  • - ))} + {entries.map((entry, idx) => { + const actualIndex = currentPath ? idx + 1 : idx; + return ( +
  • onNavigateTo(entry.path)} + > + + {entry.name} +
  • + ); + })} {isLoading && currentPath && !hasEntries ? (
  • Loading directories...