clifm-0.5.2.1: src/Widgets/Prompt.hs
module Widgets.Prompt where
import Commons
import qualified Widgets.Tab as Tab
import qualified Widgets.Menu as Menu
import qualified Widgets.Entry as Entry
import Control.Lens
import Data.Monoid ((<>))
import Data.Functor (($>))
import Control.Monad(when,forM_)
import Data.Maybe (fromJust)
import Control.Monad.IO.Class (liftIO)
import Control.Concurrent (forkFinally, ThreadId, killThread)
import Control.Exception (try, throw, displayException, SomeException, fromException)
import Control.Exception.Base (AsyncException(ThreadKilled))
import Control.Applicative ((*>), (<$>))
import Brick.Widgets.Core ((<+>), str, strWrap, vBox, hLimit, padLeftRight, padTopBottom, withDefAttr)
import Brick.Widgets.Border (borderWithLabel, hBorder)
import Brick.Types (Widget, EventM, BrickEvent(..))
import Brick.Widgets.Center (centerLayer, hCenter)
import qualified Brick.Widgets.Edit as Edit
import Brick.BChan (BChan, writeBChan)
import Graphics.Vty (Event(EvKey), Key(..))
import Data.Time.Format (formatTime, defaultTimeLocale)
import Conduit
import System.FilePath (isValid, takeDirectory, (</>), takeFileName, makeRelative)
import System.Directory (doesFileExist, doesDirectoryExist, createDirectory, renameFile,
copyFileWithMetadata, removeFile, removeDirectoryRecursive, createDirectoryIfMissing,
readable, writable, executable, searchable)
data Prompt = Prompt {_originTab :: Tab.Tab, _originPane :: PaneName, _action :: Action} deriving Show
type Editor = Edit.Editor FilePath Name
data Action = Copy Entry.Entry FilePath | Cut Entry.Entry FilePath | Rename Editor Entry.Entry |
Delete Entry.Entry | Mkdir Editor FilePath | Touch Editor FilePath |
GoTo Editor | Search Editor FilePath | DisplayInfo Entry.Entry |
DisplayError String | Performing String ThreadId
instance Show Action where
show (Copy _ _) = " Copy "
show (Cut _ _) = " Cut "
show (Rename _ _) = " Rename "
show (Delete _) = " Delete "
show (Mkdir _ _) = " Make Directory "
show (Touch _ _) = " Touch File "
show (GoTo _) = " Go To "
show (DisplayInfo _) = " Entry Info "
show (Search _ _) = " Search "
show (Performing name _) = " Performing" ++ name
show _ = " Error "
-- lenses
originTab :: Lens' Prompt Tab.Tab
originTab = lens _originTab (\prompt x -> prompt {_originTab = x})
originPane :: Lens' Prompt PaneName
originPane = lens _originPane (\prompt x -> prompt {_originPane = x})
action :: Lens' Prompt Action
action = lens _action (\prompt x -> prompt {_action = x})
-- creation
emptyEditor :: Editor
emptyEditor = makeEditor []
makeEditor :: FilePath -> Editor
makeEditor = Edit.editor PromptEditor (Just 1)
paste :: Menu.Clipboard -> Tab.Tab -> PaneName -> Prompt
paste clip tab pName = let
checked
| Menu.isEmpty clip = DisplayError "The clipboard is empty"
| Tab.isEmpty tab = DisplayError "You cannot paste into an empty tab"
| Tab.isSearch tab = DisplayError "You cannot paste into a search tab"
| Menu.isCopy clip = Copy (fromJust $ preview Menu.fromEntry clip) (tab ^. Tab.path)
| Menu.isCut clip = Cut (fromJust $ preview Menu.fromEntry clip) (tab ^. Tab.path)
| otherwise = DisplayError "Cannot paste: unknown error"
in Prompt tab pName checked
goTo :: Tab.Tab -> PaneName -> Prompt
goTo tab pName = Prompt tab pName $ GoTo emptyEditor
rename :: Tab.Tab -> PaneName -> Prompt
rename = withSelectedEntry (\en -> Rename (editorFromEntry en) en)
where editorFromEntry = makeEditor . takeFileName . view Entry.path
delete :: Tab.Tab -> PaneName -> Prompt
delete = withSelectedEntry Delete
mkdir :: Tab.Tab -> PaneName -> Prompt
mkdir = withDirTabPath (Mkdir emptyEditor)
touch :: Tab.Tab -> PaneName -> Prompt
touch = withDirTabPath (Touch emptyEditor)
displayInfo :: Tab.Tab -> PaneName -> Prompt
displayInfo = withSelectedEntry DisplayInfo
search :: Tab.Tab -> PaneName -> Prompt
search = withDirTabPath (Search emptyEditor)
withSelectedEntry :: (Entry.Entry -> Action) -> Tab.Tab -> PaneName -> Prompt
withSelectedEntry func tab pName = Prompt tab pName $ case Tab.selectedEntry tab of
Just entry -> func entry
_ -> DisplayError "This tab does not have a selected entry"
withDirTabPath :: (FilePath -> Action) -> Tab.Tab -> PaneName -> Prompt
withDirTabPath func tab pName = Prompt tab pName $ if Tab.isDir tab
then func $ view Tab.path tab
else DisplayError "This tab does not represent a directory"
-- rendering
render :: Prompt -> Widget Name
render prompt = centerLayer . box $ vBox [body, hBorder, footer]
where
box = withDefAttr promptAttr . borderWithLabel (str . show $ view action prompt) . hLimit 70
body = padLeftRight 2 . padTopBottom 1 $ renderBody prompt
footer = hCenter . renderFooter $ view action prompt
renderBody :: Prompt -> Widget Name
renderBody pr = vBox $ case view action pr of
Copy en path -> disclaimer : map strWrap [tellEntry en <> " will be copied from:", takeDirectory $ view Entry.path en, "to: ", path]
Cut en path -> disclaimer : map strWrap [tellEntry en <> " will be moved from:", takeDirectory $ view Entry.path en, "to: ", path]
Rename edit en -> disclaimer : strWrap (tellEntry en <> " will be renamed to:") : renderValidatedEditor edit
Delete en -> [disclaimer, strWrap $ tellEntry en <> " will be permanently deleted"]
Mkdir edit _ -> str "Directory name:" : renderValidatedEditor edit
Touch edit _ -> str "File name:" : renderValidatedEditor edit
GoTo edit -> str "Directory to open:" : renderValidatedEditor edit
Search edit _ -> str "Search for:" : renderValidatedEditor edit
DisplayInfo en -> map strWrap . (displaySize en :) $ displayPerms en ++ displayTimes en
DisplayError msg -> [str "Whoops, this went wrong:", withDefAttr errorAttr $ strWrap msg]
Performing name _ -> [str $ "Performing" ++ name, str "Please wait"]
displaySize :: Entry.Entry -> String
displaySize entry = case view Entry.size entry of
Entry.Known s -> "Size: " ++ show s ++ " Bytes (" ++ Entry.shortSize (Entry.Known s) ++ ")"
Entry.Unknown -> "Size unknown (unable to read)"
Entry.Calculating -> "Size is being calculated"
Entry.Waiting -> "Size will soon be calculated"
_ -> ""
displayPerms :: Entry.Entry -> [String]
displayPerms entry = case view Entry.perms entry of
Nothing -> [" ", "Permissions unknown", "(could not read them)"]
Just p -> [
" ",
"Is readable: " <> (if readable p then "yes" else "no"),
"Is writable: " <> (if writable p then "yes" else "no"),
"Is executable: " <> (if executable p then "yes" else "no"),
"Is searchable: " <> (if searchable p then "yes" else "no")
]
displayTimes :: Entry.Entry -> [String]
displayTimes entry = case view Entry.times entry of
Nothing -> [" ", "Last access and modification times unknown", "(could not read them)"]
Just (acTm, mdTm) -> [" ", "Last access time:" <> format acTm, "Last modification time:" <> format mdTm]
where format = formatTime defaultTimeLocale " %T %B %-d %Y"
tellEntry :: Entry.Entry -> String
tellEntry entry
| Entry.isDir entry = "The directory " <> name <> " (and all it's content)"
| otherwise = "The file " <> name
where name = view Entry.name entry
disclaimer :: Widget Name
disclaimer = withDefAttr disclaimerAttr $ strWrap "NOTE: this will operate on \
\your file system and may be irreversible, double check it! Also please note \
\that the operation can be stopped, but will not revert what was already done."
renderValidatedEditor :: Editor -> [Widget Name]
renderValidatedEditor e = [Edit.renderEditor (str . unlines) True e, validLine]
where
validLine = if isValid $ editorLine e
then str " "
else withDefAttr errorAttr $ str " ^ invalid filepath!"
renderFooter :: Action -> Widget Name
renderFooter act = case act of
Performing _ _ -> kb "Esc" <+> str " to Cancel. NOTE: will not revert what was already done."
_ -> kb "Enter" <+> str txt <+> kb "Esc" <+> str " to close and go back"
where
kb = withDefAttr keybindAttr . str
txt = case act of
Copy _ _ -> " to copy, "
Cut _ _ -> " to move, "
Rename _ _ -> " to rename, "
Delete _ -> " to delete, "
Mkdir _ _ -> " to make the directory, "
Touch _ _ -> " to touch the file, "
GoTo _ -> " to change directory, "
Search _ _ -> " to search, "
_ -> " or "
-- event-handling
handleEvent :: BrickEvent Name (ThreadEvent Tab.Tab) -> Prompt -> EChan Tab.Tab -> EventM Name (Either Prompt Tab.Tab)
handleEvent (AppEvent ev) pr _ = case ev of
PromptError err -> return . Left $ pr & action .~ DisplayError err
PromptSuccess tab -> return $ Right tab
PromptClosed -> return . Right $ view originTab pr
_ -> return $ Left pr
handleEvent (VtyEvent ev) pr eChan = case ev of
EvKey KEsc [] -> liftIO $ exit pr
EvKey KEnter [] -> liftIO $ performAction pr eChan
_ -> Left <$> (pr & action %%~ handleEditor ev)
handleEvent _ pr _ = return $ Left pr
-- if performing it will return the same prompt, the raised exception will trigger the closing
exit :: Prompt -> IO (Either Prompt Tab.Tab)
exit pr = case view action pr of
Performing name tId -> killThread tId $> Left pr
_ -> return . Right $ view originTab pr
-- gets to decide if the action will be processed in a different thread or not
performAction :: Prompt -> EChan Tab.Tab -> IO (Either Prompt Tab.Tab) --
performAction pr eChan = case view action pr of
Copy _ _ -> Left <$> processThreaded pr eChan
Cut _ _ -> Left <$> processThreaded pr eChan
Rename _ _ -> Left <$> processThreaded pr eChan
Delete _ -> Left <$> processThreaded pr eChan
Search _ _ -> Left <$> processThreaded pr eChan
Performing _ _ -> return $ Left pr -- doesn't really make sense
_ -> processSafe pr
processThreaded :: Prompt -> EChan Tab.Tab -> IO Prompt
processThreaded pr eChan = do
tId <- forkFinally (processUnsafe pr) (reportResult eChan)
return $ pr & action %~ (\act -> Performing (show act) tId)
reportResult :: EChan Tab.Tab -> Either SomeException Tab.Tab -> IO ()
reportResult eChan = writeBChan eChan . either endingEvent PromptSuccess
endingEvent :: SomeException -> ThreadEvent Tab.Tab
endingEvent e = case (fromException e :: Maybe AsyncException) of
Just ThreadKilled -> PromptClosed
_ -> PromptError $ displayException e
processSafe :: Prompt -> IO (Either Prompt Tab.Tab)
processSafe pr = do
result <- (try $ processUnsafe pr) :: IO (Either SomeException Tab.Tab)
return $ case result of
Left e -> Left $ pr & action .~ DisplayError (displayException e)
Right tabRes -> Right tabRes
processUnsafe :: Prompt -> IO Tab.Tab
processUnsafe prompt = case act of
Copy entry path -> processCopy entry path pName tab
Cut entry path -> processCut entry path pName tab
Rename edit entry -> processRename entry (editorLine edit) pName tab
Delete entry -> processDelete entry pName tab
Mkdir edit path -> createDirectory (path </> editorLine edit) *> Tab.reload pName tab
Touch edit path -> writeFile (path </> editorLine edit) "" *> Tab.reload pName tab
GoTo edit -> processGoTo pName $ editorLine edit
Search edit path -> Tab.makeSearchTab pName path $ editorLine edit
_ -> return tab
where
tab = view originTab prompt
pName = view originPane prompt
act = view action prompt
processCopy :: Entry.Entry -> FilePath -> PaneName -> Tab.Tab -> IO Tab.Tab
processCopy entry path pName tab
| Entry.isFile entry = copyFileWithMetadata ePath (path </> takeFileName ePath) *> Tab.reload pName tab
| otherwise = copyDirectoryRecursive ePath (path </> takeFileName ePath) *> Tab.reload pName tab
where ePath = view Entry.path entry
processCut :: Entry.Entry -> FilePath -> PaneName -> Tab.Tab -> IO Tab.Tab
processCut entry path pName tab
| Entry.isFile entry = moveFileWithMetadata ePath (path </> takeFileName ePath) *> Tab.reload pName tab
| otherwise = moveDirectoryRecursive ePath (path </> takeFileName ePath) *> Tab.reload pName tab
where ePath = view Entry.path entry
processRename :: Entry.Entry -> String -> PaneName -> Tab.Tab -> IO Tab.Tab
processRename entry editLine pName tab
| Entry.isFile entry = renameFile ePath (takeDirectory ePath </> editLine) *> Tab.reload pName tab
| otherwise = moveDirectoryRecursive ePath (takeDirectory ePath </> editLine) *> Tab.reload pName tab
where ePath = view Entry.path entry
processDelete :: Entry.Entry -> PaneName -> Tab.Tab -> IO Tab.Tab
processDelete entry pName tab
| Entry.isFile entry = removeFile ePath *> Tab.reload pName tab
| otherwise = removeDirectoryRecursive ePath *> Tab.reload pName tab
where ePath = view Entry.path entry
processGoTo :: PaneName -> FilePath -> IO Tab.Tab
processGoTo pName path = do
isFile <- doesFileExist path
isDir <- doesDirectoryExist path
if isFile || not isDir
then throw . userError $ path <> " does not exist or is not a directory"
else Tab.makeDirTab pName path
handleEditor :: Event -> Action -> EventM Name Action
handleEditor ev act = case act of
Rename edit en -> (`Rename` en) <$> Edit.handleEditorEvent ev edit
Mkdir edit path -> (`Mkdir` path) <$> Edit.handleEditorEvent ev edit
Touch edit path -> (`Touch` path) <$> Edit.handleEditorEvent ev edit
GoTo edit -> GoTo <$> Edit.handleEditorEvent ev edit
Search edit path -> (`Search` path) <$> Edit.handleEditorEvent ev edit
_ -> return act
-- utility
editorLine :: Editor -> String
editorLine = head . Edit.getEditContents
notifySize :: FilePath -> Entry.Size -> Prompt -> Prompt
notifySize path size prompt = prompt & action %~ notifyActionSize path size
notifyActionSize :: FilePath -> Entry.Size -> Action -> Action
notifyActionSize path size act = case act of
DisplayInfo en -> if path == en ^. Entry.path then DisplayInfo (en & Entry.size .~ size) else act
_ -> act
-- files functions not covered by System.Directory nor System.FilePath
moveFileWithMetadata :: FilePath -> FilePath -> IO ()
moveFileWithMetadata src dst = do
copyFileWithMetadata src dst
removeFile src
moveDirectoryRecursive :: FilePath -> FilePath -> IO ()
moveDirectoryRecursive src dst = do
copyDirectoryRecursive src dst
removeDirectoryRecursive src
copyDirectoryRecursive :: FilePath -> FilePath -> IO ()
copyDirectoryRecursive src dst = runConduitRes
$ sourceDirectoryDeep False src
.| mapC (\fPath -> (fPath, dst </> (makeRelative src fPath)))
.| iterMC (liftIO . createDirectoryIfMissing True . takeDirectory . snd)
.| mapM_C (\(srcFile, dstFile) -> liftIO $ copyFileWithMetadata srcFile dstFile)