file browser: type-ahead jump-to-item, flips to filter on longer bursts
Replaces the old "any key focuses the search box" with classic file-manager type-ahead in FileGrid: keys within 1s accumulate into a burst. 1–2 chars select and scroll to the first item (folder or file, in sort order) whose name starts with the burst; the 3rd char flips it into the search filter (dumps the typed text into the box and hands off focus, cursor at end). Idle >1s resets the burst. Removed the single-key focus-search branch from useFileBrowserApp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ const LIST_ROW_HEIGHT = 42;
|
|||||||
const GRID_ROW_HEIGHT = 130;
|
const GRID_ROW_HEIGHT = 130;
|
||||||
const GRID_GAP = 12;
|
const GRID_GAP = 12;
|
||||||
const HEADER_HEIGHT = 36;
|
const HEADER_HEIGHT = 36;
|
||||||
|
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
|
||||||
|
// matching item; the 3rd char flips the burst into the search filter. Idle past the window resets it.
|
||||||
|
const TYPEAHEAD_RESET_MS = 1000;
|
||||||
|
const TYPEAHEAD_FILTER_AT = 3;
|
||||||
|
|
||||||
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
const [cols, setCols] = useState(4);
|
const [cols, setCols] = useState(4);
|
||||||
@@ -67,6 +71,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
handleRunTask,
|
handleRunTask,
|
||||||
handleCreateDashboard,
|
handleCreateDashboard,
|
||||||
fileScrollRef: scrollRef,
|
fileScrollRef: scrollRef,
|
||||||
|
setSearchQuery,
|
||||||
|
searchInputRef,
|
||||||
} = fileBrowserManager;
|
} = fileBrowserManager;
|
||||||
const { defaultSort } = fileBrowserManager;
|
const { defaultSort } = fileBrowserManager;
|
||||||
const lastClickedIdx = useRef<number>(-1);
|
const lastClickedIdx = useRef<number>(-1);
|
||||||
@@ -152,6 +158,65 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
scrollMargin: HEADER_HEIGHT,
|
scrollMargin: HEADER_HEIGHT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refs hold the latest values so the once-registered keydown listener always reads fresh state.
|
||||||
|
const sortedRef = useRef(sorted);
|
||||||
|
sortedRef.current = sorted;
|
||||||
|
const colsRef = useRef(cols);
|
||||||
|
colsRef.current = cols;
|
||||||
|
const viewModeRef = useRef(viewMode);
|
||||||
|
viewModeRef.current = viewMode;
|
||||||
|
const virtualizerRef = useRef(virtualizer);
|
||||||
|
virtualizerRef.current = virtualizer;
|
||||||
|
|
||||||
|
// Type-ahead (see constants above): jump-to-item on a short burst, flip to the filter on the 3rd key.
|
||||||
|
useEffect(() => {
|
||||||
|
let buffer = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const reset = () => {
|
||||||
|
buffer = '';
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
};
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
const t = ev.target as HTMLElement;
|
||||||
|
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.closest('.monaco-editor')) return;
|
||||||
|
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.key.length !== 1) return; // printable, unmodified only
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
buffer += ev.key;
|
||||||
|
timer = setTimeout(reset, TYPEAHEAD_RESET_MS);
|
||||||
|
const typed = buffer;
|
||||||
|
|
||||||
|
// Kept typing → treat it as a filter: dump the burst into the search box and hand off focus.
|
||||||
|
if (typed.length >= TYPEAHEAD_FILTER_AT) {
|
||||||
|
reset();
|
||||||
|
setSearchQuery(typed);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = searchInputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(el.value.length, el.value.length); // cursor at the end, keep typing
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short burst → jump: select + scroll to the first item (folder or file) whose name starts with it.
|
||||||
|
const prefix = typed.toLowerCase();
|
||||||
|
const list = sortedRef.current;
|
||||||
|
const idx = list.findIndex((e) => e.name.toLowerCase().startsWith(prefix));
|
||||||
|
if (idx < 0) return;
|
||||||
|
setSelected(new Set([list[idx]!.name]));
|
||||||
|
const row = viewModeRef.current === 'list' ? idx : Math.floor(idx / Math.max(1, colsRef.current));
|
||||||
|
virtualizerRef.current.scrollToIndex(row, { align: 'auto' });
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [setSearchQuery, searchInputRef, setSelected]);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,9 +237,7 @@ export const useFileBrowserApp = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
// (Printable keys are handled by FileGrid's type-ahead: jump-to-item, or flip to the filter.)
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener('keydown', handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user