Skip to content

Commit 67743af

Browse files
committed
feat: add keyboard navigation and prefix search in file explorer
1 parent b4bd62a commit 67743af

File tree

1 file changed

+172
-53
lines changed

1 file changed

+172
-53
lines changed

src-frontend/components/workspace/file-explorer.tsx

Lines changed: 172 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"use client";
22

3-
import React, { useState, useCallback, useMemo } from "react";
3+
import React, {
4+
useState,
5+
useCallback,
6+
useMemo,
7+
useEffect,
8+
useRef,
9+
} from "react";
410
import { useFileSystemStore } from "@/stores/useFileSystemStore";
511
import type { FileSystemItem, ClipboardItem } from "@/lib/types";
612
import { FileIcon } from "@/components/workspace/file-icon";
@@ -380,6 +386,8 @@ const FileExplorerItem = React.memo(function FileExplorerItem({
380386
selectedItems.includes(item.path) ? "bg-accent" : "hover:bg-muted",
381387
dropTarget === item.id && "ring-2 ring-primary",
382388
viewMode === "list" && "justify-start gap-3",
389+
document.activeElement?.id === `file-item-${item.id}` &&
390+
"ring-2 ring-primary animate-pulse",
383391
);
384392

385393
const contentClasses = cn(
@@ -405,6 +413,7 @@ const FileExplorerItem = React.memo(function FileExplorerItem({
405413
<ContextMenu>
406414
<ContextMenuTrigger asChild>
407415
<motion.div
416+
id={`file-item-${item.id}`}
408417
initial={{ opacity: 0, scale: 0.9 }}
409418
animate={{ opacity: 1, scale: 1 }}
410419
exit={{ opacity: 0, scale: 0.9 }}
@@ -574,6 +583,14 @@ const RenameDialog = React.memo(
574583
// Set display name for the RenameDialog component
575584
RenameDialog.displayName = "RenameDialog";
576585

586+
// State interface for keyboard navigation
587+
interface SearchState {
588+
prefix: string;
589+
timeout: NodeJS.Timeout | null;
590+
lastKeyTime: number | null;
591+
items: FileSystemItem[];
592+
}
593+
577594
export function FileExplorer({
578595
items,
579596
selectedItems: externalSelectedItems,
@@ -618,17 +635,28 @@ export function FileExplorer({
618635
const navigateTo = onNavigate || fsNavigateTo;
619636
const setPreviewFile = externalSetPreviewFile || fsSetPreviewFile;
620637

621-
// Empty unused block since we're already destructuring these values above
622-
623638
// Group all state variables together for better readability
624639
const [renameItem, setRenameItem] = useState<FileSystemItem | null>(null);
625640
const [newName, setNewName] = useState("");
626641
const [draggedItem, setDraggedItem] = useState<FileSystemItem | null>(null);
627642
const [dropTarget, setDropTarget] = useState<string | null>(null);
628643
const [shareItem, setShareItem] = useState<FileSystemItem | null>(null);
629644

645+
// Create a ref to maintain search state between renders
646+
const searchStateRef = useRef<SearchState>({
647+
prefix: "",
648+
timeout: null,
649+
lastKeyTime: null,
650+
items: [],
651+
});
652+
630653
const isMobile = useIsMobile();
631654

655+
// Update items in search state when they change
656+
useEffect(() => {
657+
searchStateRef.current.items = items;
658+
}, [items]);
659+
632660
const handleItemDoubleClick = useCallback(
633661
(item: FileSystemItem) => {
634662
if (item.type === "folder") {
@@ -645,6 +673,147 @@ export function FileExplorer({
645673
[navigateTo, currentPath, setPreviewFile],
646674
);
647675

676+
// Handle keyboard navigation
677+
useEffect(() => {
678+
const TIMEOUT_DURATION = 1000;
679+
const state = searchStateRef.current; // Capture ref value
680+
681+
const handleKeyDown = (e: KeyboardEvent) => {
682+
// Ignore if user is typing in an input
683+
if (e.target instanceof HTMLInputElement) {
684+
return;
685+
}
686+
687+
// Ignore if any modifier keys are pressed (Ctrl, Alt, Meta, Shift)
688+
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) {
689+
return;
690+
}
691+
692+
// Handle Enter key to open selected item
693+
if (e.key === "Enter" && selectedItems.length === 1) {
694+
const selectedItem = items.find(
695+
(item) => item.path === selectedItems[0],
696+
);
697+
if (selectedItem) {
698+
handleItemDoubleClick(selectedItem);
699+
}
700+
return;
701+
}
702+
703+
// Handle alphanumeric keys for search
704+
if (e.key.length === 1 && /[a-zA-Z0-9]/.test(e.key)) {
705+
e.preventDefault();
706+
707+
const now = Date.now();
708+
709+
// Only reset if we have a previous key press and it's been too long
710+
if (
711+
state.lastKeyTime !== null &&
712+
now - state.lastKeyTime > TIMEOUT_DURATION
713+
) {
714+
state.prefix = "";
715+
}
716+
717+
// Clear existing timeout
718+
if (state.timeout) {
719+
clearTimeout(state.timeout);
720+
state.timeout = null;
721+
}
722+
723+
// Add the new key to the prefix
724+
state.prefix += e.key.toLowerCase();
725+
state.lastKeyTime = now;
726+
727+
// Find the first item that matches the current prefix
728+
const match = state.items.find((item) =>
729+
item.name.toLowerCase().startsWith(state.prefix),
730+
);
731+
732+
if (match) {
733+
setSelectedItems([match.path]);
734+
requestAnimationFrame(() => {
735+
const element = document.getElementById(`file-item-${match.id}`);
736+
if (element) {
737+
element.focus();
738+
element.scrollIntoView({
739+
behavior: "smooth",
740+
block: "nearest",
741+
});
742+
}
743+
});
744+
}
745+
746+
// Set new timeout to clear the prefix
747+
state.timeout = setTimeout(() => {
748+
state.prefix = "";
749+
state.timeout = null;
750+
state.lastKeyTime = null;
751+
}, TIMEOUT_DURATION);
752+
}
753+
};
754+
755+
window.addEventListener("keydown", handleKeyDown);
756+
757+
return () => {
758+
window.removeEventListener("keydown", handleKeyDown);
759+
if (state.timeout) {
760+
// Use captured state
761+
clearTimeout(state.timeout);
762+
state.timeout = null;
763+
}
764+
};
765+
}, [setSelectedItems, selectedItems, items, handleItemDoubleClick]);
766+
767+
// Apply sorting to items based on sort configuration - memoized to prevent unnecessary re-sorting
768+
const sortedItems = useMemo(
769+
() =>
770+
[...items].sort((a, b) => {
771+
// Always show folders before files regardless of sort option
772+
if (a.type === "folder" && b.type === "file") return -1;
773+
if (a.type === "file" && b.type === "folder") return 1;
774+
775+
// If both are the same type (folder or file), then sort by the selected criteria
776+
let compareResult = 0;
777+
778+
switch (sortConfig.sortBy) {
779+
case "name":
780+
compareResult = a.name.localeCompare(b.name, undefined, {
781+
sensitivity: "base",
782+
});
783+
break;
784+
case "date":
785+
compareResult =
786+
new Date(a.modifiedAt).getTime() -
787+
new Date(b.modifiedAt).getTime();
788+
break;
789+
case "size":
790+
compareResult = (a.size || 0) - (b.size || 0);
791+
break;
792+
case "type":
793+
// For files, compare by extension
794+
if (a.type === "file" && b.type === "file") {
795+
const aExt = a.name.split(".").pop() || "";
796+
const bExt = b.name.split(".").pop() || "";
797+
compareResult = aExt.localeCompare(bExt);
798+
// If same extension, sort by name
799+
if (compareResult === 0) {
800+
compareResult = a.name.localeCompare(b.name);
801+
}
802+
} else {
803+
// For folders, sort by name
804+
compareResult = a.name.localeCompare(b.name);
805+
}
806+
break;
807+
default:
808+
compareResult = a.name.localeCompare(b.name);
809+
}
810+
811+
// Apply sorting direction
812+
return sortConfig.direction === "asc" ? compareResult : -compareResult;
813+
}),
814+
[items, sortConfig],
815+
);
816+
648817
const handleItemClick = useCallback(
649818
(item: FileSystemItem, event: React.MouseEvent) => {
650819
// Single click now selects the item and shows details
@@ -814,56 +983,6 @@ export function FileExplorer({
814983
}
815984
}, []);
816985

817-
// Apply sorting to items based on sort configuration - memoized to prevent unnecessary re-sorting
818-
const sortedItems = useMemo(
819-
() =>
820-
[...items].sort((a, b) => {
821-
// Always show folders before files regardless of sort option
822-
if (a.type === "folder" && b.type === "file") return -1;
823-
if (a.type === "file" && b.type === "folder") return 1;
824-
825-
// If both are the same type (folder or file), then sort by the selected criteria
826-
let compareResult = 0;
827-
828-
switch (sortConfig.sortBy) {
829-
case "name":
830-
compareResult = a.name.localeCompare(b.name, undefined, {
831-
sensitivity: "base",
832-
});
833-
break;
834-
case "date":
835-
compareResult =
836-
new Date(a.modifiedAt).getTime() -
837-
new Date(b.modifiedAt).getTime();
838-
break;
839-
case "size":
840-
compareResult = (a.size || 0) - (b.size || 0);
841-
break;
842-
case "type":
843-
// For files, compare by extension
844-
if (a.type === "file" && b.type === "file") {
845-
const aExt = a.name.split(".").pop() || "";
846-
const bExt = b.name.split(".").pop() || "";
847-
compareResult = aExt.localeCompare(bExt);
848-
// If same extension, sort by name
849-
if (compareResult === 0) {
850-
compareResult = a.name.localeCompare(b.name);
851-
}
852-
} else {
853-
// For folders, sort by name
854-
compareResult = a.name.localeCompare(b.name);
855-
}
856-
break;
857-
default:
858-
compareResult = a.name.localeCompare(b.name);
859-
}
860-
861-
// Apply sorting direction
862-
return sortConfig.direction === "asc" ? compareResult : -compareResult;
863-
}),
864-
[items, sortConfig],
865-
);
866-
867986
// Extract empty state UI into a component for reuse
868987
const renderEmptyState = useCallback(
869988
() => (

0 commit comments

Comments
 (0)