{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module Brick.Widgets.FileBrowser
(
FileBrowser
, FileInfo(..)
, FileStatus(..)
, FileType(..)
, newFileBrowser
, selectNonDirectories
, selectDirectories
, setWorkingDirectory
, getWorkingDirectory
, updateFileBrowserSearch
, setFileBrowserEntryFilter
, handleFileBrowserEvent
, renderFileBrowser
, fileBrowserCursor
, fileBrowserIsSearching
, fileBrowserSelection
, fileBrowserException
, fileBrowserSelectable
, fileInfoFileType
, fileBrowserAttr
, fileBrowserCurrentDirectoryAttr
, fileBrowserSelectionInfoAttr
, fileBrowserSelectedAttr
, fileBrowserDirectoryAttr
, fileBrowserBlockDeviceAttr
, fileBrowserRegularFileAttr
, fileBrowserCharacterDeviceAttr
, fileBrowserNamedPipeAttr
, fileBrowserSymbolicLinkAttr
, fileBrowserUnixSocketAttr
, fileTypeMatch
, fileExtensionMatch
, fileBrowserEntryFilterL
, fileBrowserSelectableL
, fileInfoFilenameL
, fileInfoSanitizedFilenameL
, fileInfoFilePathL
, fileInfoFileStatusL
, fileInfoLinkTargetTypeL
, fileStatusSizeL
, fileStatusFileTypeL
, prettyFileSize
, entriesForDirectory
, getFileInfo
)
where
import qualified Control.Exception as E
import Control.Monad (forM)
import Control.Monad.IO.Class (liftIO)
import Data.Char (toLower, isPrint)
import Data.Maybe (fromMaybe, isJust, fromJust)
import qualified Data.Foldable as F
import qualified Data.Text as T
#if !(MIN_VERSION_base(4,11,0))
import Data.Monoid
#endif
import Data.Int (Int64)
import Data.List (sortBy, isSuffixOf)
import qualified Data.Set as Set
import qualified Data.Vector as V
import Lens.Micro
import qualified Graphics.Vty as Vty
import qualified System.Directory as D
import qualified System.Posix.Files as U
import qualified System.Posix.Types as U
import qualified System.FilePath as FP
import Text.Printf (printf)
import Brick.Types
import Brick.AttrMap (AttrName)
import Brick.Widgets.Core
import Brick.Widgets.List
data FileBrowser n =
FileBrowser { fileBrowserWorkingDirectory :: FilePath
, fileBrowserEntries :: List n FileInfo
, fileBrowserLatestResults :: [FileInfo]
, fileBrowserSelectedFiles :: Set.Set String
, fileBrowserName :: n
, fileBrowserEntryFilter :: Maybe (FileInfo -> Bool)
, fileBrowserSearchString :: Maybe T.Text
, fileBrowserException :: Maybe E.IOException
, fileBrowserSelectable :: FileInfo -> Bool
}
data FileStatus =
FileStatus { fileStatusSize :: Int64
, fileStatusFileType :: Maybe FileType
}
deriving (Show, Eq)
data FileInfo =
FileInfo { fileInfoFilename :: String
, fileInfoSanitizedFilename :: String
, fileInfoFilePath :: FilePath
, fileInfoFileStatus :: Either E.IOException FileStatus
, fileInfoLinkTargetType :: Maybe FileType
}
deriving (Show, Eq)
data FileType =
RegularFile
| BlockDevice
| CharacterDevice
| NamedPipe
| Directory
| SymbolicLink
| UnixSocket
deriving (Read, Show, Eq)
suffixLenses ''FileBrowser
suffixLenses ''FileInfo
suffixLenses ''FileStatus
newFileBrowser :: (FileInfo -> Bool)
-> n
-> Maybe FilePath
-> IO (FileBrowser n)
newFileBrowser selPredicate name mCwd = do
initialCwd <- case mCwd of
Just path -> return path
Nothing -> D.getCurrentDirectory
let b = FileBrowser { fileBrowserWorkingDirectory = initialCwd
, fileBrowserEntries = list name mempty 1
, fileBrowserLatestResults = mempty
, fileBrowserSelectedFiles = mempty
, fileBrowserName = name
, fileBrowserEntryFilter = Nothing
, fileBrowserSearchString = Nothing
, fileBrowserException = Nothing
, fileBrowserSelectable = selPredicate
}
setWorkingDirectory initialCwd b
selectNonDirectories :: FileInfo -> Bool
selectNonDirectories i =
case fileInfoFileType i of
Just Directory -> False
Just SymbolicLink ->
case fileInfoLinkTargetType i of
Just Directory -> False
_ -> True
_ -> True
selectDirectories :: FileInfo -> Bool
selectDirectories i =
case fileInfoFileType i of
Just Directory -> True
Just SymbolicLink ->
case fileInfoLinkTargetType i of
Just Directory -> True
_ -> False
_ -> False
setFileBrowserEntryFilter :: Maybe (FileInfo -> Bool) -> FileBrowser n -> FileBrowser n
setFileBrowserEntryFilter f b =
applyFilterAndSearch $ b & fileBrowserEntryFilterL .~ f
setWorkingDirectory :: FilePath -> FileBrowser n -> IO (FileBrowser n)
setWorkingDirectory path b = do
entriesResult <- E.try $ entriesForDirectory path
let (entries, exc) = case entriesResult of
Left (e::E.IOException) -> ([], Just e)
Right es -> (es, Nothing)
allEntries <- if path == "/" then return entries else do
parentResult <- E.try $ parentOf path
return $ case parentResult of
Left (_::E.IOException) -> entries
Right parent -> parent : entries
let b' = setEntries allEntries b
return $ b' & fileBrowserWorkingDirectoryL .~ path
& fileBrowserExceptionL .~ exc
& fileBrowserSelectedFilesL .~ mempty
parentOf :: FilePath -> IO FileInfo
parentOf path = getFileInfo ".." $ FP.takeDirectory path
getFileInfo :: String
-> FilePath
-> IO FileInfo
getFileInfo name fullPath = do
filePath <- D.makeAbsolute fullPath
statusResult <- E.try $ U.getSymbolicLinkStatus filePath
let stat = do
status <- statusResult
let U.COff sz = U.fileSize status
return FileStatus { fileStatusFileType = fileTypeFromStatus status
, fileStatusSize = sz
}
targetTy <- case fileStatusFileType <$> stat of
Right (Just SymbolicLink) -> do
targetPathResult <- E.try $ U.readSymbolicLink filePath
case targetPathResult of
Left (_::E.SomeException) -> return Nothing
Right targetPath -> do
targetInfo <- liftIO $ getFileInfo "unused" targetPath
case fileInfoFileStatus targetInfo of
Right (FileStatus _ targetTy) -> return targetTy
_ -> return Nothing
_ -> return Nothing
return FileInfo { fileInfoFilename = name
, fileInfoFilePath = filePath
, fileInfoSanitizedFilename = sanitizeFilename name
, fileInfoFileStatus = stat
, fileInfoLinkTargetType = targetTy
}
fileInfoFileType :: FileInfo -> Maybe FileType
fileInfoFileType i =
case fileInfoFileStatus i of
Left _ -> Nothing
Right stat -> fileStatusFileType stat
getWorkingDirectory :: FileBrowser n -> FilePath
getWorkingDirectory = fileBrowserWorkingDirectory
setEntries :: [FileInfo] -> FileBrowser n -> FileBrowser n
setEntries es b =
applyFilterAndSearch $ b & fileBrowserLatestResultsL .~ es
fileBrowserIsSearching :: FileBrowser n -> Bool
fileBrowserIsSearching b = isJust $ b^.fileBrowserSearchStringL
fileBrowserSelection :: FileBrowser n -> [FileInfo]
fileBrowserSelection b =
let getEntry filename = fromJust $ F.find ((== filename) . fileInfoFilename) $ b^.fileBrowserLatestResultsL
in fmap getEntry $ F.toList $ b^.fileBrowserSelectedFilesL
updateFileBrowserSearch :: (Maybe T.Text -> Maybe T.Text)
-> FileBrowser n
-> FileBrowser n
updateFileBrowserSearch f b =
let old = b^.fileBrowserSearchStringL
new = f $ b^.fileBrowserSearchStringL
oldLen = maybe 0 T.length old
newLen = maybe 0 T.length new
in if old == new
then b
else if oldLen == newLen
then b & fileBrowserSearchStringL .~ new
else applyFilterAndSearch $ b & fileBrowserSearchStringL .~ new
applyFilterAndSearch :: FileBrowser n -> FileBrowser n
applyFilterAndSearch b =
let filterMatch = fromMaybe (const True) (b^.fileBrowserEntryFilterL)
searchMatch = maybe (const True)
(\search i -> (T.toLower search `T.isInfixOf` (T.pack $ toLower <$> fileInfoSanitizedFilename i)))
(b^.fileBrowserSearchStringL)
match i = filterMatch i && searchMatch i
matching = filter match $ b^.fileBrowserLatestResultsL
in b { fileBrowserEntries = list (b^.fileBrowserNameL) (V.fromList matching) 1 }
prettyFileSize :: Int64
-> T.Text
prettyFileSize i
| i >= 2 ^ (40::Int64) = T.pack $ format (i `divBy` (2 ** 40)) <> "T"
| i >= 2 ^ (30::Int64) = T.pack $ format (i `divBy` (2 ** 30)) <> "G"
| i >= 2 ^ (20::Int64) = T.pack $ format (i `divBy` (2 ** 20)) <> "M"
| i >= 2 ^ (10::Int64) = T.pack $ format (i `divBy` (2 ** 10)) <> "K"
| otherwise = T.pack $ show i <> " bytes"
where
format = printf "%0.1f"
divBy :: Int64 -> Double -> Double
divBy a b = ((fromIntegral a) :: Double) / b
entriesForDirectory :: FilePath -> IO [FileInfo]
entriesForDirectory rawPath = do
path <- D.makeAbsolute rawPath
dirContents <- D.listDirectory path
infos <- forM dirContents $ \f -> do
getFileInfo f (path FP.</> f)
let dirsFirst a b = if fileInfoFileType a == Just Directory &&
fileInfoFileType b == Just Directory
then compare (toLower <$> fileInfoFilename a)
(toLower <$> fileInfoFilename b)
else if fileInfoFileType a == Just Directory &&
fileInfoFileType b /= Just Directory
then LT
else if fileInfoFileType b == Just Directory &&
fileInfoFileType a /= Just Directory
then GT
else compare (toLower <$> fileInfoFilename a)
(toLower <$> fileInfoFilename b)
allEntries = sortBy dirsFirst infos
return allEntries
fileTypeFromStatus :: U.FileStatus -> Maybe FileType
fileTypeFromStatus s =
if | U.isBlockDevice s -> Just BlockDevice
| U.isCharacterDevice s -> Just CharacterDevice
| U.isNamedPipe s -> Just NamedPipe
| U.isRegularFile s -> Just RegularFile
| U.isDirectory s -> Just Directory
| U.isSocket s -> Just UnixSocket
| U.isSymbolicLink s -> Just SymbolicLink
| otherwise -> Nothing
fileBrowserCursor :: FileBrowser n -> Maybe FileInfo
fileBrowserCursor b = snd <$> listSelectedElement (b^.fileBrowserEntriesL)
handleFileBrowserEvent :: (Ord n) => Vty.Event -> FileBrowser n -> EventM n (FileBrowser n)
handleFileBrowserEvent e b =
if fileBrowserIsSearching b
then handleFileBrowserEventSearching e b
else handleFileBrowserEventNormal e b
safeInit :: T.Text -> T.Text
safeInit t | T.length t == 0 = t
| otherwise = T.init t
handleFileBrowserEventSearching :: (Ord n) => Vty.Event -> FileBrowser n -> EventM n (FileBrowser n)
handleFileBrowserEventSearching e b =
case e of
Vty.EvKey (Vty.KChar 'c') [Vty.MCtrl] ->
return $ updateFileBrowserSearch (const Nothing) b
Vty.EvKey Vty.KEsc [] ->
return $ updateFileBrowserSearch (const Nothing) b
Vty.EvKey Vty.KBS [] ->
return $ updateFileBrowserSearch (fmap safeInit) b
Vty.EvKey Vty.KEnter [] ->
updateFileBrowserSearch (const Nothing) <$>
maybeSelectCurrentEntry b
Vty.EvKey (Vty.KChar c) [] ->
return $ updateFileBrowserSearch (fmap (flip T.snoc c)) b
_ ->
handleFileBrowserEventCommon e b
handleFileBrowserEventNormal :: (Ord n) => Vty.Event -> FileBrowser n -> EventM n (FileBrowser n)
handleFileBrowserEventNormal e b =
case e of
Vty.EvKey (Vty.KChar '/') [] ->
return $ updateFileBrowserSearch (const $ Just "") b
Vty.EvKey Vty.KEnter [] ->
maybeSelectCurrentEntry b
Vty.EvKey (Vty.KChar ' ') [] ->
selectCurrentEntry b
_ ->
handleFileBrowserEventCommon e b
handleFileBrowserEventCommon :: (Ord n) => Vty.Event -> FileBrowser n -> EventM n (FileBrowser n)
handleFileBrowserEventCommon e b =
case e of
Vty.EvKey (Vty.KChar 'n') [Vty.MCtrl] ->
return $ b & fileBrowserEntriesL %~ listMoveBy 1
Vty.EvKey (Vty.KChar 'p') [Vty.MCtrl] ->
return $ b & fileBrowserEntriesL %~ listMoveBy (-1)
_ ->
handleEventLensed b fileBrowserEntriesL handleListEvent e
maybeSelectCurrentEntry :: FileBrowser n -> EventM n (FileBrowser n)
maybeSelectCurrentEntry b =
case fileBrowserCursor b of
Nothing -> return b
Just entry ->
if fileBrowserSelectable b entry
then return $ b & fileBrowserSelectedFilesL %~ Set.insert (fileInfoFilename entry)
else case fileInfoFileType entry of
Just Directory ->
liftIO $ setWorkingDirectory (fileInfoFilePath entry) b
Just SymbolicLink ->
case fileInfoLinkTargetType entry of
Just Directory -> do
liftIO $ setWorkingDirectory (fileInfoFilePath entry) b
_ ->
return b
_ ->
return b
selectCurrentEntry :: FileBrowser n -> EventM n (FileBrowser n)
selectCurrentEntry b =
case fileBrowserCursor b of
Nothing -> return b
Just e -> return $ b & fileBrowserSelectedFilesL %~ Set.insert (fileInfoFilename e)
renderFileBrowser :: (Show n, Ord n)
=> Bool
-> FileBrowser n
-> Widget n
renderFileBrowser foc b =
let maxFilenameLength = maximum $ (length . fileInfoFilename) <$> (b^.fileBrowserEntriesL)
cwdHeader = padRight Max $
str $ sanitizeFilename $ fileBrowserWorkingDirectory b
selInfo = case listSelectedElement (b^.fileBrowserEntriesL) of
Nothing -> vLimit 1 $ fill ' '
Just (_, i) -> padRight Max $ selInfoFor i
fileTypeLabel Nothing = "unknown"
fileTypeLabel (Just t) =
case t of
RegularFile -> "file"
BlockDevice -> "block device"
CharacterDevice -> "character device"
NamedPipe -> "pipe"
Directory -> "directory"
SymbolicLink -> "symbolic link"
UnixSocket -> "socket"
selInfoFor i =
let label = case fileInfoFileStatus i of
Left _ -> "unknown"
Right stat ->
let maybeSize = if fileStatusFileType stat == Just RegularFile
then ", " <> prettyFileSize (fileStatusSize stat)
else ""
in fileTypeLabel (fileStatusFileType stat) <> maybeSize
in txt $ (T.pack $ fileInfoSanitizedFilename i) <> ": " <> label
maybeSearchInfo = case b^.fileBrowserSearchStringL of
Nothing -> emptyWidget
Just s -> padRight Max $
txt "Search: " <+>
showCursor (b^.fileBrowserNameL) (Location (T.length s, 0)) (txt s)
in withDefAttr fileBrowserAttr $
vBox [ withDefAttr fileBrowserCurrentDirectoryAttr cwdHeader
, renderList (renderFileInfo foc maxFilenameLength (b^.fileBrowserSelectedFilesL))
foc (b^.fileBrowserEntriesL)
, maybeSearchInfo
, withDefAttr fileBrowserSelectionInfoAttr selInfo
]
renderFileInfo :: Bool -> Int -> Set.Set String -> Bool -> FileInfo -> Widget n
renderFileInfo foc maxLen selFiles listSel info =
(if foc
then (if listSel then forceAttr listSelectedFocusedAttr
else if sel then forceAttr fileBrowserSelectedAttr else id)
else (if listSel then forceAttr listSelectedAttr
else if sel then forceAttr fileBrowserSelectedAttr else id)) $
padRight Max body
where
sel = fileInfoFilename info `Set.member` selFiles
addAttr = maybe id (withDefAttr . attrForFileType) (fileInfoFileType info)
body = addAttr (hLimit (maxLen + 1) $
padRight Max $
str $ fileInfoSanitizedFilename info <> suffix)
suffix = (if fileInfoFileType info == Just Directory then "/" else "") <>
(if sel then "*" else "")
sanitizeFilename :: String -> String
sanitizeFilename = fmap toPrint
where
toPrint c | isPrint c = c
| otherwise = '?'
attrForFileType :: FileType -> AttrName
attrForFileType RegularFile = fileBrowserRegularFileAttr
attrForFileType BlockDevice = fileBrowserBlockDeviceAttr
attrForFileType CharacterDevice = fileBrowserCharacterDeviceAttr
attrForFileType NamedPipe = fileBrowserNamedPipeAttr
attrForFileType Directory = fileBrowserDirectoryAttr
attrForFileType SymbolicLink = fileBrowserSymbolicLinkAttr
attrForFileType UnixSocket = fileBrowserUnixSocketAttr
fileBrowserAttr :: AttrName
fileBrowserAttr = "fileBrowser"
fileBrowserCurrentDirectoryAttr :: AttrName
fileBrowserCurrentDirectoryAttr = fileBrowserAttr <> "currentDirectory"
fileBrowserSelectionInfoAttr :: AttrName
fileBrowserSelectionInfoAttr = fileBrowserAttr <> "selectionInfo"
fileBrowserDirectoryAttr :: AttrName
fileBrowserDirectoryAttr = fileBrowserAttr <> "directory"
fileBrowserBlockDeviceAttr :: AttrName
fileBrowserBlockDeviceAttr = fileBrowserAttr <> "block"
fileBrowserRegularFileAttr :: AttrName
fileBrowserRegularFileAttr = fileBrowserAttr <> "regular"
fileBrowserCharacterDeviceAttr :: AttrName
fileBrowserCharacterDeviceAttr = fileBrowserAttr <> "char"
fileBrowserNamedPipeAttr :: AttrName
fileBrowserNamedPipeAttr = fileBrowserAttr <> "pipe"
fileBrowserSymbolicLinkAttr :: AttrName
fileBrowserSymbolicLinkAttr = fileBrowserAttr <> "symlink"
fileBrowserUnixSocketAttr :: AttrName
fileBrowserUnixSocketAttr = fileBrowserAttr <> "unixSocket"
fileBrowserSelectedAttr :: AttrName
fileBrowserSelectedAttr = fileBrowserAttr <> "selected"
fileTypeMatch :: [FileType] -> FileInfo -> Bool
fileTypeMatch tys i = maybe False (`elem` tys) $ fileInfoFileType i
fileExtensionMatch :: String -> FileInfo -> Bool
fileExtensionMatch ext i =
('.' : (toLower <$> ext)) `isSuffixOf` (toLower <$> fileInfoFilename i)