11import React from "react" ;
2- import { Box , Text , useInput , useApp } from "ink" ;
2+ import { Box , Text , useApp } from "ink" ;
33import figures from "figures" ;
44import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination" ;
55import { getClient } from "../../utils/client.js" ;
@@ -22,6 +22,11 @@ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
2222import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js" ;
2323import { useCursorPagination } from "../../hooks/useCursorPagination.js" ;
2424import { useListSearch } from "../../hooks/useListSearch.js" ;
25+ import { openInBrowser } from "../../utils/browser.js" ;
26+ import {
27+ useInputHandler ,
28+ type InputMode ,
29+ } from "../../hooks/useInputHandler.js" ;
2530import { colors } from "../../utils/theme.js" ;
2631import { useDevboxStore , type Devbox } from "../../store/devboxStore.js" ;
2732
@@ -447,122 +452,143 @@ const ListDevboxesUI = ({
447452 } )
448453 : allOperations ;
449454
450- useInput ( ( input , key ) => {
451- const pageDevboxes = devboxes . length ;
452-
453- // Skip input handling when in search mode - let TextInput handle it
454- if ( search . searchMode ) {
455- if ( key . escape ) {
456- search . cancelSearch ( ) ;
457- }
458- return ;
459- }
460-
461- // Skip input handling when in details view
462- if ( showDetails ) {
463- return ;
464- }
465-
466- // Skip input handling when in create view
467- if ( showCreate ) {
468- return ;
455+ const closePopup = React . useCallback ( ( ) => {
456+ setShowPopup ( false ) ;
457+ setSelectedOperation ( 0 ) ;
458+ } , [ ] ) ;
459+
460+ const handleListEscape = React . useCallback ( ( ) => {
461+ if ( search . handleEscape ( ) ) return ;
462+ if ( onBack ) {
463+ onBack ( ) ;
464+ } else if ( onExit ) {
465+ onExit ( ) ;
466+ } else {
467+ inkExit ( ) ;
469468 }
469+ } , [ search , onBack , onExit , inkExit ] ) ;
470470
471- // Skip input handling when in actions view
472- if ( showActions ) {
473- return ;
474- }
475-
476- // Handle popup navigation
477- if ( showPopup ) {
478- if ( key . escape || input === "q" ) {
479- setShowPopup ( false ) ;
480- setSelectedOperation ( 0 ) ;
481- } else if ( key . upArrow && selectedOperation > 0 ) {
482- setSelectedOperation ( selectedOperation - 1 ) ;
483- } else if ( key . downArrow && selectedOperation < operations . length - 1 ) {
484- setSelectedOperation ( selectedOperation + 1 ) ;
485- } else if ( key . return ) {
486- setShowPopup ( false ) ;
487- setShowActions ( true ) ;
488- } else if ( input ) {
489- const matchedOpIndex = operations . findIndex (
490- ( op ) => op . shortcut === input ,
491- ) ;
492- if ( matchedOpIndex !== - 1 ) {
493- setSelectedOperation ( matchedOpIndex ) ;
494- setShowPopup ( false ) ;
495- setShowActions ( true ) ;
496- }
497- }
498- return ;
499- }
471+ const handleOpenInBrowser = React . useCallback ( ( ) => {
472+ if ( ! selectedDevbox ) return ;
473+ openInBrowser ( getDevboxUrl ( selectedDevbox . id ) ) ;
474+ } , [ selectedDevbox ] ) ;
500475
501- // Handle list view
502- if ( key . upArrow && selectedIndex > 0 ) {
503- setSelectedIndex ( selectedIndex - 1 ) ;
504- } else if ( key . downArrow && selectedIndex < pageDevboxes - 1 ) {
505- setSelectedIndex ( selectedIndex + 1 ) ;
506- } else if (
507- ( input === "n" || key . rightArrow ) &&
508- ! loading &&
509- ! navigating &&
510- hasMore
511- ) {
476+ const goToNextPage = React . useCallback ( ( ) => {
477+ if ( ! loading && ! navigating && hasMore ) {
512478 nextPage ( ) ;
513479 setSelectedIndex ( 0 ) ;
514- } else if (
515- ( input === "p" || key . leftArrow ) &&
516- ! loading &&
517- ! navigating &&
518- hasPrev
519- ) {
480+ }
481+ } , [ loading , navigating , hasMore , nextPage ] ) ;
482+
483+ const goToPrevPage = React . useCallback ( ( ) => {
484+ if ( ! loading && ! navigating && hasPrev ) {
520485 prevPage ( ) ;
521486 setSelectedIndex ( 0 ) ;
522- } else if ( key . return ) {
523- if ( onNavigateToDetail && selectedDevbox ) {
524- onNavigateToDetail ( selectedDevbox . id ) ;
525- } else {
526- setShowDetails ( true ) ;
527- }
528- } else if ( input === "a" ) {
529- setShowPopup ( true ) ;
530- setSelectedOperation ( 0 ) ;
531- } else if ( input === "c" ) {
532- setShowCreate ( true ) ;
533- } else if ( input === "o" && selectedDevbox ) {
534- const url = getDevboxUrl ( selectedDevbox . id ) ;
535- const openBrowser = async ( ) => {
536- const { exec } = await import ( "child_process" ) ;
537- const platform = process . platform ;
538-
539- let openCommand : string ;
540- if ( platform === "darwin" ) {
541- openCommand = `open "${ url } "` ;
542- } else if ( platform === "win32" ) {
543- openCommand = `start "${ url } "` ;
544- } else {
545- openCommand = `xdg-open "${ url } "` ;
546- }
547-
548- exec ( openCommand ) ;
549- } ;
550- openBrowser ( ) ;
551- } else if ( input === "/" ) {
552- search . enterSearchMode ( ) ;
553- } else if ( key . escape ) {
554- if ( search . handleEscape ( ) ) {
555- return ;
556- }
557- if ( onBack ) {
558- onBack ( ) ;
559- } else if ( onExit ) {
560- onExit ( ) ;
561- } else {
562- inkExit ( ) ;
563- }
564487 }
565- } ) ;
488+ } , [ loading , navigating , hasPrev , prevPage ] ) ;
489+
490+ const inputModes : InputMode [ ] = React . useMemo (
491+ ( ) => [
492+ // Search mode: only escape to cancel, swallow everything else
493+ {
494+ name : "search" ,
495+ active : ( ) => search . searchMode ,
496+ bindings : {
497+ escape : ( ) => search . cancelSearch ( ) ,
498+ } ,
499+ captureAll : true ,
500+ } ,
501+ // Subview guards: swallow all input when a child view is active
502+ {
503+ name : "subviews" ,
504+ active : ( ) => showDetails || showCreate || showActions ,
505+ bindings : { } ,
506+ captureAll : true ,
507+ } ,
508+ // Popup navigation
509+ {
510+ name : "popup" ,
511+ active : ( ) => showPopup ,
512+ bindings : {
513+ escape : closePopup ,
514+ q : closePopup ,
515+ up : ( ) => {
516+ if ( selectedOperation > 0 )
517+ setSelectedOperation ( selectedOperation - 1 ) ;
518+ } ,
519+ down : ( ) => {
520+ if ( selectedOperation < operations . length - 1 )
521+ setSelectedOperation ( selectedOperation + 1 ) ;
522+ } ,
523+ enter : ( ) => {
524+ setShowPopup ( false ) ;
525+ setShowActions ( true ) ;
526+ } ,
527+ } ,
528+ onUnmatched : ( input ) => {
529+ const idx = operations . findIndex ( ( op ) => op . shortcut === input ) ;
530+ if ( idx !== - 1 ) {
531+ setSelectedOperation ( idx ) ;
532+ setShowPopup ( false ) ;
533+ setShowActions ( true ) ;
534+ }
535+ } ,
536+ } ,
537+ // List navigation (default mode)
538+ {
539+ name : "list" ,
540+ active : ( ) => true ,
541+ bindings : {
542+ up : ( ) => {
543+ if ( selectedIndex > 0 ) setSelectedIndex ( selectedIndex - 1 ) ;
544+ } ,
545+ down : ( ) => {
546+ if ( selectedIndex < devboxes . length - 1 )
547+ setSelectedIndex ( selectedIndex + 1 ) ;
548+ } ,
549+ n : goToNextPage ,
550+ right : goToNextPage ,
551+ p : goToPrevPage ,
552+ left : goToPrevPage ,
553+ enter : ( ) => {
554+ if ( onNavigateToDetail && selectedDevbox ) {
555+ onNavigateToDetail ( selectedDevbox . id ) ;
556+ } else {
557+ setShowDetails ( true ) ;
558+ }
559+ } ,
560+ a : ( ) => {
561+ setShowPopup ( true ) ;
562+ setSelectedOperation ( 0 ) ;
563+ } ,
564+ c : ( ) => setShowCreate ( true ) ,
565+ o : handleOpenInBrowser ,
566+ "/" : ( ) => search . enterSearchMode ( ) ,
567+ escape : handleListEscape ,
568+ } ,
569+ } ,
570+ ] ,
571+ [
572+ search ,
573+ showDetails ,
574+ showCreate ,
575+ showActions ,
576+ showPopup ,
577+ closePopup ,
578+ selectedOperation ,
579+ operations ,
580+ selectedIndex ,
581+ devboxes . length ,
582+ goToNextPage ,
583+ goToPrevPage ,
584+ onNavigateToDetail ,
585+ selectedDevbox ,
586+ handleOpenInBrowser ,
587+ handleListEscape ,
588+ ] ,
589+ ) ;
590+
591+ useInputHandler ( inputModes ) ;
566592
567593 // Create view
568594 if ( showCreate ) {
0 commit comments