Skip to content

Commit cae2563

Browse files
authored
🤖 feat: improve directory picker with keyboard navigation and path input (#1039)
- Add editable path input field for direct navigation - Add type-ahead search (type to jump to matching folder) - Add keyboard navigation (Arrow keys, Enter to enter folder, Backspace for parent) - Add Ctrl/Cmd+O shortcut to open folder, shown in button tooltip - Add visual selection highlight for keyboard navigation _Generated with mux_
1 parent aaf26a8 commit cae2563

File tree

2 files changed

+201
-23
lines changed

2 files changed

+201
-23
lines changed

‎src/browser/components/DirectoryPickerModal.tsx‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useState } from "react";
1+
import React, { useCallback, useEffect, useRef, useState } from "react";
22
import {
33
Dialog,
44
DialogContent,
@@ -8,9 +8,13 @@ import {
88
DialogFooter,
99
} from "@/browser/components/ui/dialog";
1010
import { Button } from "@/browser/components/ui/button";
11+
import { Input } from "@/browser/components/ui/input";
1112
import type { FileTreeNode } from "@/common/utils/git/numstatParser";
1213
import { DirectoryTree } from "./DirectoryTree";
1314
import { useAPI } from "@/browser/contexts/API";
15+
import { formatKeybind, isMac } from "@/browser/utils/ui/keybinds";
16+
17+
const OPEN_KEYBIND = { key: "o", ctrl: true };
1418

1519
interface DirectoryPickerModalProps {
1620
isOpen: boolean;
@@ -29,6 +33,9 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
2933
const [root, setRoot] = useState<FileTreeNode | null>(null);
3034
const [isLoading, setIsLoading] = useState(false);
3135
const [error, setError] = useState<string | null>(null);
36+
const [pathInput, setPathInput] = useState(initialPath || "");
37+
const [selectedIndex, setSelectedIndex] = useState(0);
38+
const treeRef = useRef<HTMLDivElement>(null);
3239

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

5259
setRoot(result.data);
60+
setPathInput(result.data.path);
61+
setSelectedIndex(0);
5362
} catch (err) {
5463
const message = err instanceof Error ? err.message : String(err);
5564
setError(`Failed to load directory: ${message}`);
@@ -96,36 +105,77 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
96105
[isLoading, onClose]
97106
);
98107

108+
const handlePathInputKeyDown = useCallback(
109+
(e: React.KeyboardEvent<HTMLInputElement>) => {
110+
if (e.key === "Enter") {
111+
e.preventDefault();
112+
void loadDirectory(pathInput);
113+
} else if (e.key === "ArrowDown") {
114+
e.preventDefault();
115+
// Focus the tree and start navigation
116+
const treeContainer = treeRef.current?.querySelector("[tabindex]");
117+
if (treeContainer instanceof HTMLElement) {
118+
treeContainer.focus();
119+
}
120+
} else if ((e.ctrlKey || e.metaKey) && e.key === "o") {
121+
e.preventDefault();
122+
if (!isLoading && root) {
123+
handleConfirm();
124+
}
125+
}
126+
},
127+
[pathInput, loadDirectory, handleConfirm, isLoading, root]
128+
);
129+
99130
const entries =
100131
root?.children
101132
.filter((child) => child.isDirectory)
102133
.map((child) => ({ name: child.name, path: child.path })) ?? [];
103134

135+
const shortcutLabel = isMac() ? "⌘O" : formatKeybind(OPEN_KEYBIND);
136+
104137
return (
105138
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
106139
<DialogContent showCloseButton={false}>
107140
<DialogHeader>
108141
<DialogTitle>Select Project Directory</DialogTitle>
109-
<DialogDescription>
110-
{root ? root.path : "Select a directory to use as your project root"}
111-
</DialogDescription>
142+
<DialogDescription>Navigate to select a directory for your project</DialogDescription>
112143
</DialogHeader>
144+
<div className="mb-3">
145+
<Input
146+
value={pathInput}
147+
onChange={(e) => setPathInput(e.target.value)}
148+
onKeyDown={handlePathInputKeyDown}
149+
placeholder="Enter path..."
150+
className="bg-modal-bg border-border-medium h-9 font-mono text-sm"
151+
/>
152+
</div>
113153
{error && <div className="text-error mb-3 text-xs">{error}</div>}
114-
<div className="bg-modal-bg border-border-medium mb-4 h-80 overflow-hidden rounded border">
154+
<div
155+
ref={treeRef}
156+
className="bg-modal-bg border-border-medium mb-4 h-80 overflow-hidden rounded border"
157+
>
115158
<DirectoryTree
116159
currentPath={root ? root.path : null}
117160
entries={entries}
118161
isLoading={isLoading}
119162
onNavigateTo={handleNavigateTo}
120163
onNavigateParent={handleNavigateParent}
164+
onConfirm={handleConfirm}
165+
selectedIndex={selectedIndex}
166+
onSelectedIndexChange={setSelectedIndex}
121167
/>
122168
</div>
123169
<DialogFooter>
124170
<Button variant="secondary" onClick={onClose} disabled={isLoading}>
125171
Cancel
126172
</Button>
127-
<Button onClick={() => void handleConfirm()} disabled={isLoading || !root}>
128-
Select
173+
<Button
174+
onClick={() => void handleConfirm()}
175+
disabled={isLoading || !root}
176+
title={`Open folder (${shortcutLabel})`}
177+
>
178+
Open ({shortcutLabel})
129179
</Button>
130180
</DialogFooter>
131181
</DialogContent>

‎src/browser/components/DirectoryTree.tsx‎

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useCallback, useEffect, useRef, useState } from "react";
22
import { Folder, FolderUp } from "lucide-react";
33

44
interface DirectoryTreeEntry {
@@ -12,29 +12,151 @@ interface DirectoryTreeProps {
1212
isLoading?: boolean;
1313
onNavigateTo: (path: string) => void;
1414
onNavigateParent: () => void;
15+
onConfirm: () => void;
16+
selectedIndex: number;
17+
onSelectedIndexChange: (index: number) => void;
1518
}
1619

1720
export const DirectoryTree: React.FC<DirectoryTreeProps> = (props) => {
18-
const { currentPath, entries, isLoading = false, onNavigateTo, onNavigateParent } = props;
21+
const {
22+
currentPath,
23+
entries,
24+
isLoading = false,
25+
onNavigateTo,
26+
onNavigateParent,
27+
onConfirm,
28+
selectedIndex,
29+
onSelectedIndexChange,
30+
} = props;
1931

2032
const hasEntries = entries.length > 0;
21-
const containerRef = React.useRef<HTMLDivElement | null>(null);
33+
const containerRef = useRef<HTMLDivElement | null>(null);
34+
const selectedItemRef = useRef<HTMLLIElement | null>(null);
35+
const [typeAheadBuffer, setTypeAheadBuffer] = useState("");
36+
const typeAheadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2237

23-
React.useEffect(() => {
38+
// Total navigable items: parent (..) + entries
39+
const totalItems = (currentPath ? 1 : 0) + entries.length;
40+
41+
// Scroll container to top when path changes
42+
useEffect(() => {
2443
if (containerRef.current) {
2544
containerRef.current.scrollTop = 0;
2645
}
2746
}, [currentPath]);
2847

48+
// Scroll selected item into view
49+
useEffect(() => {
50+
if (selectedItemRef.current) {
51+
selectedItemRef.current.scrollIntoView({ block: "nearest" });
52+
}
53+
}, [selectedIndex]);
54+
55+
// Clear type-ahead buffer after 500ms of inactivity
56+
const resetTypeAhead = useCallback(() => {
57+
if (typeAheadTimeoutRef.current) {
58+
clearTimeout(typeAheadTimeoutRef.current);
59+
}
60+
typeAheadTimeoutRef.current = setTimeout(() => {
61+
setTypeAheadBuffer("");
62+
}, 500);
63+
}, []);
64+
65+
// Handle keyboard navigation
66+
const handleKeyDown = useCallback(
67+
(e: React.KeyboardEvent) => {
68+
// Type-ahead search for printable characters
69+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
70+
const newBuffer = typeAheadBuffer + e.key.toLowerCase();
71+
setTypeAheadBuffer(newBuffer);
72+
resetTypeAhead();
73+
74+
// Find first entry matching the buffer
75+
const matchIndex = entries.findIndex((entry) =>
76+
entry.name.toLowerCase().startsWith(newBuffer)
77+
);
78+
if (matchIndex !== -1) {
79+
// Offset by 1 if parent exists (index 0 is parent)
80+
const actualIndex = currentPath ? matchIndex + 1 : matchIndex;
81+
onSelectedIndexChange(actualIndex);
82+
}
83+
e.preventDefault();
84+
return;
85+
}
86+
87+
switch (e.key) {
88+
case "ArrowUp":
89+
e.preventDefault();
90+
if (totalItems > 0) {
91+
onSelectedIndexChange(selectedIndex <= 0 ? totalItems - 1 : selectedIndex - 1);
92+
}
93+
break;
94+
case "ArrowDown":
95+
e.preventDefault();
96+
if (totalItems > 0) {
97+
onSelectedIndexChange(selectedIndex >= totalItems - 1 ? 0 : selectedIndex + 1);
98+
}
99+
break;
100+
case "Enter":
101+
e.preventDefault();
102+
if (selectedIndex === 0 && currentPath) {
103+
// Parent directory selected
104+
onNavigateParent();
105+
} else if (entries.length > 0) {
106+
// Navigate into selected directory
107+
const entryIndex = currentPath ? selectedIndex - 1 : selectedIndex;
108+
if (entryIndex >= 0 && entryIndex < entries.length) {
109+
onNavigateTo(entries[entryIndex].path);
110+
}
111+
}
112+
break;
113+
case "Backspace":
114+
e.preventDefault();
115+
if (currentPath) {
116+
onNavigateParent();
117+
}
118+
break;
119+
case "o":
120+
if (e.ctrlKey || e.metaKey) {
121+
e.preventDefault();
122+
onConfirm();
123+
}
124+
break;
125+
}
126+
},
127+
[
128+
selectedIndex,
129+
totalItems,
130+
currentPath,
131+
entries,
132+
onSelectedIndexChange,
133+
onNavigateTo,
134+
onNavigateParent,
135+
onConfirm,
136+
typeAheadBuffer,
137+
resetTypeAhead,
138+
]
139+
);
140+
141+
const isSelected = (index: number) => selectedIndex === index;
142+
29143
return (
30-
<div ref={containerRef} className="h-full overflow-y-auto p-2 text-sm">
144+
<div
145+
ref={containerRef}
146+
className="h-full overflow-y-auto p-2 text-sm outline-none"
147+
tabIndex={0}
148+
onKeyDown={handleKeyDown}
149+
>
31150
{isLoading && !currentPath ? (
32151
<div className="text-muted py-4 text-center">Loading directories...</div>
33152
) : (
34153
<ul className="m-0 list-none p-0">
35154
{currentPath && (
36155
<li
37-
className="text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
156+
ref={isSelected(0) ? selectedItemRef : null}
157+
className={`text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
158+
isSelected(0) ? "bg-white/10" : "hover:bg-white/5"
159+
}`}
38160
onClick={onNavigateParent}
39161
>
40162
<FolderUp size={16} className="text-muted shrink-0" />
@@ -46,16 +168,22 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = (props) => {
46168
<li className="text-muted px-2 py-1.5">No subdirectories found</li>
47169
) : null}
48170

49-
{entries.map((entry) => (
50-
<li
51-
key={entry.path}
52-
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
53-
onClick={() => onNavigateTo(entry.path)}
54-
>
55-
<Folder size={16} className="shrink-0 text-yellow-500/80" />
56-
<span className="truncate">{entry.name}</span>
57-
</li>
58-
))}
171+
{entries.map((entry, idx) => {
172+
const actualIndex = currentPath ? idx + 1 : idx;
173+
return (
174+
<li
175+
key={entry.path}
176+
ref={isSelected(actualIndex) ? selectedItemRef : null}
177+
className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
178+
isSelected(actualIndex) ? "bg-white/10" : "hover:bg-white/5"
179+
}`}
180+
onClick={() => onNavigateTo(entry.path)}
181+
>
182+
<Folder size={16} className="shrink-0 text-yellow-500/80" />
183+
<span className="truncate">{entry.name}</span>
184+
</li>
185+
);
186+
})}
59187

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

0 commit comments

Comments
 (0)