This commit is contained in:
2026-02-03 12:22:19 -05:00
parent 2c103f7f96
commit 0702d27166
2 changed files with 400 additions and 405 deletions
+279 -316
View File
@@ -1,6 +1,7 @@
(ns lazygitclj.core
"lazygitclj - A lazygit-inspired TUI for git."
(:require [tui.core :as tui :refer [quit]]
(:require [tui.core :as tui]
[tui.events :as ev :refer [key=]]
[lazygitclj.git :as git]
[clojure.string :as str]))
@@ -53,31 +54,6 @@
(str (subs s 0 max-len) "...")
s))
(defn visible-window
"Calculate the visible window of items for scrolling.
Returns {:items visible-items :start-idx start-index :total total-count}
Keeps cursor visible by scrolling the window."
[items cursor max-visible]
(let [total (count items)
max-visible (max 1 max-visible)]
(if (<= total max-visible)
;; All items fit, no scrolling needed
{:items items :start-idx 0 :total total}
;; Need to scroll - calculate window that keeps cursor visible
(let [;; Simple approach: cursor should be in the middle when possible
half (quot max-visible 2)
;; Calculate start index
start-idx (cond
;; Cursor near start - show from beginning
(<= cursor half) 0
;; Cursor near end - show end portion
(>= cursor (- total half)) (- total max-visible)
;; Cursor in middle - center it
:else (- cursor half))
start-idx (max 0 (min start-idx (- total max-visible)))
visible-items (subvec (vec items) start-idx (+ start-idx max-visible))]
{:items visible-items :start-idx start-idx :total total}))))
(defn get-current-diff
"Get the diff for the currently selected item based on panel and cursor."
[model]
@@ -137,496 +113,494 @@
(assoc :message nil)
update-diff))
(defn update-files [model msg]
(defn update-files [{:keys [model event]}]
(let [items (file-items model)
cursor (:cursor model)
item (get items cursor)]
(cond
(tui/key= msg " ")
(key= event \space)
(if item
(do
(if (= (:type item) :staged)
(git/unstage-file (:path item))
(git/stage-file (:path item)))
[(refresh model) nil])
[model nil])
{:model (refresh model)})
{:model model})
(tui/key= msg "a")
(key= event \a)
(if (and (seq (:staged model)) (empty? (:unstaged model)))
;; All files are staged - unstage all
(do
(git/reset-staged)
[(refresh model) nil])
{:model (refresh model)})
;; Some files are unstaged - stage all
(do
(git/stage-all)
[(refresh model) nil]))
{:model (refresh model)}))
(tui/key= msg "d")
(key= event \d)
(if (and item (= (:type item) :unstaged) (not= (:index item) \?))
(do
(git/discard-file (:path item))
[(refresh model) nil])
[(assoc model :message "Can only discard unstaged changes") nil])
{:model (refresh model)})
{:model (assoc model :message "Can only discard unstaged changes")})
;; s: Quick stash all changes
(tui/key= msg "s")
(key= event \s)
(do
(git/stash-all)
[(-> model refresh (assoc :message "Stashed all changes")) nil])
{:model (-> model refresh (assoc :message "Stashed all changes"))})
;; S: Stash options menu
(tui/key= msg "S")
[(assoc model :menu-mode :stash-options) nil]
(key= event \s #{:shift})
{:model (assoc model :menu-mode :stash-options)}
:else
[model nil])))
{:model model})))
(defn update-commits [model msg]
(defn update-commits [{:keys [model event]}]
(let [items (current-items model)
cursor (:cursor model)
item (get (vec items) cursor)
sha (when item (:sha item))]
(cond
;; Space: Checkout commit/reflog entry
(tui/key= msg " ")
(key= event \space)
(if sha
(do
(git/checkout-reflog-entry sha)
[(-> model refresh (assoc :message (str "Checked out " sha))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Checked out " sha)))})
{:model model})
;; g: Reset to this commit
(tui/key= msg "g")
(key= event \g)
(if sha
(do
(git/reset-to-reflog sha)
[(-> model refresh (assoc :message (str "Reset to " sha))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Reset to " sha)))})
{:model model})
;; C: Cherry-pick commit
(tui/key= msg "C")
(key= event \c #{:shift})
(if sha
(do
(git/cherry-pick-commit sha)
[(-> model refresh (assoc :message (str "Cherry-picked " sha))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Cherry-picked " sha)))})
{:model model})
;; t: Revert commit
(tui/key= msg "t")
(key= event \t)
(if sha
(do
(git/revert-commit sha)
[(-> model refresh (assoc :message (str "Reverted " sha))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Reverted " sha)))})
{:model model})
;; r: Reword commit (HEAD only)
(tui/key= msg "r")
(key= event \r)
(if sha
[(assoc model :input-mode :reword :input-buffer "" :input-context sha) nil]
[model nil])
{:model (assoc model :input-mode :reword :input-buffer "" :input-context sha)}
{:model model})
;; y: Copy SHA
(tui/key= msg "y")
(key= event \y)
(if sha
[(assoc model :message (str "SHA: " sha " (not copied - no clipboard support)")) nil]
[model nil])
{:model (assoc model :message (str "SHA: " sha " (not copied - no clipboard support)"))}
{:model model})
:else
[model nil])))
{:model model})))
(defn update-branches [model msg]
(defn update-branches [{:keys [model event]}]
(let [items (current-items model)
cursor (:cursor model)
item (get (vec items) cursor)
tab (:branches-tab model)]
(cond
;; Enter: checkout branch/tag
(tui/key= msg :enter)
(key= event :enter)
(cond
(and (= tab :local) item (not= item (:branch model)))
(do
(git/checkout-branch item)
[(-> model refresh (assoc :message (str "Checked out " item))) nil])
{:model (-> model refresh (assoc :message (str "Checked out " item)))})
(and (= tab :tags) item)
(do
(git/checkout-tag item)
[(-> model refresh (assoc :message (str "Checked out tag " item))) nil])
{:model (-> model refresh (assoc :message (str "Checked out tag " item)))})
:else [model nil])
:else {:model model})
;; n: new branch (local tab only)
(and (tui/key= msg "n") (= tab :local))
[(assoc model :input-mode :new-branch :input-buffer "") nil]
(and (key= event \n) (= tab :local))
{:model (assoc model :input-mode :new-branch :input-buffer "")}
;; d: delete branch (local tab only)
(and (tui/key= msg "d") (= tab :local) item (not= item (:branch model)))
(and (key= event \d) (= tab :local) item (not= item (:branch model)))
(do
(git/delete-branch item)
[(-> model refresh (assoc :message (str "Deleted branch " item))) nil])
{:model (-> model refresh (assoc :message (str "Deleted branch " item)))})
;; R: rename branch (local tab only)
(and (tui/key= msg "R") (= tab :local) item)
[(assoc model :input-mode :rename-branch :input-buffer "" :input-context item) nil]
(and (key= event \r #{:shift}) (= tab :local) item)
{:model (assoc model :input-mode :rename-branch :input-buffer "" :input-context item)}
;; M: merge branch into current
(and (tui/key= msg "M") (= tab :local) item (not= item (:branch model)))
(and (key= event \m #{:shift}) (= tab :local) item (not= item (:branch model)))
(do
(git/merge-branch item)
[(-> model refresh (assoc :message (str "Merged " item))) nil])
{:model (-> model refresh (assoc :message (str "Merged " item)))})
;; f: fast-forward branch
(and (tui/key= msg "f") (= tab :local) item)
(and (key= event \f) (= tab :local) item)
(do
(git/fast-forward-branch item)
[(-> model refresh (assoc :message (str "Fast-forwarded " item))) nil])
{:model (-> model refresh (assoc :message (str "Fast-forwarded " item)))})
;; Tab switching with [ and ]
(tui/key= msg "[")
[(-> model
(update :branches-tab #(case % :local :tags :remotes :local :tags :remotes))
(assoc :cursor 0)
clamp-cursor) nil]
(key= event \[)
{:model (-> model
(update :branches-tab #(case % :local :tags :remotes :local :tags :remotes))
(assoc :cursor 0)
clamp-cursor)}
(tui/key= msg "]")
[(-> model
(update :branches-tab #(case % :local :remotes :remotes :tags :tags :local))
(assoc :cursor 0)
clamp-cursor) nil]
(key= event \])
{:model (-> model
(update :branches-tab #(case % :local :remotes :remotes :tags :tags :local))
(assoc :cursor 0)
clamp-cursor)}
:else
[model nil])))
{:model model})))
(defn update-stash [model msg]
(defn update-stash [{:keys [model event]}]
(let [stashes (:stashes model)
cursor (:cursor model)
stash (get (vec stashes) cursor)
ref (when stash (:ref stash))]
(cond
;; Space: apply stash (keep in list)
(tui/key= msg " ")
(key= event \space)
(if ref
(do
(git/stash-apply ref)
[(-> model refresh (assoc :message (str "Applied " ref))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Applied " ref)))})
{:model model})
;; g: pop stash (apply and remove)
(tui/key= msg "g")
(key= event \g)
(if ref
(do
(git/stash-pop ref)
[(-> model refresh (assoc :message (str "Popped " ref))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Popped " ref)))})
{:model model})
;; d: drop stash
(tui/key= msg "d")
(key= event \d)
(if ref
(do
(git/stash-drop ref)
[(-> model refresh (assoc :message (str "Dropped " ref))) nil])
[model nil])
{:model (-> model refresh (assoc :message (str "Dropped " ref)))})
{:model model})
;; n: new branch from stash
(and (tui/key= msg "n") ref)
[(assoc model :input-mode :stash-branch :input-buffer "" :input-context ref) nil]
(and (key= event \n) ref)
{:model (assoc model :input-mode :stash-branch :input-buffer "" :input-context ref)}
:else
[model nil])))
{:model model})))
(defn update-input-mode [model msg]
(defn update-input-mode [{:keys [model event]}]
(cond
(tui/key= msg :escape)
[(assoc model :input-mode nil :input-buffer "" :input-context nil) nil]
(key= event :escape)
{:model (assoc model :input-mode nil :input-buffer "" :input-context nil)}
(tui/key= msg :enter)
(key= event :enter)
(let [buf (:input-buffer model)
ctx (:input-context model)]
(case (:input-mode model)
:commit
(if (str/blank? buf)
[(assoc model :message "Commit message cannot be empty") nil]
{:model (assoc model :message "Commit message cannot be empty")}
(do
(git/commit buf)
[(-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message "Committed!")) nil]))
{:model (-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message "Committed!"))}))
:new-branch
(if (str/blank? buf)
[(assoc model :message "Branch name cannot be empty") nil]
{:model (assoc model :message "Branch name cannot be empty")}
(do
(git/create-branch buf)
[(-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Created branch " buf))) nil]))
{:model (-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Created branch " buf)))}))
:rename-branch
(if (str/blank? buf)
[(assoc model :message "New name cannot be empty") nil]
{:model (assoc model :message "New name cannot be empty")}
(do
(git/rename-branch ctx buf)
[(-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Renamed " ctx " to " buf))) nil]))
{:model (-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Renamed " ctx " to " buf)))}))
:stash-branch
(if (str/blank? buf)
[(assoc model :message "Branch name cannot be empty") nil]
{:model (assoc model :message "Branch name cannot be empty")}
(do
(git/stash-branch buf ctx)
[(-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Created branch " buf " from stash"))) nil]))
{:model (-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message (str "Created branch " buf " from stash")))}))
:reword
(if (str/blank? buf)
[(assoc model :message "Message cannot be empty") nil]
{:model (assoc model :message "Message cannot be empty")}
(if (git/reword-commit ctx buf)
[(-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message "Rewrote commit message")) nil]
[(assoc model :input-mode nil :input-buffer "" :input-context nil
:message "Can only reword HEAD commit") nil]))
{:model (-> model
(assoc :input-mode nil :input-buffer "" :input-context nil)
refresh
(assoc :message "Rewrote commit message"))}
{:model (assoc model :input-mode nil :input-buffer "" :input-context nil
:message "Can only reword HEAD commit")}))
[model nil]))
{:model model}))
(tui/key= msg :backspace)
[(update model :input-buffer #(if (empty? %) % (subs % 0 (dec (count %))))) nil]
(key= event :backspace)
{:model (update model :input-buffer #(if (empty? %) % (subs % 0 (dec (count %)))))}
;; Character input - key format is [:key {:char \a}]
(and (= (first msg) :key)
(map? (second msg))
(:char (second msg))
(not (:ctrl (second msg)))
(not (:alt (second msg))))
[(update model :input-buffer str (:char (second msg))) nil]
;; Character input - new event format {:type :key :key \a}
(and (= (:type event) :key)
(char? (:key event))
(not (:modifiers event)))
{:model (update model :input-buffer str (:key event))}
:else
[model nil]))
{:model model}))
(defn update-stash-menu [model msg]
(defn update-stash-menu [{:keys [model event]}]
(cond
(tui/key= msg :escape)
[(assoc model :menu-mode nil) nil]
(key= event :escape)
{:model (assoc model :menu-mode nil)}
;; a: Stash all changes
(tui/key= msg "a")
(key= event \a)
(do
(git/stash-all)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes"))})
;; i: Stash all changes and keep index
(tui/key= msg "i")
(key= event \i)
(do
(git/stash-keep-index)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes (kept index)")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes (kept index)"))})
;; U: Stash all including untracked files
(tui/key= msg "U")
(key= event \u #{:shift})
(do
(git/stash-include-untracked)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes (including untracked)")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed all changes (including untracked)"))})
;; s: Stash staged changes only
(tui/key= msg "s")
(key= event \s)
(do
(git/stash-staged)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed staged changes only")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed staged changes only"))})
;; u: Stash unstaged changes only
(tui/key= msg "u")
(key= event \u)
(do
(git/stash-unstaged)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed unstaged changes only")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Stashed unstaged changes only"))})
:else
[model nil]))
{:model model}))
(defn update-reset-menu [model msg]
(defn update-reset-menu [{:keys [model event]}]
(cond
(tui/key= msg :escape)
[(assoc model :menu-mode nil) nil]
(key= event :escape)
{:model (assoc model :menu-mode nil)}
;; s: Soft reset (keep staged)
(tui/key= msg "s")
(key= event \s)
(do
(git/reset-soft)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Soft reset to HEAD~1")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Soft reset to HEAD~1"))})
;; m: Mixed reset (unstage)
(tui/key= msg "m")
(key= event \m)
(do
(git/reset-mixed)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Mixed reset to HEAD~1")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Mixed reset to HEAD~1"))})
;; h: Hard reset (discard all)
(tui/key= msg "h")
(key= event \h)
(do
(git/reset-hard)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Hard reset to HEAD~1")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Hard reset to HEAD~1"))})
;; u: Unstage all
(tui/key= msg "u")
(key= event \u)
(do
(git/reset-staged)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Unstaged all changes")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Unstaged all changes"))})
;; d: Discard unstaged changes
(tui/key= msg "d")
(key= event \d)
(do
(git/discard-all-unstaged)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Discarded unstaged changes")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Discarded unstaged changes"))})
;; c: Clean untracked files
(tui/key= msg "c")
(key= event \c)
(do
(git/clean-untracked)
[(-> model (assoc :menu-mode nil) refresh (assoc :message "Cleaned untracked files")) nil])
{:model (-> model (assoc :menu-mode nil) refresh (assoc :message "Cleaned untracked files"))})
:else
[model nil]))
{:model model}))
(defn update-help [model msg]
(defn update-help [{:keys [model event]}]
(cond
(or (tui/key= msg :escape) (tui/key= msg "?") (tui/key= msg "q"))
[(assoc model :menu-mode nil) nil]
(or (key= event :escape) (key= event \?) (key= event \q))
{:model (assoc model :menu-mode nil)}
:else
[model nil]))
{:model model}))
(defn update-model [model msg]
(defn update-model [{:keys [model event] :as ctx}]
(cond
;; Menu modes take priority
(= (:menu-mode model) :stash-options)
(update-stash-menu model msg)
(update-stash-menu ctx)
(= (:menu-mode model) :reset-options)
(update-reset-menu model msg)
(update-reset-menu ctx)
(= (:menu-mode model) :help)
(update-help model msg)
(update-help ctx)
(:input-mode model)
(update-input-mode model msg)
(update-input-mode ctx)
(or (tui/key= msg "q") (tui/key= msg [:ctrl \c]))
[model quit]
(or (key= event \q) (key= event \c #{:ctrl}))
{:model model :events [(ev/quit)]}
(tui/key= msg "r")
[(refresh model) nil]
(key= event \r)
{:model (refresh model)}
;; Help panel
(tui/key= msg "?")
[(assoc model :menu-mode :help) nil]
(key= event \?)
{:model (assoc model :menu-mode :help)}
;; Reset options menu
(tui/key= msg "D")
[(assoc model :menu-mode :reset-options) nil]
(key= event \d #{:shift})
{:model (assoc model :menu-mode :reset-options)}
;; Panel jump keys (matching lazygit: 2=Files, 3=Branches, 4=Commits, 5=Stash)
(tui/key= msg "2")
[(-> model (assoc :panel :files :cursor 0) clamp-cursor update-diff) nil]
(key= event \2)
{:model (-> model (assoc :panel :files :cursor 0) clamp-cursor update-diff)}
(tui/key= msg "3")
[(-> model (assoc :panel :branches :cursor 0) clamp-cursor update-diff) nil]
(key= event \3)
{:model (-> model (assoc :panel :branches :cursor 0) clamp-cursor update-diff)}
(tui/key= msg "4")
[(-> model (assoc :panel :commits :cursor 0) clamp-cursor update-diff) nil]
(key= event \4)
{:model (-> model (assoc :panel :commits :cursor 0) clamp-cursor update-diff)}
(tui/key= msg "5")
[(-> model (assoc :panel :stash :cursor 0) clamp-cursor update-diff) nil]
(key= event \5)
{:model (-> model (assoc :panel :stash :cursor 0) clamp-cursor update-diff)}
;; h/l or Left/Right: move between panels (order: files → branches → commits → stash)
(or (tui/key= msg "h") (tui/key= msg :left))
[(-> model
(update :panel #(case % :files :stash :branches :files :commits :branches :stash :commits))
(assoc :cursor 0)
clamp-cursor
update-diff) nil]
(or (key= event \h) (key= event :left))
{:model (-> model
(update :panel #(case % :files :stash :branches :files :commits :branches :stash :commits))
(assoc :cursor 0)
clamp-cursor
update-diff)}
(or (tui/key= msg "l") (tui/key= msg :right))
[(-> model
(update :panel #(case % :files :branches :branches :commits :commits :stash :stash :files))
(assoc :cursor 0)
clamp-cursor
update-diff) nil]
(or (key= event \l) (key= event :right))
{:model (-> model
(update :panel #(case % :files :branches :branches :commits :commits :stash :stash :files))
(assoc :cursor 0)
clamp-cursor
update-diff)}
(or (tui/key= msg :up) (tui/key= msg "k"))
[(-> model (update :cursor dec) clamp-cursor update-diff) nil]
(or (key= event :up) (key= event \k))
{:model (-> model (update :cursor dec) clamp-cursor update-diff)}
(or (tui/key= msg :down) (tui/key= msg "j"))
[(-> model (update :cursor inc) clamp-cursor update-diff) nil]
(or (key= event :down) (key= event \j))
{:model (-> model (update :cursor inc) clamp-cursor update-diff)}
(and (tui/key= msg "c") (= (:panel model) :files))
(and (key= event \c) (= (:panel model) :files))
(if (empty? (:staged model))
[(assoc model :message "Nothing staged to commit") nil]
[(assoc model :input-mode :commit :input-buffer "") nil])
{:model (assoc model :message "Nothing staged to commit")}
{:model (assoc model :input-mode :commit :input-buffer "")})
(tui/key= msg "P")
(key= event \p #{:shift})
(do
(git/push)
[(assoc model :message "Pushed!") nil])
{:model (assoc model :message "Pushed!")})
(tui/key= msg "p")
(key= event \p)
(do
(git/pull)
[(-> model refresh (assoc :message "Pulled!")) nil])
{:model (-> model refresh (assoc :message "Pulled!"))})
;; Tab switching with [ and ] (for commits panel)
(and (tui/key= msg "[") (= (:panel model) :commits))
[(-> model
(update :commits-tab #(if (= % :reflog) :commits :reflog))
(assoc :cursor 0)
clamp-cursor) nil]
(and (key= event \[) (= (:panel model) :commits))
{:model (-> model
(update :commits-tab #(if (= % :reflog) :commits :reflog))
(assoc :cursor 0)
clamp-cursor)}
(and (tui/key= msg "]") (= (:panel model) :commits))
[(-> model
(update :commits-tab #(if (= % :commits) :reflog :commits))
(assoc :cursor 0)
clamp-cursor) nil]
(and (key= event \]) (= (:panel model) :commits))
{:model (-> model
(update :commits-tab #(if (= % :commits) :reflog :commits))
(assoc :cursor 0)
clamp-cursor)}
;; Global undo (z): Reset to previous reflog entry
(tui/key= msg "z")
(key= event \z)
(let [reflog (:reflog model)
current-idx (or (:reflog-index model) 0)
next-idx (inc current-idx)]
(if (< next-idx (count reflog))
(let [entry (get (vec reflog) next-idx)]
(git/reset-to-reflog (:ref entry))
[(-> model
refresh
(assoc :reflog-index next-idx)
(assoc :message (str "Undo: reset to " (:ref entry)))) nil])
[(assoc model :message "Nothing to undo") nil]))
{:model (-> model
refresh
(assoc :reflog-index next-idx)
(assoc :message (str "Undo: reset to " (:ref entry))))})
{:model (assoc model :message "Nothing to undo")}))
;; Global redo (Z): Reset forward in reflog
(tui/key= msg "Z")
(key= event \z #{:shift})
(let [current-idx (or (:reflog-index model) 0)]
(if (> current-idx 0)
(let [prev-idx (dec current-idx)
reflog (:reflog model)
entry (get (vec reflog) prev-idx)]
(git/reset-to-reflog (:ref entry))
[(-> model
refresh
(assoc :reflog-index prev-idx)
(assoc :message (str "Redo: reset to " (:ref entry)))) nil])
[(assoc model :message "Nothing to redo") nil]))
{:model (-> model
refresh
(assoc :reflog-index prev-idx)
(assoc :message (str "Redo: reset to " (:ref entry))))})
{:model (assoc model :message "Nothing to redo")}))
:else
(case (:panel model)
:files (update-files model msg)
:commits (update-commits model msg)
:branches (update-branches model msg)
:stash (update-stash model msg)
[model nil])))
:files (update-files ctx)
:commits (update-commits ctx)
:branches (update-branches ctx)
:stash (update-stash ctx)
{:model model})))
;; === Views ===
@@ -648,20 +622,19 @@
(when sync-info [:text {:fg :cyan} sync-info])]))]))
;; Panel 2: Files
(defn files-panel [{:keys [staged unstaged cursor panel]} max-visible]
(defn files-panel [{:keys [staged unstaged cursor panel]}]
(let [active? (= panel :files)
all-files (into (mapv #(assoc % :section :staged) staged)
(mapv #(assoc % :section :unstaged) unstaged))
{:keys [items start-idx total]} (visible-window all-files (if active? cursor 0) max-visible)]
total (count all-files)]
[:box {:border (if active? :double :single)
:title (str "2 Files (" total ")")
:padding [0 1] :width :fill :height :fill}
(if (empty? all-files)
[:text {:fg :gray} "No changes"]
(into [:col]
(for [[local-idx file] (map-indexed vector items)]
(let [global-idx (+ start-idx local-idx)
is-cursor (and active? (= global-idx cursor))
(into [:scroll {:cursor (if active? cursor 0)}]
(for [[idx file] (map-indexed vector all-files)]
(let [is-cursor (and active? (= idx cursor))
color (if (= (:section file) :staged) :green :red)]
[:row {:gap 1}
[:text {:fg color} (file-status-char file)]
@@ -669,60 +642,55 @@
(truncate (:path file) 20)]]))))]))
;; Panel 3: Branches
(defn branches-panel [{:keys [branches remote-branches tags branch branches-tab cursor panel]} max-visible]
(defn branches-panel [{:keys [branches remote-branches tags branch branches-tab cursor panel]}]
(let [active? (= panel :branches)
all-items (case branches-tab :local branches :remotes remote-branches :tags tags branches)
{:keys [items start-idx total]} (visible-window all-items (if active? cursor 0) max-visible)
tab-str (case branches-tab :local "[L] R T" :remotes "L [R] T" :tags "L R [T]")]
[:box {:border (if active? :double :single)
:title (str "3 Branches " tab-str)
:padding [0 1] :width :fill :height :fill}
(if (empty? all-items)
[:text {:fg :gray} (str "No " (name branches-tab))]
(into [:col]
(for [[local-idx item] (map-indexed vector items)]
(let [global-idx (+ start-idx local-idx)
is-cursor (and active? (= global-idx cursor))
(into [:scroll {:cursor (if active? cursor 0)}]
(for [[idx item] (map-indexed vector all-items)]
(let [is-cursor (and active? (= idx cursor))
is-current (and (= branches-tab :local) (= item branch))
fg (cond is-current :green is-cursor :cyan :else :white)]
[:text {:fg fg :bold (or is-current is-cursor) :inverse is-cursor}
(str (if is-current "* " " ") (truncate item 22))]))))]))
;; Panel 4: Commits
(defn commits-panel [{:keys [commits reflog commits-tab cursor panel]} max-visible]
(defn commits-panel [{:keys [commits reflog commits-tab cursor panel]}]
(let [active? (= panel :commits)
reflog? (= commits-tab :reflog)
all-items (if reflog? reflog commits)
{:keys [items start-idx total]} (visible-window all-items (if active? cursor 0) max-visible)]
all-items (if reflog? reflog commits)]
[:box {:border (if active? :double :single)
:title (str "4 Commits " (if reflog? "C [R]" "[C] R"))
:padding [0 1] :width :fill :height :fill}
(if (empty? all-items)
[:text {:fg :gray} "No commits"]
(into [:col]
(for [[local-idx {:keys [sha action subject]}] (map-indexed vector items)]
(let [global-idx (+ start-idx local-idx)
is-cursor (and active? (= global-idx cursor))]
(into [:scroll {:cursor (if active? cursor 0)}]
(for [[idx {:keys [sha action subject]}] (map-indexed vector all-items)]
(let [is-cursor (and active? (= idx cursor))]
[:row {:gap 1}
[:text {:fg :yellow} (or sha "?")]
[:text {:fg (if is-cursor :cyan :white) :bold is-cursor :inverse is-cursor}
(truncate (if reflog? (str action ": " subject) (or subject "")) 14)]]))))]))
;; Panel 5: Stash
(defn stash-panel [{:keys [stashes cursor panel]} max-visible]
(defn stash-panel [{:keys [stashes cursor panel]}]
(let [active? (= panel :stash)
{:keys [items start-idx total]} (visible-window stashes (if active? cursor 0) max-visible)]
total (count stashes)]
[:box {:border (if active? :double :single)
:title (str "5 Stash (" total ")")
:padding [0 1] :width :fill :height :fill}
(if (empty? stashes)
[:text {:fg :gray} "No stashes"]
(into [:col]
(for [[local-idx {:keys [index message]}] (map-indexed vector items)]
(let [global-idx (+ start-idx local-idx)
is-cursor (and active? (= global-idx cursor))]
(into [:scroll {:cursor (if active? cursor 0)}]
(for [[idx {:keys [index message]}] (map-indexed vector stashes)]
(let [is-cursor (and active? (= idx cursor))]
[:row {:gap 1}
[:text {:fg :yellow} (str (or index global-idx))]
[:text {:fg :yellow} (str (or index idx))]
[:text {:fg (if is-cursor :cyan :white) :bold is-cursor :inverse is-cursor}
(truncate (or message "") 20)]]))))]))
@@ -844,52 +812,47 @@
[:text {:fg :yellow :bold true} (str " " message)]))
(defn main-grid-view
"Render the main lazygit-style grid layout."
"Render the main lazygit-style grid layout using grid primitive."
[model width height]
(let [has-message? (some? (:message model))
narrow? (< width 70)
;; Calculate available height for panels
;; Wide layout: left column has [3 :flex :flex :flex :flex]
;; So 4 flex panels share (height - status(3) - help-bar(1) - message?)
;; Each panel total height = remaining / 4
;; Inner height = panel total - 2 (borders)
remaining-height (- height 3 1 (if has-message? 1 0))
panel-total-height (quot remaining-height 4)
;; Inner height is total minus borders (2 rows)
panel-inner-height (if narrow?
;; Narrow: 6 sections share height
(max 1 (- (quot remaining-height 6) 2))
;; Wide: 4 panels share remaining height
(max 1 (- panel-total-height 2)))
content (if narrow?
;; NARROW: Single column stacked
[:col {:heights [3 :flex :flex :flex 3 :flex 4]}
(status-panel model)
(files-panel model panel-inner-height)
(branches-panel model panel-inner-height)
(commits-panel model panel-inner-height)
(stash-panel model panel-inner-height)
(main-view-panel model)
(command-log-panel)]
;; WIDE: Two columns
[:row {:gap 1 :widths [30 :flex]}
[:col {:heights [3 :flex :flex :flex :flex]}
(status-panel model)
(files-panel model panel-inner-height)
(branches-panel model panel-inner-height)
(commits-panel model panel-inner-height)
(stash-panel model panel-inner-height)]
[:col {:heights [:flex 4]}
(main-view-panel model)
(command-log-panel)]])]
(if has-message?
[:col {:width width :height height :heights [1 :flex 1]}
(message-view model)
content
narrow? (< width 70)]
(if narrow?
;; NARROW: Single column stacked layout
[:col {:width width :height height
:heights (if has-message?
[1 3 :flex :flex :flex :flex :flex 4 1]
[3 :flex :flex :flex :flex :flex 4 1])}
(when has-message? (message-view model))
(status-panel model)
(files-panel model)
(branches-panel model)
(commits-panel model)
(stash-panel model)
(main-view-panel model)
(command-log-panel)
(help-bar model)]
[:col {:width width :height height :heights [:flex 1]}
content
(help-bar model)])))
;; WIDE: Two columns using grid
[:grid {:rows (if has-message?
[1 3 :flex :flex :flex :flex 4 1]
[3 :flex :flex :flex :flex 4 1])
:cols [30 :flex]
:gap 1}
;; Message row spans both columns (if present)
(when has-message?
[:area {:row 0 :col 0 :col-span 2} (message-view model)])
;; Left column panels
(let [row-offset (if has-message? 1 0)]
(list
[:area {:row row-offset :col 0} (status-panel model)]
[:area {:row (+ row-offset 1) :col 0} (files-panel model)]
[:area {:row (+ row-offset 2) :col 0} (branches-panel model)]
[:area {:row (+ row-offset 3) :col 0} (commits-panel model)]
[:area {:row (+ row-offset 4) :col 0} (stash-panel model)]
;; Right column panels (spanning all rows)
[:area {:row row-offset :col 1 :row-span 5} (main-view-panel model)]
[:area {:row (+ row-offset 5) :col 1} (command-log-panel)]
;; Help bar at bottom, spanning both columns
[:area {:row (+ row-offset 6) :col 0 :col-span 2} (help-bar model)]))])))
(defn view [model {:keys [width height] :or {width 120 height 30}}]
(let [background (main-grid-view model width height)]
+121 -89
View File
@@ -1,15 +1,18 @@
(ns lazygitclj.core-test
"Unit tests for lazygitclj.core namespace - model and update functions"
(:require [clojure.test :refer [deftest testing is]]
[lazygitclj.core :as core]))
[lazygitclj.core :as core]
[tui.events :as ev]))
;; Helper to create key messages in the format the TUI library uses
(defn key-msg [k]
(cond
(char? k) [:key {:char k}]
(keyword? k) [:key k]
(and (vector? k) (= :ctrl (first k))) [:key {:ctrl true :char (second k)}]
:else [:key k]))
;; Helper to create key events in the new format
(defn key-event
([k]
(cond
(char? k) {:type :key :key k}
(keyword? k) {:type :key :key k}
:else {:type :key :key k}))
([k modifiers]
{:type :key :key k :modifiers modifiers}))
;; === Model Tests ===
@@ -108,178 +111,207 @@
;; === Update Tests ===
(deftest test-update-model-quit
(testing "q returns quit command"
(let [[model cmd] (core/update-model {} (key-msg \q))]
(is (= [:quit] cmd))))
(testing "q returns quit event"
(let [{:keys [events]} (core/update-model {:model {} :event (key-event \q)})]
(is (= [{:type :quit}] events))))
(testing "ctrl-c returns quit command"
(let [[model cmd] (core/update-model {} [:key {:ctrl true :char \c}])]
(is (= [:quit] cmd)))))
(testing "ctrl-c returns quit event"
(let [{:keys [events]} (core/update-model {:model {} :event (key-event \c #{:ctrl})})]
(is (= [{:type :quit}] events)))))
(deftest test-update-model-panel-switch
(testing "number keys switch panels (2-5 matching lazygit)"
(let [[model _] (core/update-model {:panel :commits :cursor 0
:staged [] :unstaged []} (key-msg \2))]
(let [{:keys [model]} (core/update-model {:model {:panel :commits :cursor 0
:staged [] :unstaged []}
:event (key-event \2)})]
(is (= :files (:panel model))))
(let [[model _] (core/update-model {:panel :files :cursor 0
:branches-tab :local
:branches [] :remote-branches []
:tags []} (key-msg \3))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:branches-tab :local
:branches [] :remote-branches []
:tags []}
:event (key-event \3)})]
(is (= :branches (:panel model))))
(let [[model _] (core/update-model {:panel :files :cursor 0
:commits-tab :commits
:commits [] :reflog []} (key-msg \4))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:commits-tab :commits
:commits [] :reflog []}
:event (key-event \4)})]
(is (= :commits (:panel model))))
(let [[model _] (core/update-model {:panel :files :cursor 0
:stashes []} (key-msg \5))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:stashes []}
:event (key-event \5)})]
(is (= :stash (:panel model)))))
(testing "l key cycles panels right (files → branches → commits → stash → files)"
(let [[model _] (core/update-model {:panel :files :cursor 0
:branches-tab :local
:branches [] :remote-branches []
:tags []} (key-msg \l))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:branches-tab :local
:branches [] :remote-branches []
:tags []}
:event (key-event \l)})]
(is (= :branches (:panel model))))
(let [[model _] (core/update-model {:panel :branches :cursor 0
:commits-tab :commits
:commits [] :reflog []} (key-msg \l))]
(let [{:keys [model]} (core/update-model {:model {:panel :branches :cursor 0
:commits-tab :commits
:commits [] :reflog []}
:event (key-event \l)})]
(is (= :commits (:panel model))))
(let [[model _] (core/update-model {:panel :commits :cursor 0
:stashes []} (key-msg \l))]
(let [{:keys [model]} (core/update-model {:model {:panel :commits :cursor 0
:stashes []}
:event (key-event \l)})]
(is (= :stash (:panel model))))
(let [[model _] (core/update-model {:panel :stash :cursor 0
:staged [] :unstaged []} (key-msg \l))]
(let [{:keys [model]} (core/update-model {:model {:panel :stash :cursor 0
:staged [] :unstaged []}
:event (key-event \l)})]
(is (= :files (:panel model))))))
(deftest test-update-model-cursor-movement
(testing "j moves cursor down"
(let [[model _] (core/update-model {:panel :files :cursor 0
:staged [{:path "a"} {:path "b"}]
:unstaged []} (key-msg \j))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:staged [{:path "a"} {:path "b"}]
:unstaged []}
:event (key-event \j)})]
(is (= 1 (:cursor model)))))
(testing "k moves cursor up"
(let [[model _] (core/update-model {:panel :files :cursor 1
:staged [{:path "a"} {:path "b"}]
:unstaged []} (key-msg \k))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 1
:staged [{:path "a"} {:path "b"}]
:unstaged []}
:event (key-event \k)})]
(is (= 0 (:cursor model)))))
(testing "down arrow moves cursor down"
(let [[model _] (core/update-model {:panel :files :cursor 0
:staged [{:path "a"} {:path "b"}]
:unstaged []} (key-msg :down))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 0
:staged [{:path "a"} {:path "b"}]
:unstaged []}
:event (key-event :down)})]
(is (= 1 (:cursor model)))))
(testing "up arrow moves cursor up"
(let [[model _] (core/update-model {:panel :files :cursor 1
:staged [{:path "a"} {:path "b"}]
:unstaged []} (key-msg :up))]
(let [{:keys [model]} (core/update-model {:model {:panel :files :cursor 1
:staged [{:path "a"} {:path "b"}]
:unstaged []}
:event (key-event :up)})]
(is (= 0 (:cursor model))))))
(deftest test-update-model-help-menu
(testing "? opens help menu"
(let [[model _] (core/update-model {:menu-mode nil} (key-msg \?))]
(let [{:keys [model]} (core/update-model {:model {:menu-mode nil}
:event (key-event \?)})]
(is (= :help (:menu-mode model))))))
(deftest test-update-model-reset-menu
(testing "D opens reset options menu"
(let [[model _] (core/update-model {:menu-mode nil} (key-msg \D))]
(let [{:keys [model]} (core/update-model {:model {:menu-mode nil}
:event (key-event \d #{:shift})})]
(is (= :reset-options (:menu-mode model))))))
(deftest test-update-model-input-mode
(testing "c in files panel opens commit input when staged files exist"
(let [[model _] (core/update-model {:panel :files
:staged [{:path "a.txt"}]
:unstaged []} (key-msg \c))]
(let [{:keys [model]} (core/update-model {:model {:panel :files
:staged [{:path "a.txt"}]
:unstaged []}
:event (key-event \c)})]
(is (= :commit (:input-mode model)))))
(testing "c in files panel shows message when no staged files"
(let [[model _] (core/update-model {:panel :files
:staged []
:unstaged []} (key-msg \c))]
(let [{:keys [model]} (core/update-model {:model {:panel :files
:staged []
:unstaged []}
:event (key-event \c)})]
(is (= "Nothing staged to commit" (:message model))))))
(deftest test-update-input-mode
(testing "escape cancels input mode"
(let [[model _] (core/update-input-mode {:input-mode :commit
:input-buffer "test"
:input-context nil} (key-msg :escape))]
(let [{:keys [model]} (core/update-input-mode {:model {:input-mode :commit
:input-buffer "test"
:input-context nil}
:event (key-event :escape)})]
(is (nil? (:input-mode model)))
(is (= "" (:input-buffer model)))))
(testing "backspace removes last character"
(let [[model _] (core/update-input-mode {:input-mode :commit
:input-buffer "abc"
:input-context nil} (key-msg :backspace))]
(let [{:keys [model]} (core/update-input-mode {:model {:input-mode :commit
:input-buffer "abc"
:input-context nil}
:event (key-event :backspace)})]
(is (= "ab" (:input-buffer model))))))
(deftest test-update-commits-tabs
(testing "] switches to reflog tab in commits panel"
(let [[model _] (core/update-model {:panel :commits
:commits-tab :commits
:cursor 0
:commits []
:reflog []} (key-msg \]))]
(let [{:keys [model]} (core/update-model {:model {:panel :commits
:commits-tab :commits
:cursor 0
:commits []
:reflog []}
:event (key-event \])})]
(is (= :reflog (:commits-tab model)))))
(testing "[ switches to commits tab in commits panel"
(let [[model _] (core/update-model {:panel :commits
:commits-tab :reflog
:cursor 0
:commits []
:reflog []} (key-msg \[))]
(let [{:keys [model]} (core/update-model {:model {:panel :commits
:commits-tab :reflog
:cursor 0
:commits []
:reflog []}
:event (key-event \[)})]
(is (= :commits (:commits-tab model))))))
(deftest test-update-branches-tabs
(testing "] cycles branches tabs forward"
(let [[model _] (core/update-branches {:branches-tab :local
:cursor 0
:branches []} (key-msg \]))]
(let [{:keys [model]} (core/update-branches {:model {:branches-tab :local
:cursor 0
:branches []}
:event (key-event \])})]
(is (= :remotes (:branches-tab model))))
(let [[model _] (core/update-branches {:branches-tab :remotes
:cursor 0
:remote-branches []} (key-msg \]))]
(let [{:keys [model]} (core/update-branches {:model {:branches-tab :remotes
:cursor 0
:remote-branches []}
:event (key-event \])})]
(is (= :tags (:branches-tab model))))
(let [[model _] (core/update-branches {:branches-tab :tags
:cursor 0
:tags []} (key-msg \]))]
(let [{:keys [model]} (core/update-branches {:model {:branches-tab :tags
:cursor 0
:tags []}
:event (key-event \])})]
(is (= :local (:branches-tab model)))))
(testing "[ cycles branches tabs backward"
(let [[model _] (core/update-branches {:branches-tab :local
:cursor 0
:tags []} (key-msg \[))]
(let [{:keys [model]} (core/update-branches {:model {:branches-tab :local
:cursor 0
:tags []}
:event (key-event \[)})]
(is (= :tags (:branches-tab model))))))
(deftest test-update-stash-menu
(testing "escape closes stash menu"
(let [[model _] (core/update-stash-menu {:menu-mode :stash-options} (key-msg :escape))]
(let [{:keys [model]} (core/update-stash-menu {:model {:menu-mode :stash-options}
:event (key-event :escape)})]
(is (nil? (:menu-mode model))))))
(deftest test-update-reset-menu
(testing "escape closes reset menu"
(let [[model _] (core/update-reset-menu {:menu-mode :reset-options} (key-msg :escape))]
(let [{:keys [model]} (core/update-reset-menu {:model {:menu-mode :reset-options}
:event (key-event :escape)})]
(is (nil? (:menu-mode model))))))
(deftest test-update-help
(testing "escape closes help"
(let [[model _] (core/update-help {:menu-mode :help} (key-msg :escape))]
(let [{:keys [model]} (core/update-help {:model {:menu-mode :help}
:event (key-event :escape)})]
(is (nil? (:menu-mode model)))))
(testing "q closes help"
(let [[model _] (core/update-help {:menu-mode :help} (key-msg \q))]
(let [{:keys [model]} (core/update-help {:model {:menu-mode :help}
:event (key-event \q)})]
(is (nil? (:menu-mode model)))))
(testing "? closes help"
(let [[model _] (core/update-help {:menu-mode :help} (key-msg \?))]
(let [{:keys [model]} (core/update-help {:model {:menu-mode :help}
:event (key-event \?)})]
(is (nil? (:menu-mode model))))))
;; Run tests when executed directly