mywork-1.0.1.0: lib/Panes/FileMgr.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Panes.FileMgr
(
FileMgrPane
, FileMgrOps(..)
, initFileMgr
, showFileMgr
, isFileMgrActive
, fileMgrReadProjectsFile
, unsavedChanges
, fileMgrNotices
)
where
import Brick hiding ( Location )
import Brick.Panes
import Brick.Widgets.Center
import qualified Brick.Widgets.Core as BC
import Brick.Widgets.FileBrowser
import qualified Control.Exception as X
import Control.Lens
import Control.Monad ( unless )
import Control.Monad.IO.Class ( MonadIO, liftIO )
import Data.Aeson ( eitherDecode, encode )
import qualified Data.ByteString.Lazy as BS
import Data.Maybe ( catMaybes )
import Data.Maybe ( isJust )
import qualified Data.Sequence as Seq
import qualified Graphics.Vty as Vty
import Path ( Path, Abs, File, (</>), parseAbsFile
, relfile, reldir, parent, toFilePath )
import Path.IO ( createDirIfMissing, doesFileExist
, XdgDirectory(XdgData), getXdgDir )
import Defs
import Defs.JSON ()
import Defs.Static
import Panes.Common.Attrs
import Sync
data FileMgrPane
data FileMgrOps = AckProjectChanges
| UpdProject (Maybe ProjectName) Project -- add/replace project
| DelProject ProjectName
| DelLocation ProjectName LocationSpec
| DelNote ProjectName LocationSpec NoteTitle
instance Pane WName MyWorkEvent FileMgrPane where
data (PaneState FileMgrPane MyWorkEvent) =
FB { fB :: Maybe (FileBrowser WName)
-- ^ A Nothing value indicates the modal is not currently active
, myProjects :: Projects
-- ^ Current loaded set of projects
, myProjFile :: Maybe (Path Abs File)
, projsChanged :: Either Confirm Bool
-- ^ True when myProjects has been updated; clear this via updatePane
, unsavedChanges :: Bool
-- ^ True when myProjects has been changed since the last load
, errMsg :: String
-- ^ Internal error message to display in the FileMgr modal
, fmgrMsgs :: [ Widget WName ]
-- ^ Messages to display when the FileMgr modal exits
}
type (DrawConstraints FileMgrPane s WName) = ( HasFocus s WName )
type (EventConstraints FileMgrPane e) = ( HasFocus e WName )
initPaneState _ = FB Nothing (Projects mempty) Nothing (Right False)
False "" mempty
drawPane ps gs = drawFB ps gs <$> fB ps
focusable _ ps = case fB ps of
Nothing -> mempty
Just _ -> Seq.fromList $ catMaybes
[
Just $ WName "FMgr:Browser"
, const (WName "FMgr:SaveBtn") <$> myProjFile ps
, if isDirSelected ps
then Nothing
else Just $ WName "FMgr:SaveAsBtn"
]
handlePaneEvent bs ev ts =
let isSearching = maybe False fileBrowserIsSearching (ts^.fBrowser)
in case ev of
Vty.EvKey Vty.KEsc [] | not isSearching -> return $ ts & fBrowser .~ Nothing
Vty.EvKey (Vty.KChar 's') [Vty.MCtrl] -> do
ts' <- case myProjFile ts of
Just fp -> fileMgrSaveProjectsFile fp ts
Nothing ->
-- Ctrl-S dismisses the FileMgr window, even on failure
return ts
return $ ts'
& exitMsgsL <>~ (if null (ts ^. errMsgL) then []
else [ withAttr a'Error $ str $ ts ^. errMsgL])
& fBrowser .~ Nothing
_ -> case bs^.getFocus of
Focused (Just (WName "FMgr:Browser")) -> handleFileLoadEvent ev ts
Focused (Just (WName "FMgr:SaveAsBtn")) -> handleFileSaveEvent ev ts
Focused (Just (WName "FMgr:SaveBtn")) ->
case myProjFile ts of
Just fp -> fileMgrSaveProjectsFile fp ts
Nothing -> return ts
_ -> return ts
type (UpdateType FileMgrPane) = FileMgrOps
updatePane = \case
AckProjectChanges ->
\ps -> ps { projsChanged = Right False, fmgrMsgs = mempty }
UpdProject mbOldNm prj ->
(myProjectsL %~ updateProject mbOldNm prj)
. (projsChangedL .~ Right True)
. (unsavedChangesL .~ True)
DelProject pname ->
(myProjectsL %~ Projects . filter ((/= pname) . view projNameL) . projects)
. (projsChangedL .~ Right True)
. (unsavedChangesL .~ True)
DelLocation pname locn ->
let rmvLoc p = if p ^. projNameL == pname
then p & locationsL %~ filter (not . thisLoc)
else p
thisLoc = ((== locn) . view locationL)
in (myProjectsL %~ Projects . fmap rmvLoc . projects)
. (projsChangedL .~ Right True)
. (unsavedChangesL .~ True)
DelNote pname locn nt ->
let rmvNote p = if p ^. projNameL == pname
then p & locationsL %~ fmap rmvNote'
else p
rmvNote' l = if l ^. locationL == locn
then l & notesL %~ filter (not . thisNote)
else l
thisNote = ((== nt) . noteTitle)
in (myProjectsL %~ Projects . fmap rmvNote . projects)
. (projsChangedL .~ Right True)
. (unsavedChangesL .~ True)
fBrowser :: Lens' (PaneState FileMgrPane MyWorkEvent) (Maybe (FileBrowser WName))
fBrowser f ps = (\n -> ps { fB = n }) <$> f (fB ps)
myProjectsL :: Lens' (PaneState FileMgrPane MyWorkEvent) Projects
myProjectsL f wc = (\n -> wc { myProjects = n }) <$> f (myProjects wc)
myProjFileL :: Lens' (PaneState FileMgrPane MyWorkEvent) (Maybe (Path Abs File))
myProjFileL f wc = (\n -> wc { myProjFile = n }) <$> f (myProjFile wc)
projsChangedL :: Lens' (PaneState FileMgrPane MyWorkEvent) (Either Confirm Bool)
projsChangedL f wc = (\n -> wc { projsChanged = n }) <$> f (projsChanged wc)
unsavedChangesL :: Lens' (PaneState FileMgrPane MyWorkEvent) Bool
unsavedChangesL = lens unsavedChanges (\wc n -> wc { unsavedChanges = n })
errMsgL :: Lens' (PaneState FileMgrPane MyWorkEvent) String
errMsgL f wc = (\n -> wc { errMsg = n }) <$> f (errMsg wc)
exitMsgsL :: Lens' (PaneState FileMgrPane MyWorkEvent) [ Widget WName ]
exitMsgsL f wc = (\n -> wc { fmgrMsgs = n }) <$> f (fmgrMsgs wc)
isFileMgrActive :: PaneState FileMgrPane MyWorkEvent -> Bool
isFileMgrActive = isJust . fB
fileMgrNotices :: Lens' (PaneState FileMgrPane MyWorkEvent) [ Widget WName ]
fileMgrNotices = exitMsgsL
isDirSelected :: PaneState FileMgrPane MyWorkEvent -> Bool
isDirSelected ps =
case fileBrowserCursor =<< fB ps of
Just fi ->
case fileStatusFileType <$> fileInfoFileStatus fi of
Right (Just Directory) -> True
Right (Just SymbolicLink) -> case fileInfoLinkTargetType fi of
Just Directory -> True
_ -> False
_ -> False
Nothing -> False
instance ( PanelOps FileMgrPane WName MyWorkEvent panes MyWorkCore
, HasProjects (PaneState FileMgrPane MyWorkEvent)
)
=> HasProjects (Panel WName MyWorkEvent MyWorkCore panes) where
getProjects = getProjects . view (onPane @FileMgrPane)
instance HasProjects (PaneState FileMgrPane MyWorkEvent) where
getProjects ps = (projsChanged ps, myProjects ps)
drawFB :: DrawConstraints FileMgrPane drawstate WName
=> PaneState FileMgrPane MyWorkEvent
-> drawstate -> FileBrowser WName -> Widget WName
drawFB ps ds b =
let width = 70
fcsd = ds^.getFocus.to focused
browserFocused = fcsd == Just (WName "FMgr:Browser")
isDir = isDirSelected ps
browserPane fb =
vLimitPercent 55 $ hLimitPercent width
$ titledB browserFocused "Choose a file"
$ renderFileBrowser browserFocused fb
helpPane =
let esact = if browserFocused
then if isDir then "change directory" else "load file"
else "save"
in padTop (BC.Pad 1) $ hLimitPercent width $ vBox
[ hCenter $ txt "/: search, Ctrl-C or Esc: cancel search"
, hCenter $ txt $ "Enter/Space: " <> esact
, hCenter $ txt "TAB: select Save/Load options"
, hCenter $ txt "ESC: quit"
]
errDisplay fb = case fileBrowserException fb of
Nothing -> if null $ errMsg ps
then emptyWidget
else hLimitPercent width
$ withDefAttr a'Error
$ strWrap $ errMsg ps
Just e -> hLimitPercent width
$ withDefAttr a'Error
$ strWrap
$ X.displayException e
savePane = padTop (Pad 1)
$ vBox
[
let a = if fcsd == Just (WName "FMgr:SaveBtn")
then withAttr a'Selected
else if null f then withAttr a'Disabled else id
f = myProjFile ps
btxt = case f of
Nothing -> " "
Just x -> "[SAVE] " <> show x
in a $ str btxt
, if isDir
then withAttr a'Disabled $ str "[SAVE]"
else let a = if fcsd == Just (WName "FMgr:SaveAsBtn")
then withAttr a'Selected
else id
btxt =
case fileBrowserCursor =<< fB ps of
Just fi -> "[SAVE] " <> fileInfoFilePath fi
Nothing -> "[SAVE]"
in a $ str btxt
]
in centerLayer (browserPane b <=> errDisplay b <=> savePane <=> helpPane)
handleFileLoadEvent :: Vty.Event
-> PaneState FileMgrPane MyWorkEvent
-> EventM WName es (PaneState FileMgrPane MyWorkEvent)
handleFileLoadEvent ev ts =
case ts^.fBrowser of
Just fb -> do
b <- nestEventM' fb $ handleFileBrowserEvent ev
let selectFile =
case fileBrowserCursor b of
Nothing -> return $ ts & fBrowser .~ Just b -- navigation
Just f ->
case parseAbsFile $ fileInfoFilePath f of
Just fp -> if unsavedChanges ts
then let msg = ConfirmLoad fp
in return $ ts
& projsChangedL .~ Left msg
& fBrowser .~ Nothing
else (fBrowser .~ Nothing)
<$> fileMgrReadProjectsFile fp ts
Nothing ->
-- Current selection is a directory; cannot load a directory,
-- so just allow the fBrowser to change to that directory.
return $ ts & fBrowser .~ Just b
case ev of
Vty.EvKey Vty.KEnter [] -> selectFile
Vty.EvKey (Vty.KChar ' ') [] -> selectFile -- override filebrowser's default multi-select ability
_ -> return $ ts & fBrowser .~ Just b & errMsgL .~ ""
Nothing -> return ts -- shouldn't happen
handleFileSaveEvent :: Vty.Event
-> PaneState FileMgrPane MyWorkEvent
-> EventM WName es (PaneState FileMgrPane MyWorkEvent)
handleFileSaveEvent ev ts =
case fileBrowserCursor =<< ts^.fBrowser of
Nothing -> return ts
Just f ->
case ev of
Vty.EvKey Vty.KEnter [] -> doSave f
Vty.EvKey (Vty.KChar ' ') [] -> doSave f
_ -> return ts
where
doSave f = case parseAbsFile $ fileInfoFilePath f of
Just fp -> fileMgrSaveProjectsFile fp ts
Nothing -> return $ ts & errMsgL .~ dirErr
dirErr = "Cannot save to a directory: please select a file"
ensureDefaultProjectFile :: IO (Path Abs File)
ensureDefaultProjectFile = do
dataDir <- getXdgDir XdgData $ Just [reldir|mywork|]
createDirIfMissing True dataDir
let pFile = dataDir </> [relfile|projects.json|]
e <- doesFileExist pFile
unless e $ BS.writeFile (toFilePath pFile) ""
return pFile
-- | Called to initialize the FileMgr at startup. Reads the default Projects
-- file.
initFileMgr :: PaneState FileMgrPane MyWorkEvent
-> IO (PaneState FileMgrPane MyWorkEvent)
initFileMgr ps = do
f <- ensureDefaultProjectFile
ps' <- fileMgrReadProjectsFile f ps
let e = ps' ^. errMsgL
unless (null e) $ error e
return ps'
readProjectsFile :: MonadIO m => Path Abs File -> m (Either String Projects)
readProjectsFile fp = do
newprjs <- eitherDecode <$> liftIO (BS.readFile $ toFilePath fp)
case newprjs of
Right prjs ->
Right . Projects <$> mapM (syncProject . hydrate) (projects prjs)
Left e -> return $ Left e
fileMgrReadProjectsFile :: MonadIO m
=> Path Abs File
-> PaneState FileMgrPane MyWorkEvent
-> m (PaneState FileMgrPane MyWorkEvent)
fileMgrReadProjectsFile fp ps = readProjectsFile fp >>= \case
Left er -> return $ ps & errMsgL .~ er
Right prjs -> return $ ps
& myProjectsL .~ prjs
& myProjFileL .~ Just fp
& projsChangedL .~ Right True
& unsavedChangesL .~ False
& exitMsgsL <>~ [ withAttr a'Notice $ str $ "Loaded " <> show fp ]
fileMgrSaveProjectsFile :: MonadIO m
=> Path Abs File
-> PaneState FileMgrPane MyWorkEvent
-> m (PaneState FileMgrPane MyWorkEvent)
fileMgrSaveProjectsFile fp ps =
do let prjs = Projects (dehydrate <$> (projects $ myProjects ps))
liftIO $ BS.writeFile (toFilePath fp) (encode prjs)
return $ ps
& fBrowser .~ Nothing
& myProjFileL .~ Just fp
& unsavedChangesL .~ False
& exitMsgsL <>~ [ withAttr a'Notice $ str $ "Wrote " <> show fp ]
-- | Called to display the FileMgr modal pane to allow the user to Load or Save.
showFileMgr :: PaneState FileMgrPane MyWorkEvent
-> IO (PaneState FileMgrPane MyWorkEvent)
showFileMgr prev =
case prev ^. fBrowser of
Just _ -> return prev
Nothing -> do
let n = WName "FMgr:Browser"
dataDir <- parent <$> ensureDefaultProjectFile
fb <- newFileBrowser selectNonDirectories n (Just $ toFilePath dataDir)
return $ prev & fBrowser .~ Just fb
----------------------------------------------------------------------