packages feed

clifm-0.3.1.0: src/Widgets/Prompt.hs

module Widgets.Prompt where
import Types
import Widgets.Tab
import Widgets.Menu

import Data.Monoid ((<>))
import Data.Functor (($>))
import Control.Monad(when,forM_)
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 Brick.Widgets.Edit (Editor, editor, renderEditor, getEditContents, handleEditorEvent)
import Brick.BChan (BChan, writeBChan)
import Graphics.Vty (Event(EvKey), Key(..))
import Data.Time.Format (formatTime, defaultTimeLocale)
import System.FilePath (isValid, takeDirectory, (</>), takeFileName)
import System.Directory (doesFileExist, doesDirectoryExist, createDirectory, renameFile,
  copyFileWithMetadata, renameFile, removeFile, removeDirectoryRecursive, getDirectoryContents,
  readable, writable, executable, searchable)

data Prompt = Prompt {originTab :: Tab, originPane :: PaneName, action :: PromptAction} deriving Show
type PathEditor = Editor FilePath Name
data PromptAction = Copy Entry FilePath | Cut Entry FilePath | Rename PathEditor Entry |
  Delete Entry | Mkdir PathEditor FilePath | Touch PathEditor FilePath |
  GoTo PathEditor | Search PathEditor FilePath | DisplayInfo EntryInfo |
  DisplayError String | Performing String ThreadId

instance Show PromptAction 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 "

-- creation functions
emptyPathEditor :: PathEditor
emptyPathEditor = makePathEditor []

makePathEditor :: FilePath -> PathEditor
makePathEditor = editor PromptEditor (Just 1)

makePastePrompt :: Clipboard -> Tab -> PaneName -> Prompt
makePastePrompt c tab pName = Prompt tab pName $ case (c, tab) of
  (EmptyBoard, _) -> DisplayError "The clipboard is empty"
  (_, EmptyTab) -> DisplayError "You cannot paste into an empty tab"
  (_, SearchTab {}) -> DisplayError "You cannot paste into a search tab"
  (CopyBoard {fromEntry = entry}, DirTab{tabPath = path}) -> Copy entry path
  (CutBoard {fromEntry = entry}, DirTab{tabPath = path}) -> Cut entry path

makeGoToPrompt :: Tab -> PaneName -> Prompt
makeGoToPrompt tab pName = Prompt tab pName $ GoTo emptyPathEditor

makeRenamePrompt :: Tab -> PaneName -> Prompt
makeRenamePrompt = withSelectedEntry (\en -> Rename (editorFromEntry en) en)
  where editorFromEntry = makePathEditor . takeFileName . entryPath

makeDeletePrompt :: Tab -> PaneName -> Prompt
makeDeletePrompt = withSelectedEntry Delete

makeMkdirPrompt :: Tab -> PaneName -> Prompt
makeMkdirPrompt = withDirTabPath (Mkdir emptyPathEditor)

makeTouchPrompt :: Tab -> PaneName -> Prompt
makeTouchPrompt = withDirTabPath (Touch emptyPathEditor)

makeDisplayInfoPrompt :: Tab -> PaneName -> Prompt
makeDisplayInfoPrompt = withSelectedEntry (DisplayInfo . entryInfo)

makeSearchPrompt :: Tab -> PaneName -> Prompt
makeSearchPrompt = withDirTabPath (Search emptyPathEditor)

withSelectedEntry :: (Entry -> PromptAction) -> Tab -> PaneName -> Prompt
withSelectedEntry func tab pName = Prompt tab pName $ case selectedEntry tab of
  Just entry -> func entry
  _ -> DisplayError "This tab does not have a selected entry"

withDirTabPath :: (FilePath -> PromptAction) -> Tab -> PaneName -> Prompt
withDirTabPath func tab pName = Prompt tab pName $ case tab of
   DirTab {tabPath = path} -> func path
   _ -> DisplayError "This tab does not represent a directory"

