From 2d0abaeb779ef63ed59de136e55c63ed2fd0a4ba Mon Sep 17 00:00:00 2001 From: Akshay Nair Date: Fri, 3 Oct 2025 11:05:17 +0530 Subject: Preserve list position while navigating through directories --- daffm.cabal | 5 ++- lib/Daffm.hs | 128 ++--------------------------------------------------- lib/Daffm/Event.hs | 62 ++++++++++++++++++++++++++ lib/Daffm/State.hs | 85 +++++++++++++++++++++++++++++++++++ lib/Daffm/Types.hs | 4 +- lib/Daffm/View.hs | 11 ++--- notes.org | 6 ++- 7 files changed, 168 insertions(+), 133 deletions(-) create mode 100644 lib/Daffm/Event.hs create mode 100644 lib/Daffm/State.hs diff --git a/daffm.cabal b/daffm.cabal index a9cce5f..75a49fe 100644 --- a/daffm.cabal +++ b/daffm.cabal @@ -25,7 +25,7 @@ common common-config build-depends: array, base, - brick <= 2.9, + brick <= 2.10, containers, data-default <= 0.8.0.1, directory <= 1.3.9.0, @@ -33,6 +33,7 @@ common common-config process <= 1.6.26.1, unix-compat <= 0.7.4.1, mtl == 2.3.1, + containers <= 0.8, temporary, text, vector, @@ -59,6 +60,8 @@ library lib-daffm Daffm.View Daffm.Types Daffm.Attrs + Daffm.Event + Daffm.State test-suite specs import: common-config, warnings diff --git a/lib/Daffm.hs b/lib/Daffm.hs index a7a3705..ffae42b 100644 --- a/lib/Daffm.hs +++ b/lib/Daffm.hs @@ -1,72 +1,11 @@ -module Daffm where +module Daffm (app, loadDirInAppState, mkEmptyAppState) where -import Brick (suspendAndResume') import qualified Brick.Main as M -import qualified Brick.Types as T -import qualified Brick.Widgets.Edit as Editor -import qualified Brick.Widgets.List as L -import Control.Monad (forM) -import Control.Monad.State (MonadIO (liftIO), MonadState, get, gets, modify, put) import Daffm.Attrs (appAttrMap) -import Daffm.Types (AppState (..), FileInfo (..), FileType (..), FocusTarget (FocusCmdline, FocusMain)) +import Daffm.Event (appEvent) +import Daffm.State (loadDirInAppState, mkEmptyAppState) +import Daffm.Types (AppState (..), FocusTarget) import Daffm.View (appView) -import Data.Char (toLower) -import Data.List (sortBy) -import Data.Maybe (fromMaybe) -import Data.Vector ((!?)) -import qualified Data.Vector as Vec -import qualified Graphics.Vty as V -import System.Directory (listDirectory, makeAbsolute, setCurrentDirectory) -import System.FilePath (takeDirectory) -import qualified System.PosixCompat as Posix -import System.Process (callProcess) - -type AppEvent = T.EventM FocusTarget AppState - -modifyM :: (MonadState s m) => (s -> m s) -> m () -modifyM f = get >>= f >>= put - -openSelectedFile :: AppEvent () -openSelectedFile = do - AppState {stateFiles, stateCwd} <- get - let indexM = L.listSelected stateFiles - let files = L.listElements stateFiles - case indexM >>= (files !?) of - Just (FileInfo {filePath, fileType = Directory}) -> - modifyM (liftIO . loadDirInAppState filePath stateCwd) - Just (FileInfo {filePath, fileType}) -> do - suspendAndResume' $ do - callProcess "nvim" [filePath] - putStrLn $ "Opening " <> show fileType <> ": " <> filePath - pure () - Nothing -> pure () - pure () - -goBackToParentDir :: AppEvent () -goBackToParentDir = do - dir <- gets stateParentDir - modifyM (liftIO . loadDirInAppState dir (takeDirectory dir)) - -appEvent :: T.BrickEvent FocusTarget e -> AppEvent () -appEvent brickevent@(T.VtyEvent event) = do - focusTarget <- gets stateFocusTarget - case (focusTarget, event) of - (FocusCmdline, V.EvKey V.KEsc []) -> modify (\st -> st {stateFocusTarget = FocusMain}) - (FocusMain, V.EvKey (V.KChar ':') []) -> modify (\st -> st {stateFocusTarget = FocusCmdline}) - (FocusMain, V.EvKey (V.KChar 'q') []) -> M.halt - (FocusMain, V.EvKey (V.KChar 'l') []) -> openSelectedFile - (FocusMain, V.EvKey (V.KChar 'h') []) -> goBackToParentDir - (FocusMain, V.EvKey V.KEnter []) -> openSelectedFile - (FocusMain, V.EvKey V.KBS []) -> goBackToParentDir - (FocusMain, _) -> do - files <- gets stateFiles - newFiles <- T.nestEventM' files (L.handleListEventVi L.handleListEvent event) - modify (\appState -> appState {stateFiles = newFiles}) - (FocusCmdline, _) -> do - editor <- gets stateCmdlineEditor - newEditor <- T.nestEventM' editor (Editor.handleEditorEvent brickevent) - modify (\appState -> appState {stateCmdlineEditor = newEditor}) -appEvent _ = pure () app :: M.App AppState e FocusTarget app = @@ -77,62 +16,3 @@ app = M.appStartEvent = pure (), M.appAttrMap = const appAttrMap } - -fileTypeFromStatus :: Posix.FileStatus -> Maybe FileType -fileTypeFromStatus s = - if - | Posix.isBlockDevice s -> Just BlockDevice - | Posix.isCharacterDevice s -> Just CharacterDevice - | Posix.isNamedPipe s -> Just NamedPipe - | Posix.isRegularFile s -> Just RegularFile - | Posix.isDirectory s -> Just Directory - | Posix.isSocket s -> Just UnixSocket - | Posix.isSymbolicLink s -> Just SymbolicLink - | otherwise -> Nothing - -getFileInfo :: FilePath -> IO FileInfo -getFileInfo name = do - path <- makeAbsolute name - stat <- Posix.getSymbolicLinkStatus path - pure $ - FileInfo - { filePath = path, - fileName = name, - fileSize = Posix.fileSize stat, - fileType = fromMaybe RegularFile $ fileTypeFromStatus stat - } - -fileSorter :: FileInfo -> FileInfo -> Ordering -fileSorter (FileInfo {fileType = Directory, fileName = fa}) (FileInfo {fileType = Directory, fileName = fb}) = - compare (toLower <$> fa) (toLower <$> fb) -fileSorter (FileInfo {fileType = Directory}) _ = LT -fileSorter _ (FileInfo {fileType = Directory}) = GT -fileSorter (FileInfo {fileName = fa}) (FileInfo {fileName = fb}) = - compare (toLower <$> fa) (toLower <$> fb) - -listFilesInDir :: FilePath -> IO [FileInfo] -listFilesInDir dir = do - files <- listDirectory dir - sortBy fileSorter <$> forM files getFileInfo - -loadDirInAppState :: FilePath -> FilePath -> AppState -> IO AppState -loadDirInAppState dir parentDir appState = do - setCurrentDirectory dir - files <- listFilesInDir dir - pure $ - appState - { stateFiles = L.list FocusMain (Vec.fromList files) 1, - stateCwd = dir, - stateParentDir = parentDir - } - -mkEmptyAppState :: AppState -mkEmptyAppState = - AppState - { stateFiles = L.list FocusMain (Vec.fromList []) 1, - stateCmdlineEditor = Editor.editor FocusCmdline Nothing "", - stateFocusTarget = FocusMain, - -- stateFocusRing = focusRing [FocusMain, FocusCmdline], - stateCwd = "", - stateParentDir = "" - } diff --git a/lib/Daffm/Event.hs b/lib/Daffm/Event.hs new file mode 100644 index 0000000..f14ebd8 --- /dev/null +++ b/lib/Daffm/Event.hs @@ -0,0 +1,62 @@ +module Daffm.Event where + +import Brick (suspendAndResume') +import qualified Brick.Main as M +import qualified Brick.Types as T +import qualified Brick.Widgets.Edit as Editor +import qualified Brick.Widgets.List as L +import Control.Monad.State (MonadIO (liftIO), MonadState, get, gets, modify, put) +import Daffm.State (cacheDirPosition, loadDirInAppState) +import Daffm.Types (AppState (..), FileInfo (..), FileType (..), FocusTarget (FocusCmdline, FocusMain)) +import Data.Vector ((!?)) +import qualified Graphics.Vty as V +import System.FilePath (takeDirectory) +import System.Process (callProcess) + +type AppEvent = T.EventM FocusTarget AppState + +modifyM :: (MonadState s m) => (s -> m s) -> m () +modifyM f = get >>= f >>= put + +appEvent :: T.BrickEvent FocusTarget e -> AppEvent () +appEvent brickevent@(T.VtyEvent event) = do + focusTarget <- gets stateFocusTarget + case (focusTarget, event) of + (FocusCmdline, V.EvKey V.KEsc []) -> modify (\st -> st {stateFocusTarget = FocusMain}) + (FocusMain, V.EvKey (V.KChar ':') []) -> modify (\st -> st {stateFocusTarget = FocusCmdline}) + (FocusMain, V.EvKey (V.KChar 'q') []) -> M.halt + (FocusMain, V.EvKey (V.KChar 'l') []) -> openSelectedFile + (FocusMain, V.EvKey (V.KChar 'h') []) -> goBackToParentDir + (FocusMain, V.EvKey V.KEnter []) -> openSelectedFile + (FocusMain, V.EvKey V.KBS []) -> goBackToParentDir + (FocusMain, _) -> do + files <- gets stateFiles + newFiles <- T.nestEventM' files (L.handleListEventVi L.handleListEvent event) + modify (\appState -> appState {stateFiles = newFiles}) + (FocusCmdline, _) -> do + editor <- gets stateCmdlineEditor + newEditor <- T.nestEventM' editor (Editor.handleEditorEvent brickevent) + modify (\appState -> appState {stateCmdlineEditor = newEditor}) + modify cacheDirPosition +appEvent _ = pure () + +openSelectedFile :: AppEvent () +openSelectedFile = do + AppState {stateFiles, stateCwd} <- get + let indexM = L.listSelected stateFiles + let files = L.listElements stateFiles + case indexM >>= (files !?) of + Just (FileInfo {filePath, fileType = Directory}) -> + modifyM (liftIO . loadDirInAppState filePath stateCwd) + Just (FileInfo {filePath, fileType}) -> do + suspendAndResume' $ do + putStrLn $ "Opening " <> show fileType <> ": " <> filePath + callProcess "nvim" [filePath] + pure () + Nothing -> pure () + pure () + +goBackToParentDir :: AppEvent () +goBackToParentDir = do + dir <- gets stateParentDir + modifyM (liftIO . loadDirInAppState dir (takeDirectory dir)) diff --git a/lib/Daffm/State.hs b/lib/Daffm/State.hs new file mode 100644 index 0000000..cfa9aeb --- /dev/null +++ b/lib/Daffm/State.hs @@ -0,0 +1,85 @@ +module Daffm.State where + +import qualified Brick.Widgets.Edit as Editor +import qualified Brick.Widgets.List as L +import Control.Applicative ((<|>)) +import Control.Monad (forM) +import Daffm.Types (AppState (..), FileInfo (..), FileType (..), FocusTarget (..)) +import Data.Char (toLower) +import Data.List (findIndex, sortBy) +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe) +import qualified Data.Vector as Vec +import System.Directory (listDirectory, makeAbsolute, setCurrentDirectory) +import qualified System.PosixCompat as Posix + +mkEmptyAppState :: AppState +mkEmptyAppState = + AppState + { stateFiles = L.list FocusMain (Vec.fromList []) 1, + stateCmdlineEditor = Editor.editor FocusCmdline Nothing "", + stateFocusTarget = FocusMain, + stateListPositionCache = Map.empty, + stateCwd = "", + stateParentDir = "" + } + +loadDirInAppState :: FilePath -> FilePath -> AppState -> IO AppState +loadDirInAppState dir parentDir appState@(AppState {stateCwd, stateListPositionCache}) = do + setCurrentDirectory dir + files <- listFilesInDir dir + let prevDirPosM = findIndex ((== stateCwd) . filePath) files + let cachedPosM = Map.lookup dir stateListPositionCache + let pos = fromMaybe 0 (cachedPosM <|> prevDirPosM) + let list = L.listMoveTo pos $ L.list FocusMain (Vec.fromList files) 1 + pure $ + appState + { stateFiles = list, + stateCwd = dir, + stateParentDir = parentDir + } + +fileTypeFromStatus :: Posix.FileStatus -> FileType +fileTypeFromStatus s = + if + | Posix.isBlockDevice s -> BlockDevice + | Posix.isCharacterDevice s -> CharacterDevice + | Posix.isNamedPipe s -> NamedPipe + | Posix.isRegularFile s -> RegularFile + | Posix.isDirectory s -> Directory + | Posix.isSocket s -> UnixSocket + | Posix.isSymbolicLink s -> SymbolicLink + | otherwise -> UnknownFileType + +getFileInfo :: FilePath -> IO FileInfo +getFileInfo name = do + path <- makeAbsolute name + stat <- Posix.getSymbolicLinkStatus path + pure $ + FileInfo + { filePath = path, + fileName = name, + fileSize = Posix.fileSize stat, + fileType = fileTypeFromStatus stat + } + +fileSorter :: FileInfo -> FileInfo -> Ordering +fileSorter (FileInfo {fileType = Directory, fileName = fa}) (FileInfo {fileType = Directory, fileName = fb}) = + compare (toLower <$> fa) (toLower <$> fb) +fileSorter (FileInfo {fileType = Directory}) _ = LT +fileSorter _ (FileInfo {fileType = Directory}) = GT +fileSorter (FileInfo {fileName = fa}) (FileInfo {fileName = fb}) = + compare (toLower <$> fa) (toLower <$> fb) + +listFilesInDir :: FilePath -> IO [FileInfo] +listFilesInDir dir = do + files <- listDirectory dir + sortBy fileSorter <$> forM files getFileInfo + +cacheDirPosition :: AppState -> AppState +cacheDirPosition appState@(AppState {stateListPositionCache, stateCwd, stateFiles}) = + appState + { stateListPositionCache = Map.insert stateCwd pos stateListPositionCache + } + where + pos = fromMaybe 0 $ L.listSelected stateFiles diff --git a/lib/Daffm/Types.hs b/lib/Daffm/Types.hs index 5462851..aaf6083 100644 --- a/lib/Daffm/Types.hs +++ b/lib/Daffm/Types.hs @@ -2,6 +2,7 @@ module Daffm.Types where import qualified Brick.Widgets.Edit as Editor import qualified Brick.Widgets.List as L +import qualified Data.Map as Map import System.Posix.Types (FileOffset) data FileType @@ -12,6 +13,7 @@ data FileType | Directory | SymbolicLink | UnixSocket + | UnknownFileType deriving (Show) data FileInfo = FileInfo @@ -28,8 +30,8 @@ data AppState = AppState { stateFiles :: L.List FocusTarget FileInfo, stateCmdlineEditor :: Editor.Editor String FocusTarget, stateFocusTarget :: FocusTarget, - -- stateFocusRing :: FocusRing FocusTarget, stateCwd :: FilePath, + stateListPositionCache :: Map.Map String Int, stateParentDir :: FilePath } deriving (Show) diff --git a/lib/Daffm/View.hs b/lib/Daffm/View.hs index db26a7d..2bfb7dc 100644 --- a/lib/Daffm/View.hs +++ b/lib/Daffm/View.hs @@ -1,7 +1,7 @@ module Daffm.View where import Brick.Types (Widget) -import Brick.Widgets.Core (Padding (Max, Pad), TextWidth (textWidth), hBox, hLimit, padLeft, padRight, str, vBox, vLimit, withAttr, (<+>)) +import Brick.Widgets.Core (Padding (Max, Pad), hBox, hLimit, padLeft, padRight, str, vBox, vLimit, withAttr, (<+>)) import Brick.Widgets.Edit (renderEditor) import qualified Brick.Widgets.List as L import Daffm.Attrs (directoryAttr, directorySelectedAttr, fileAttr, fileSelectedAttr) @@ -20,14 +20,14 @@ appView appState@(AppState {stateFiles, stateCwd}) = [ui] box :: Widget FocusTarget box = L.renderList fileItemView True stateFiles -fixedColumnsStr :: Int -> Widget n -> Widget n -fixedColumnsStr w s = hLimit w $ padRight Max s +hFixed :: Int -> Widget n -> Widget n +hFixed w = hLimit w . padRight Max fileItemView :: Bool -> FileInfo -> Widget FocusTarget fileItemView sel fileInfo@(FileInfo {fileSize, fileType}) = hBox - [ fixedColumnsStr 5 (fileTypeView fileType), - fixedColumnsStr 7 (fileSizeView fileSize), + [ hFixed 5 (fileTypeView fileType), + hFixed 7 (fileSizeView fileSize), fileNameView sel fileInfo ] where @@ -40,6 +40,7 @@ fileItemView sel fileInfo@(FileInfo {fileSize, fileType}) = showFileType CharacterDevice = "cdev" showFileType BlockDevice = "bdev" showFileType RegularFile = "file" + showFileType UnknownFileType = "?" fileNameView :: Bool -> FileInfo -> Widget FocusTarget fileNameView True (FileInfo {fileName, fileType = Directory}) = withAttr directorySelectedAttr $ str $ fileName <> "/" diff --git a/notes.org b/notes.org index d7beb46..d7f41a0 100644 --- a/notes.org +++ b/notes.org @@ -1,16 +1,18 @@ ** Current -- [ ] Show file permissions -- [ ] Preserve cursor position per dir while navigating +- [X] Preserve cursor position per dir while navigating - [ ] Cmdline must be single line - [ ] Commands - [ ] Run shell command - [ ] Command substitutions (%:filehighlighted %d:cwd %s:selections) +- [ ] Show file permissions ** Later - [ ] handle on open (for external integrations) - [ ] Cmdline history - [ ] bind command: define keybindings +- [ ] select multiple files - [ ] copy/paste across instances - user-land solution (write selections to file and read from second instance) - socket - [ ] support multikey bindings? - [ ] configuration file (toml?) +- [ ] watch for changes -- cgit v1.3.1