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
64 changes: 57 additions & 7 deletions src/browser/components/DirectoryPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
Expand All @@ -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;
Expand All @@ -29,6 +33,9 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
const [root, setRoot] = useState<FileTreeNode | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pathInput, setPathInput] = useState(initialPath || "");
const [selectedIndex, setSelectedIndex] = useState(0);
const treeRef = useRef<HTMLDivElement>(null);

const loadDirectory = useCallback(
async (path: string) => {
Expand All @@ -50,6 +57,8 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
}

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}`);
Expand Down Expand Up @@ -96,36 +105,77 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
[isLoading, onClose]
);

const handlePathInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Select Project Directory</DialogTitle>
<DialogDescription>
{root ? root.path : "Select a directory to use as your project root"}
</DialogDescription>
<DialogDescription>Navigate to select a directory for your project</DialogDescription>
</DialogHeader>
<div className="mb-3">
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
placeholder="Enter path..."
className="bg-modal-bg border-border-medium h-9 font-mono text-sm"
/>
</div>
{error && <div className="text-error mb-3 text-xs">{error}</div>}
<div className="bg-modal-bg border-border-medium mb-4 h-80 overflow-hidden rounded border">
<div
ref={treeRef}
className="bg-modal-bg border-border-medium mb-4 h-80 overflow-hidden rounded border"
>
<DirectoryTree
currentPath={root ? root.path : null}
entries={entries}
isLoading={isLoading}
onNavigateTo={handleNavigateTo}
onNavigateParent={handleNavigateParent}
onConfirm={handleConfirm}
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={() => void handleConfirm()} disabled={isLoading || !root}>
Select
<Button
onClick={() => void handleConfirm()}
disabled={isLoading || !root}
title={`Open folder (${shortcutLabel})`}
>
Open ({shortcutLabel})
</Button>
</DialogFooter>
</DialogContent>
Expand Down
160 changes: 144 additions & 16 deletions src/browser/components/DirectoryTree.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Folder, FolderUp } from "lucide-react";

interface DirectoryTreeEntry {
Expand All @@ -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<DirectoryTreeProps> = (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<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedItemRef = useRef<HTMLLIElement | null>(null);
const [typeAheadBuffer, setTypeAheadBuffer] = useState("");
const typeAheadTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div ref={containerRef} className="h-full overflow-y-auto p-2 text-sm">
<div
ref={containerRef}
className="h-full overflow-y-auto p-2 text-sm outline-none"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{isLoading && !currentPath ? (
<div className="text-muted py-4 text-center">Loading directories...</div>
) : (
<ul className="m-0 list-none p-0">
{currentPath && (
<li
className="text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
ref={isSelected(0) ? selectedItemRef : null}
className={`text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
isSelected(0) ? "bg-white/10" : "hover:bg-white/5"
}`}
onClick={onNavigateParent}
>
<FolderUp size={16} className="text-muted shrink-0" />
Expand All @@ -46,16 +168,22 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = (props) => {
<li className="text-muted px-2 py-1.5">No subdirectories found</li>
) : null}

{entries.map((entry) => (
<li
key={entry.path}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
onClick={() => onNavigateTo(entry.path)}
>
<Folder size={16} className="shrink-0 text-yellow-500/80" />
<span className="truncate">{entry.name}</span>
</li>
))}
{entries.map((entry, idx) => {
const actualIndex = currentPath ? idx + 1 : idx;
return (
<li
key={entry.path}
ref={isSelected(actualIndex) ? selectedItemRef : null}
className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
isSelected(actualIndex) ? "bg-white/10" : "hover:bg-white/5"
}`}
onClick={() => onNavigateTo(entry.path)}
>
<Folder size={16} className="shrink-0 text-yellow-500/80" />
<span className="truncate">{entry.name}</span>
</li>
);
})}

{isLoading && currentPath && !hasEntries ? (
<li className="text-muted px-2 py-1.5">Loading directories...</li>
Expand Down