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" ;
410import { useFileSystemStore } from "@/stores/useFileSystemStore" ;
511import type { FileSystemItem , ClipboardItem } from "@/lib/types" ;
612import { 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
575584RenameDialog . 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+
577594export 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 - z A - Z 0 - 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