diff --git a/src/lazygitclj/core.clj b/src/lazygitclj/core.clj index 10ecbde..f9c488c 100644 --- a/src/lazygitclj/core.clj +++ b/src/lazygitclj/core.clj @@ -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)] diff --git a/test/lazygitclj/core_test.clj b/test/lazygitclj/core_test.clj index 68189bd..eecefa1 100644 --- a/test/lazygitclj/core_test.clj +++ b/test/lazygitclj/core_test.clj @@ -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