packages feed

git-brunch-1.8.0: app/GitBrunch.hs

module GitBrunch (main) where

import Brick.Main (halt)
import Brick.Main qualified as M
import Brick.Themes (themeToAttrMap)
import Brick.Types
import Brick.Widgets.Border qualified as B
import Brick.Widgets.Border.Style qualified as BS
import Brick.Widgets.Center qualified as C
import Brick.Widgets.Core
import Brick.Widgets.Dialog qualified as D
import Brick.Widgets.Edit qualified as E
import Brick.Widgets.List qualified as L
import Control.Exception (SomeException, catch)
import Control.Monad
import Control.Monad.Extra (ifM, unlessM)
import Data.Char
import Data.List
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Vector qualified as Vec
import Graphics.Vty hiding (update)
import Lens.Micro (Lens', lens, (%~), (&), (.~), (^.), _Just)
import Lens.Micro.Mtl ((%=), (.=), (?=))
import System.Exit

import Git (Branch (..))
import Git qualified
import Theme

data Name
  = Local
  | Remote
  | Filter
  | DialogConfirm
  | DialogCancel
  deriving (Ord, Eq, Show)

data RemoteName
  = RLocal
  | RRemote
  deriving (Eq)

data GitCommand
  = GitRebase
  | GitMerge
  | GitCheckout
  | GitDeleteBranch
  deriving (Ord, Eq)

data DialogOption
  = Cancel
  | Confirm GitCommand

data State = State
  { _focus :: RemoteName
  , _gitCommand :: GitCommand
  , _branches :: [Branch]
  , _localBranches :: L.List Name Branch
  , _remoteBranches :: L.List Name Branch
  , _dialog :: Maybe (D.Dialog DialogOption Name)
  , _filter :: E.Editor Text Name
  , _isEditingFilter :: Bool
  }

instance Show GitCommand where
  show GitCheckout = "checkout"
  show GitRebase = "rebase"
  show GitMerge = "merge"
  show GitDeleteBranch = "delete"

main :: IO ()
main = do
  branches <- Git.listBranches `catch` gitFailed
  state <- M.defaultMain app $ syncBranchLists emptyState{_branches = branches}
  case selectedBranch state of
    Nothing -> pure ()
    Just branch -> do
      let cmd = _gitCommand state
      let execGit = gitFunction cmd
      exitCode <- execGit branch
      when (exitCode /= ExitSuccess)
        $ die ("Failed to " ++ show cmd ++ ".")
 where
  gitFailed :: SomeException -> IO a
  gitFailed _ = exitFailure
  gitFunction = \case
    GitCheckout -> Git.checkout
    GitRebase -> Git.rebaseInteractive
    GitMerge -> Git.merge
    GitDeleteBranch -> Git.deleteBranch

emptyState :: State
emptyState =
  State
    { _focus = RLocal
    , _gitCommand = GitCheckout
    , _branches = []
    , _localBranches = mkList Local
    , _remoteBranches = mkList Remote
    , _dialog = Nothing
    , _filter = emptyFilter
    , _isEditingFilter = False
    }
 where
  mkList focus = L.list focus Vec.empty rowHeight

emptyFilter :: E.Editor Text Name
emptyFilter = E.editor Filter Nothing ""

app :: M.App State e Name
app =
  M.App
    { M.appDraw = drawApp
    , M.appChooseCursor = M.showFirstCursor
    , M.appHandleEvent = appHandleEvent
    , M.appStartEvent = pure ()
    , M.appAttrMap = const $ themeToAttrMap theme
    }

drawApp :: State -> [Widget Name]
drawApp state =
  drawDialog state : [C.vCenter $ padAll 1 $ maxWidth 200 $ vBox content]
 where
  content = [branchLists, filterEdit, padding, instructions]
  padding = str " "
  maxWidth w = C.hCenter . hLimit w
  toBranchList r lens' =
    let isActive = state ^. focusL == r && not (_isEditingFilter state)
     in state ^. lens' & drawBranchList isActive
  filterEdit = if _isEditingFilter state then drawFilter state else emptyWidget
  branchLists =
    hBox
      [ C.hCenter $ toBranchList RLocal localBranchesL
      , str " "
      , C.hCenter $ toBranchList RRemote remoteBranchesL
      ]
  instructions =
    maxWidth 100
      $ hBox
        [ drawInstruction "Enter" "checkout"
        , drawInstruction "/" "filter"
        , drawInstruction "F" "fetch"
        , drawInstruction "R" "rebase"
        , drawInstruction "M" "merge"
        , drawInstruction "D" "delete"
        ]

drawFilter :: State -> Widget Name
drawFilter state =
  withBorderStyle BS.unicodeBold $ B.border $ vLimit 1 $ label <+> editor
 where
  editor = E.renderEditor (txt . T.unlines) True (state ^. filterL)
  label = str " Filter: "

drawDialog :: State -> Widget Name
drawDialog state = case _dialog state of
  Nothing -> emptyWidget
  Just dialog -> D.renderDialog dialog $ C.hCenter $ padAll 1 content
   where
    branch = maybe "" show $ selectedBranch state
    action = show (_gitCommand state)
    content =
      str "Really "
        <+> withAttr attrUnder (str action)
        <+> str " branch "
        <+> withAttr attrBold (str branch)
        <+> str "?"

drawBranchList :: Bool -> L.List Name Branch -> Widget Name
drawBranchList hasFocus list =
  withBorderStyle BS.unicodeBold
    $ B.borderWithLabel (drawTitle list)
    $ L.renderList drawListElement hasFocus list
 where
  attr = withAttr $ if hasFocus then attrTitleFocus else attrTitle
  drawTitle = attr . str . map toUpper . show . L.listName

drawListElement :: Bool -> Branch -> Widget Name
drawListElement isListFocussed branch =
  maxPadding $ highlight branch $ str $ " " <> show branch
 where
  maxPadding = if isListFocussed then padRight Max else id
  highlight (BranchCurrent _) = withAttr attrBranchCurrent
  highlight (BranchWorktree _) = withAttr attrBranchWorktree
  highlight b | Git.isCommonBranch b = withAttr attrBranchCommon
  highlight _ = id

drawInstruction :: Text -> Text -> Widget n
drawInstruction keys action =
  withAttr attrKey (txt keys)
    <+> txt " to "
    <+> withAttr attrBold (txt action)
    & C.hCenter

appHandleEvent :: BrickEvent Name e -> EventM Name State ()
appHandleEvent (VtyEvent e)
  | isQuitEvent e = quit
  | otherwise = do
      dialog <- gets _dialog
      if isJust dialog
        then appHandleEventDialog e
        else appHandleEventMain e
 where
  isQuitEvent (EvKey (KChar 'c') [MCtrl]) = True
  isQuitEvent (EvKey (KChar 'd') [MCtrl]) = True
  isQuitEvent _ = False
appHandleEvent _ = pure ()

appHandleEventMain :: Event -> EventM Name State ()
appHandleEventMain e =
  let
    event = lowerKey e
    endWithCheckout = gitCommandL .= GitCheckout >> halt
    endWithRebase = gitCommandL .= GitRebase >> halt
    endWithMerge = gitCommandL .= GitMerge >> halt
    resetFilter = filterL .~ emptyFilter
    showFilter = isEditingFilterL .~ True
    hideFilter = isEditingFilterL .~ False
    startEditingFilter = modify (showFilter . resetFilter)
    cancelEditingFilter = modify (hideFilter . resetFilter)
    stopEditingFilter = modify hideFilter

    confirmDelete :: Maybe Branch -> EventM Name State ()
    confirmDelete (Just (BranchCurrent _)) = pure ()
    confirmDelete (Just (BranchWorktree _)) = pure ()
    confirmDelete (Just _) = do
      gitCommandL .= GitDeleteBranch
      dialogL ?= createDialog GitDeleteBranch
    confirmDelete Nothing = pure ()

    fetch = do
      state <- get
      M.suspendAndResume $ do
        branches <- fetchBranches
        pure $ updateBranches branches state

    handleDefault :: EventM Name State ()
    handleDefault = case event of
      EvKey KEsc [] -> quit
      EvKey (KChar 'q') [] -> quit
      EvKey (KChar '/') [] -> startEditingFilter
      EvKey (KChar 'f') [MCtrl] -> startEditingFilter
      EvKey (KChar 'd') [] -> confirmDelete =<< gets selectedBranch
      EvKey KEnter [] -> endWithCheckout
      EvKey (KChar 'c') [] -> endWithCheckout
      EvKey (KChar 'r') [] -> endWithRebase
      EvKey (KChar 'm') [] -> endWithMerge
      EvKey KLeft [] -> focusBranches RLocal
      EvKey (KChar 'h') [] -> focusBranches RLocal
      EvKey KRight [] -> focusBranches RRemote
      EvKey (KChar 'l') [] -> focusBranches RRemote
      EvKey (KChar 'f') [] -> fetch
      _ -> zoom focussedBranchesL $ L.handleListEventVi L.handleListEvent e

    handleEditingFilter :: EventM Name State ()
    handleEditingFilter = do
      case event of
        EvKey KEsc [] -> cancelEditingFilter
        EvKey KEnter [] -> stopEditingFilter
        EvKey KUp [] -> stopEditingFilter
        EvKey KDown [] -> stopEditingFilter
        _ -> zoom filterL $ E.handleEditorEvent (VtyEvent e)
      modify syncBranchLists
   in
    ifM
      (gets _isEditingFilter)
      handleEditingFilter
      handleDefault

appHandleEventDialog :: Event -> EventM Name State ()
appHandleEventDialog e =
  let
    cancelDialog = do
      dialogL .= Nothing
      gitCommandL .= GitCheckout

    confirmDialog cmd =
      if cmd == GitDeleteBranch
        then do
          dialogL .= Nothing
          gitCommandL .= GitCheckout
          state <- get
          case selectedBranch state of
            Nothing -> pure ()
            Just branch -> M.suspendAndResume $ do
              exitCode <- Git.deleteBranch branch
              if exitCode == ExitSuccess
                then do
                  branches <- Git.listBranches
                  pure $ syncBranchLists state{_branches = branches}
                else do
                  T.putStrLn $ "Failed to delete branch " <> Git.fullBranchName branch
                  T.putStrLn "Press Enter to continue..."
                  void getLine
                  pure state
        else do
          dialogL .= Nothing
          gitCommandL .= cmd
          halt
   in
    case vimifiedKey e of
      EvKey KEnter [] -> do
        dialog <- gets _dialog
        case D.dialogSelection =<< dialog of
          Just (DialogConfirm, Confirm cmd) -> confirmDialog cmd
          Just (DialogCancel, Cancel) -> cancelDialog
          _ -> pure ()
      EvKey KEsc [] -> cancelDialog
      EvKey (KChar 'q') [] -> cancelDialog
      ev -> zoom (dialogL . _Just) $ D.handleDialogEvent ev

quit :: EventM n State ()
quit = focussedBranchesL %= L.listClear >> halt

focusBranches :: RemoteName -> EventM Name State ()
focusBranches target = do
  let isAlreadyFocussed = (target ==) <$> gets _focus
  unlessM isAlreadyFocussed $ do
    offsetDiff <- listOffsetDiff target
    modify (changeList . syncPosition offsetDiff)
 where
  changeList = focusL .~ target
  listIndex state = fromMaybe 0 $ state ^. currentListL . L.listSelectedL
  syncPosition diff state = (targetListL %~ L.listMoveTo (listIndex state - diff)) state
  (currentListL, targetListL) = case target of
    RLocal -> (remoteBranchesL, localBranchesL)
    RRemote -> (localBranchesL, remoteBranchesL)

listOffsetDiff :: RemoteName -> EventM Name State Int
listOffsetDiff target = do
  offLocal <- getOffset Local
  offRemote <- getOffset Remote
  pure
    $ if target == RLocal
      then offRemote - offLocal
      else offLocal - offRemote
 where
  getOffset name = maybe 0 (^. vpTop) <$> M.lookupViewport name

fetchBranches :: IO [Branch]
fetchBranches = do
  T.putStrLn "Fetching branches"
  output <- Git.fetch
  T.putStr output
  Git.listBranches

updateBranches :: [Branch] -> State -> State
updateBranches branches =
  syncBranchLists
    . (branchesL .~ branches)

syncBranchLists :: State -> State
syncBranchLists state =
  state
    & localBranchesL
    .~ mkList Local local
    & remoteBranchesL
    .~ mkList Remote remote
    & focusL
    %~ toggleFocus (local, remote)
 where
  mkList name xs = L.list name (Vec.fromList xs) rowHeight
  filterText = T.toLower $ T.unwords $ E.getEditContents $ _filter state
  isBranchInFilter = T.isInfixOf filterText . Git.fullBranchName
  filteredBranches = filter isBranchInFilter (_branches state)
  (remote, local) = partition Git.isRemoteBranch filteredBranches

toggleFocus :: ([Branch], [Branch]) -> RemoteName -> RemoteName
toggleFocus ([], _ : _) RLocal = RRemote
toggleFocus (_ : _, []) RRemote = RLocal
toggleFocus _ x = x

selectedBranch :: State -> Maybe Branch
selectedBranch state =
  snd <$> L.listSelectedElement (state ^. focussedBranchesL)

createDialog :: GitCommand -> D.Dialog DialogOption Name
createDialog cmd = D.dialog (Just $ str title) (Just (DialogConfirm, choices)) 80
 where
  title = map toUpper $ show cmd
  btnText (x : xs) = toUpper x : xs
  btnText x = x
  choices =
    [ (btnText $ show cmd, DialogConfirm, Confirm cmd)
    , ("Cancel", DialogCancel, Cancel)
    ]

mapKey :: (Char -> Key) -> Event -> Event
mapKey f (EvKey (KChar k) []) = EvKey (f k) []
mapKey _ e = e

lowerKey :: Event -> Event
lowerKey = mapKey (KChar . toLower)

vimifiedKey :: Event -> Event
vimifiedKey = mapKey vimify . lowerKey
 where
  vimify 'h' = KLeft
  vimify 'j' = KRight
  vimify 'k' = KLeft
  vimify 'l' = KRight
  vimify k = KChar k

rowHeight :: Int
rowHeight = 1

-- Lens

focussedBranchesL :: Lens' State (L.List Name Branch)
focussedBranchesL =
  lens (\s -> s ^. branchLens s) (\s bs -> (branchLens s .~ bs) s)
 where
  branchLens s = case s ^. focusL of
    RLocal -> localBranchesL
    RRemote -> remoteBranchesL

localBranchesL :: Lens' State (L.List Name Branch)
localBranchesL = lens _localBranches (\s bs -> s{_localBranches = bs})

remoteBranchesL :: Lens' State (L.List Name Branch)
remoteBranchesL = lens _remoteBranches (\s bs -> s{_remoteBranches = bs})

focusL :: Lens' State RemoteName
focusL = lens _focus (\s f -> s{_focus = f})

filterL :: Lens' State (E.Editor Text Name)
filterL = lens _filter (\s f -> s{_filter = f})

branchesL :: Lens' State [Branch]
branchesL = lens _branches (\s f -> s{_branches = f})

isEditingFilterL :: Lens' State Bool
isEditingFilterL = lens _isEditingFilter (\s f -> s{_isEditingFilter = f})

dialogL :: Lens' State (Maybe (D.Dialog DialogOption Name))
dialogL = lens _dialog (\s v -> s{_dialog = v})

gitCommandL :: Lens' State GitCommand
gitCommandL = lens _gitCommand (\s v -> s{_gitCommand = v})