-- rendering functions
renderPrompt :: Prompt -> Widget Name
renderPrompt prompt = centerLayer . box $ vBox [body, hBorder, footer]
  where
    box = withDefAttr promptAttr . borderWithLabel (str . show $ action prompt) . hLimit 70
    body = padLeftRight 2 . padTopBottom 1 $ renderBody prompt
    footer = hCenter . renderFooter $ action prompt

renderBody :: Prompt -> Widget Name
renderBody pr = vBox $ case action pr of
  Copy en path -> disclaimer : map strWrap [tellEntry en <> " will be copied from:", takeDirectory $ entryPath en, "to: ", path]
  Cut en path -> disclaimer : map strWrap [tellEntry en <> " will be moved from:", takeDirectory $ entryPath 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 info -> map strWrap . (displaySize info :) $ displayPerms info ++ displayTimes info
  DisplayError msg -> [str "Whoops, this went wrong:", withDefAttr errorAttr $ strWrap msg]
  Performing name _ -> [str $ "Performing" ++ name, str "Please wait"]

displaySize :: EntryInfo -> String
displaySize info = "Size: " ++ show (entrySize info) ++ " Bytes (" ++ shortEntrySize info ++ ")"

displayPerms :: EntryInfo -> [String]
displayPerms info = case entryPerms info 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 :: EntryInfo -> [String]
displayTimes info = case entryTimes info 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 -> String
tellEntry e = case e of
  DirEntry {entryName = name} -> "The directory " <> name <> " (and all it's content)"
  FileEntry {entryName = name} -> "The file " <> name

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 :: PathEditor -> [Widget Name]
renderValidatedEditor e = [renderEditor (str . unlines) True e, validLine]
  where
    validLine = if isValid $ getEditLine e
      then str " "
      else withDefAttr errorAttr $ str " ^ invalid filepath!"

renderFooter :: PromptAction -> 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 functions
handlePromptEvent :: BrickEvent Name (ThreadEvent Tab) -> Prompt -> BChan (ThreadEvent Tab) -> EventM Name (Either Prompt Tab)
handlePromptEvent (AppEvent ev) pr _ = case ev of
  ThreadError err -> return $ Left pr {action = DisplayError err}
  ThreadSuccess tab -> return $ Right tab
  ThreadClosed -> return . Right $ originTab pr
handlePromptEvent (VtyEvent ev) pr eChan = case ev of
  EvKey KEsc [] -> liftIO $ exitPrompt pr
  EvKey KEnter [] -> liftIO $ performAction pr eChan
  _ -> Left . Prompt (originTab pr) (originPane pr) <$> handleActionEditor ev (action pr)
handlePromptEvent _ pr _ = return $ Left pr

exitPrompt :: Prompt -> IO (Either Prompt Tab)
exitPrompt pr = case action pr of
  Performing name tId -> killThread tId $> Left pr -- returns the same prompt because the actual exiting will happen because of the exception that killThread raises
  _ -> return . Right $ originTab pr

-- gets to decide if the action will be processed in a different thread or not
performAction :: Prompt -> BChan (ThreadEvent Tab) -> IO (Either Prompt Tab) --
performAction pr eChan = case 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
  _ -> tryProcessAction pr

processThreaded :: Prompt -> BChan (ThreadEvent Tab) -> IO Prompt
processThreaded pr eChan = do
  tId <- forkFinally (processAction pr) (reportResult eChan)
  return $ pr {action = Performing (show $ action pr) tId}

reportResult :: BChan (ThreadEvent Tab) -> Either SomeException Tab -> IO ()
reportResult eChan res = writeBChan eChan $ case res of
  Left e -> endingEvent e
  Right tabRes -> ThreadSuccess tabRes

endingEvent :: SomeException -> ThreadEvent Tab
endingEvent e = case (fromException e :: Maybe AsyncException) of
  Just ThreadKilled -> ThreadClosed
  _ -> ThreadError $ displayException e

tryProcessAction :: Prompt -> IO (Either Prompt Tab)
tryProcessAction pr = do
  result <- (try $ processAction pr) :: IO (Either SomeException Tab)
  return $ case result of
    Left e -> Left $ pr {action = DisplayError $ displayException e}
    Right tabRes -> Right tabRes

processAction :: Prompt -> IO Tab
processAction Prompt {originTab = tab, originPane = pName, action = act} = case act of
  Copy FileEntry {entryPath = ePath} path -> copyFileWithMetadata ePath (path </> takeFileName ePath) *> reload pName tab
  Copy DirEntry {entryPath = ePath} path -> copyDirectoryRecursive ePath (path </> takeFileName ePath) *> reload pName tab
  Cut FileEntry {entryPath = ePath} path -> moveFileWithMetadata ePath (path </> takeFileName ePath) *> reload pName tab
  Cut DirEntry {entryPath = ePath} path -> moveDirectoryRecursive ePath (path </> takeFileName ePath) *> reload pName tab
  Rename edit FileEntry {entryPath = ePath} -> renameFile ePath (takeDirectory ePath </> getEditLine edit) *> reload pName tab
  Rename edit DirEntry {entryPath = ePath} -> moveDirectoryRecursive ePath (takeDirectory ePath </> getEditLine edit) *> reload pName tab
  Delete FileEntry {entryPath = ePath} -> removeFile ePath *> reload pName tab
  Delete DirEntry {entryPath = ePath} -> removeDirectoryRecursive ePath *> reload pName tab
  Mkdir edit path -> createDirectory (path </> getEditLine edit) *> reload pName tab
  Touch edit path -> writeFile (path </> getEditLine edit) "" *> reload pName tab
  GoTo edit -> processGoTo pName $ getEditLine edit
  Search edit path -> makeSearchTab pName path $ getEditLine edit
  _ -> return tab

processGoTo :: PaneName -> FilePath -> IO 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 makeDirTab pName path

handleActionEditor :: Event -> PromptAction -> EventM Name PromptAction
handleActionEditor ev act = case act of
  Rename edit en -> (`Rename` en) <$> handleEditorEvent ev edit
  Mkdir edit path -> (`Mkdir` path) <$> handleEditorEvent ev edit
  Touch edit path -> (`Touch` path) <$> handleEditorEvent ev edit
  GoTo edit -> GoTo <$> handleEditorEvent ev edit
  Search edit path -> (`Search` path) <$> handleEditorEvent ev edit
  _ -> return act

-- utility functions
getEditLine :: PathEditor -> String
getEditLine = head . getEditContents

-- files functions not covered by System.Directory nor System.FilePath
moveFileWithMetadata :: FilePath -> FilePath -> IO ()
moveFileWithMetadata o d = do
  copyFileWithMetadata o d
  removeFile o

moveDirectoryRecursive :: FilePath -> FilePath -> IO ()
moveDirectoryRecursive o d = do
  copyDirectoryRecursive o d
  removeDirectoryRecursive o

-- taken from https://stackoverflow.com/questions/6807025/what-is-the-haskell-way-to-copy-a-directory
copyDirectoryRecursive ::  FilePath -> FilePath -> IO ()
copyDirectoryRecursive src dst = do
  whenM (not <$> doesDirectoryExist src) $ throw (userError "source does not exist")
  whenM (doesFileOrDirectoryExist dst) $ throw (userError "destination already exists")
  createDirectory dst
  content <- getDirectoryContents src
  let xs = filter (`notElem` [".", ".."]) content
  forM_ xs $ \name -> do
    let srcPath = src </> name
    let dstPath = dst </> name
    isDirectory <- doesDirectoryExist srcPath
    if isDirectory
      then copyDirectoryRecursive srcPath dstPath
      else copyFileWithMetadata srcPath dstPath
  where
    doesFileOrDirectoryExist x = orM [doesDirectoryExist x, doesFileExist x]
    orM xs = or <$> sequence xs
    whenM s r = s >>= flip when r