diff --git a/CLAUDE.md b/CLAUDE.md index 32e108e..61b6ca1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,23 @@ ## Local TUI Library -- The TUI library is at `../clojure-tui/` (local dependency in bb.edn) +- The TUI library is at `../clojure-tui/` — use local override for development: + ``` + bb -Sdeps '{:deps {io.github.ajet/clojure-tui {:local/root "../clojure-tui"}}}' start + ``` +- `bb.edn` uses a git dep (for bbin install), override with `:local/root` when developing against local TUI changes - You have access to edit the local TUI library in conjunction with this repo - Use this access to debug issues, support new features, and simplify code - Look for opportunities to create better abstraction primitives for TUI layout +- When updating the TUI library, update the `:git/sha` in `bb.edn` after pushing clojure-tui changes + +## Installation (bbin) + +End users install from Gitea: +``` +bbin install https://git.ajet.fyi/ajet/lazygitclj.git +``` +This requires the `clojure-tui` git dep SHA in `bb.edn` to be up-to-date and pushed. ## Testing with VHS @@ -52,9 +65,11 @@ test/ | Command | Purpose | |---------|---------| | `bb start` | Run lazygitclj TUI | +| `bb -Sdeps '{:deps {io.github.ajet/clojure-tui {:local/root "../clojure-tui"}}}' start` | Run with local TUI lib | | `bb debug` | Debug TUI layout issues | | `bb test` | Run unit tests | | `bb test:e2e` | Run VHS tape tests | +| `bbin install https://git.ajet.fyi/ajet/lazygitclj.git` | Install for end users | ## Architecture: Elm Pattern diff --git a/bb.edn b/bb.edn index 56a4a01..afa977f 100644 --- a/bb.edn +++ b/bb.edn @@ -1,5 +1,7 @@ {:paths ["src" "test"] - :deps {io.github.ajet/clojure-tui {:local/root "../clojure-tui"}} + :deps {io.github.ajet/clojure-tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git" + :git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}} + :bbin/bin {lazygitclj {:main-opts ["-m" "lazygitclj.core"]}} :tasks {:requires ([e2e]) start {:doc "Run lazygitclj" :task (exec 'lazygitclj.core/-main)} diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..1600128 --- /dev/null +++ b/deps.edn @@ -0,0 +1,3 @@ +{:paths ["src"] + :deps {io.github.ajet/clojure-tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git" + :git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}} diff --git a/src/lazygitclj/core.clj b/src/lazygitclj/core.clj index 55fee7a..f76715a 100644 --- a/src/lazygitclj/core.clj +++ b/src/lazygitclj/core.clj @@ -55,6 +55,45 @@ (str (subs s 0 max-len) "...") s)) +(defn parse-diff-hunks + "Parse a diff string into header lines and hunk boundaries. + Returns {:header [lines] :hunks [{:start idx :end idx :lines [lines]} ...] :lines [all-lines]}" + [diff-text] + (when (and diff-text (not (str/blank? diff-text))) + (let [lines (vec (str/split-lines diff-text)) + hunk-starts (vec (keep-indexed + (fn [i line] (when (str/starts-with? line "@@") i)) + lines))] + (when (seq hunk-starts) + (let [header-end (first hunk-starts) + header (subvec lines 0 header-end) + hunks (mapv (fn [i] + (let [start (nth hunk-starts i) + end (if (< (inc i) (count hunk-starts)) + (nth hunk-starts (inc i)) + (count lines))] + {:start start :end end :lines (subvec lines start end)})) + (range (count hunk-starts)))] + {:header header :hunks hunks :lines lines}))))) + +(defn hunk-to-patch + "Construct a valid git patch from parsed diff header and a single hunk." + [parsed-diff hunk-idx] + (let [hunk (get (:hunks parsed-diff) hunk-idx)] + (when hunk + (str (str/join "\n" (:header parsed-diff)) "\n" + (str/join "\n" (:lines hunk)) "\n")))) + +(defn line->hunk-index + "Find which hunk index contains the given line index, or nil if in header." + [parsed-diff line-idx] + (when parsed-diff + (first (keep-indexed + (fn [i hunk] + (when (and (>= line-idx (:start hunk)) (< line-idx (:end hunk))) + i)) + (:hunks parsed-diff))))) + (defn get-current-diff "Get the diff for the currently selected item based on panel and cursor." [model] @@ -103,6 +142,12 @@ :menu-mode nil :commits-tab :commits :branches-tab :local + :diff-focused false + :diff-scroll 0 + :diff-staging false + :diff-hunks nil + :diff-cursor 0 + :diff-file-item nil :reflog-index 0} (load-git-data))] (update-diff base))) @@ -156,6 +201,22 @@ (key= event \s #{:shift}) {:model (assoc model :menu-mode :stash-options)} + ;; Enter: interactive staging view + (key= event :enter) + (if item + (let [parsed (when (:diff model) (parse-diff-hunks (:diff model)))] + (if (and parsed (seq (:hunks parsed))) + {:model (assoc model + :diff-focused true + :diff-staging true + :diff-hunks parsed + :diff-cursor (:start (first (:hunks parsed))) + :diff-file-item item)} + (if (:diff model) + {:model (assoc model :diff-focused true :diff-scroll 0)} + {:model model}))) + {:model model}) + :else {:model model}))) @@ -209,6 +270,12 @@ {:model (assoc model :message (str "SHA: " sha " (not copied - no clipboard support)"))} {:model model}) + ;; Enter: focus diff view + (key= event :enter) + (if (some? (:diff model)) + {:model (assoc model :diff-focused true :diff-scroll 0)} + {:model model}) + :else {:model model}))) @@ -231,7 +298,9 @@ (git/checkout-tag item) {:model (-> model refresh (assoc :message (str "Checked out tag " item)))}) - :else {:model model}) + :else (if (some? (:diff model)) + {:model (assoc model :diff-focused true :diff-scroll 0)} + {:model model})) ;; n: new branch (local tab only) (and (key= event \n) (= tab :local)) @@ -309,6 +378,12 @@ (and (key= event \n) ref) {:model (assoc model :input-mode :stash-branch :input-buffer "" :input-context ref)} + ;; Enter: focus diff view + (key= event :enter) + (if (some? (:diff model)) + {:model (assoc model :diff-focused true :diff-scroll 0)} + {:model model}) + :else {:model model}))) @@ -491,6 +566,84 @@ (:input-mode model) (update-input-mode ctx) + ;; Diff focused mode + (:diff-focused model) + (if (:diff-staging model) + ;; Interactive staging mode: line-by-line navigation + hunk staging + (cond + (key= event :escape) + {:model (-> model + (assoc :diff-focused false :diff-staging false + :diff-hunks nil :diff-file-item nil) + refresh)} + + (or (key= event \j) (key= event :down)) + (let [max-idx (max 0 (dec (count (:lines (:diff-hunks model)))))] + {:model (update model :diff-cursor #(min max-idx (inc %)))}) + + (or (key= event \k) (key= event :up)) + {:model (update model :diff-cursor #(max 0 (dec %)))} + + ;; Space: stage/unstage the hunk containing the current line + (key= event \space) + (let [{:keys [diff-hunks diff-cursor diff-file-item]} model + hunk-idx (line->hunk-index diff-hunks diff-cursor) + patch (when hunk-idx (hunk-to-patch diff-hunks hunk-idx)) + staged? (= (:type diff-file-item) :staged)] + (if patch + (do + (if staged? + (git/unapply-patch-cached patch) + (git/apply-patch-cached patch)) + (let [path (:path diff-file-item) + new-diff (if staged? + (git/diff-staged-file path) + (git/diff-unstaged path)) + new-parsed (when new-diff (parse-diff-hunks new-diff)) + new-model (-> model + (assoc :staged (git/staged-files) + :unstaged (git/unstaged-files)) + clamp-cursor) + max-line (if new-parsed + (dec (count (:lines new-parsed))) + 0)] + (if (and new-parsed (seq (:hunks new-parsed))) + {:model (assoc new-model + :diff new-diff + :diff-hunks new-parsed + :diff-cursor (min diff-cursor max-line))} + {:model (-> new-model + (assoc :diff-focused false :diff-staging false + :diff-hunks nil :diff-file-item nil) + update-diff + (assoc :message (if staged? "Unstaged hunk" "Staged hunk")))}))) + {:model model})) + + (or (key= event \q) (key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} + + :else + {:model model}) + + ;; Read-only scroll mode + (cond + (key= event :escape) + {:model (assoc model :diff-focused false)} + + (or (key= event \j) (key= event :down)) + (let [lines (when (:diff model) (str/split-lines (:diff model))) + max-scroll (max 0 (dec (count lines)))] + {:model (update model :diff-scroll #(min max-scroll (inc %)))}) + + (or (key= event \k) (key= event :up)) + {:model (update model :diff-scroll #(max 0 (dec %)))} + + (or (key= event \q) (key= event \c #{:ctrl})) + {:model model :events [(ev/quit)]} + + :else + {:model model})) + (or (key= event \q) (key= event \c #{:ctrl})) {:model model :events [(ev/quit)]} @@ -507,29 +660,29 @@ ;; Panel jump keys (matching lazygit: 2=Files, 3=Branches, 4=Commits, 5=Stash) (key= event \2) - {:model (-> model (assoc :panel :files :cursor 0) clamp-cursor update-diff)} + {:model (-> model (assoc :panel :files :cursor 0 :diff-focused false) clamp-cursor update-diff)} (key= event \3) - {:model (-> model (assoc :panel :branches :cursor 0) clamp-cursor update-diff)} + {:model (-> model (assoc :panel :branches :cursor 0 :diff-focused false) clamp-cursor update-diff)} (key= event \4) - {:model (-> model (assoc :panel :commits :cursor 0) clamp-cursor update-diff)} + {:model (-> model (assoc :panel :commits :cursor 0 :diff-focused false) clamp-cursor update-diff)} (key= event \5) - {:model (-> model (assoc :panel :stash :cursor 0) clamp-cursor update-diff)} + {:model (-> model (assoc :panel :stash :cursor 0 :diff-focused false) clamp-cursor update-diff)} ;; h/l or Left/Right: move between panels (order: files → branches → commits → stash) (or (key= event \h) (key= event :left)) {:model (-> model (update :panel #(case % :files :stash :branches :files :commits :branches :stash :commits)) - (assoc :cursor 0) + (assoc :cursor 0 :diff-focused false) clamp-cursor update-diff)} (or (key= event \l) (key= event :right)) {:model (-> model (update :panel #(case % :files :branches :branches :commits :commits :stash :stash :files)) - (assoc :cursor 0) + (assoc :cursor 0 :diff-focused false) clamp-cursor update-diff)} @@ -595,6 +748,46 @@ (assoc :message (str "Redo: reset to " (:ref entry))))}) {:model (assoc model :message "Nothing to redo")})) + ;; Mouse: click tab label to switch tab + (= (:type event) :switch-tab) + (let [{:keys [panel tab]} event + tab-key (case panel + :branches :branches-tab + :commits :commits-tab + nil)] + (if tab-key + {:model (-> model + (assoc :panel panel tab-key tab :cursor 0) + clamp-cursor + update-diff)} + {:model model})) + + ;; Mouse: click to switch panel + (= (:type event) :switch-panel) + {:model (-> model + (assoc :panel (:panel event) :cursor 0) + clamp-cursor + update-diff)} + + ;; Mouse: click to select item + (= (:type event) :select-item) + {:model (-> model + (assoc :panel (:panel event) :cursor (:index event)) + clamp-cursor + update-diff)} + + ;; Mouse: click to select diff line in staging view + (= (:type event) :select-diff-line) + {:model (assoc model :diff-cursor (:index event))} + + ;; Mouse: scroll wheel in panel + (= (:type event) :scroll-panel) + {:model (-> model + (assoc :panel (:panel event)) + (update :cursor (if (= (:direction event) :wheel-up) dec inc)) + clamp-cursor + update-diff)} + :else (case (:panel model) :files (update-files ctx) @@ -623,94 +816,135 @@ (when sync-info [:text {:fg :cyan} sync-info])]))])) ;; Panel 2: Files -(defn files-panel [{:keys [staged unstaged cursor panel]}] +(defn files-panel [{:keys [staged unstaged cursor panel diff-focused]}] (let [active? (= panel :files) + focused? (and active? (not diff-focused)) all-files (into (mapv #(assoc % :section :staged) staged) (mapv #(assoc % :section :unstaged) unstaged)) total (count all-files)] - [:box {:border (if active? :double :single) + [:box {:border (if focused? :double :single) :title (str "2 Files (" total ")") + :on-click {:type :switch-panel :panel :files} + :on-scroll {:type :scroll-panel :panel :files} :padding [0 1] :width :fill :height :fill} (if (empty? all-files) [:text {:fg :gray} "No changes"] - (into [:scroll {:cursor (if active? cursor 0)}] + (into [:scroll {:cursor (if active? cursor 0) + :on-scroll {:type :scroll-panel :panel :files}}] (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} + [:row {:gap 1 :on-click {:type :select-item :panel :files :index idx}} [:text {:fg color} (file-status-char file)] [:text {:fg (if is-cursor :cyan color) :bold is-cursor :inverse is-cursor} (truncate (:path file) 20)]]))))])) ;; Panel 3: Branches -(defn branches-panel [{:keys [branches remote-branches tags branch branches-tab cursor panel]}] +(defn- tab-label [text tab-key current-tab] + (if (= tab-key current-tab) (str "[" text "]") text)) + +(defn branches-panel [{:keys [branches remote-branches tags branch branches-tab cursor panel diff-focused]}] (let [active? (= panel :branches) - all-items (case branches-tab :local branches :remotes remote-branches :tags tags branches) - 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) + focused? (and active? (not diff-focused)) + all-items (case branches-tab :local branches :remotes remote-branches :tags tags branches)] + [:box {:border (if focused? :double :single) + :title (str "3 Branches " + (tab-label "L" :local branches-tab) " " + (tab-label "R" :remotes branches-tab) " " + (tab-label "T" :tags branches-tab)) + :on-click {:type :switch-panel :panel :branches} + :on-scroll {:type :scroll-panel :panel :branches} :padding [0 1] :width :fill :height :fill} (if (empty? all-items) [:text {:fg :gray} (str "No " (name branches-tab))] - (into [:scroll {:cursor (if active? cursor 0)}] + (into [:scroll {:cursor (if active? cursor 0) + :on-scroll {:type :scroll-panel :panel :branches}}] (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} + [:text {:fg fg :bold (or is-current is-cursor) :inverse is-cursor + :on-click {:type :select-item :panel :branches :index idx}} (str (if is-current "* " " ") (truncate item 22))]))))])) ;; Panel 4: Commits -(defn commits-panel [{:keys [commits reflog commits-tab cursor panel]}] +(defn commits-panel [{:keys [commits reflog commits-tab cursor panel diff-focused]}] (let [active? (= panel :commits) + focused? (and active? (not diff-focused)) reflog? (= commits-tab :reflog) all-items (if reflog? reflog commits)] - [:box {:border (if active? :double :single) - :title (str "4 Commits " (if reflog? "C [R]" "[C] R")) + [:box {:border (if focused? :double :single) + :title (str "4 Commits " + (tab-label "C" :commits commits-tab) " " + (tab-label "R" :reflog commits-tab)) + :on-click {:type :switch-panel :panel :commits} + :on-scroll {:type :scroll-panel :panel :commits} :padding [0 1] :width :fill :height :fill} (if (empty? all-items) [:text {:fg :gray} "No commits"] - (into [:scroll {:cursor (if active? cursor 0)}] + (into [:scroll {:cursor (if active? cursor 0) + :on-scroll {:type :scroll-panel :panel :commits}}] (for [[idx {:keys [sha action subject]}] (map-indexed vector all-items)] (let [is-cursor (and active? (= idx cursor))] - [:row {:gap 1} + [:row {:gap 1 :on-click {:type :select-item :panel :commits :index idx}} [: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]}] +(defn stash-panel [{:keys [stashes cursor panel diff-focused]}] (let [active? (= panel :stash) + focused? (and active? (not diff-focused)) total (count stashes)] - [:box {:border (if active? :double :single) + [:box {:border (if focused? :double :single) :title (str "5 Stash (" total ")") + :on-click {:type :switch-panel :panel :stash} + :on-scroll {:type :scroll-panel :panel :stash} :padding [0 1] :width :fill :height :fill} (if (empty? stashes) [:text {:fg :gray} "No stashes"] - (into [:scroll {:cursor (if active? cursor 0)}] + (into [:scroll {:cursor (if active? cursor 0) + :on-scroll {:type :scroll-panel :panel :stash}}] (for [[idx {:keys [index message]}] (map-indexed vector stashes)] (let [is-cursor (and active? (= idx cursor))] - [:row {:gap 1} + [:row {:gap 1 :on-click {:type :select-item :panel :stash :index 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)]]))))])) -;; Panel 0: Main View (Diff) -(defn main-view-panel [{:keys [diff]}] - (let [lines (when diff (str/split-lines diff))] - [:box {:border :single :title "0 Main" :padding [0 1] :width :fill :height :fill} +(defn diff-line-color [line] + (cond + (str/starts-with? line "+") :green + (str/starts-with? line "-") :red + (str/starts-with? line "@@") :cyan + (or (str/starts-with? line "diff") + (str/starts-with? line "commit")) :yellow + :else :white)) + +;; Main View (Diff) +(defn main-view-panel [{:keys [diff diff-focused diff-scroll + diff-staging diff-hunks diff-cursor + diff-file-item]}] + (let [lines (when diff (str/split-lines diff)) + title (if (and diff-staging diff-file-item) + (str "Staging - " (:path diff-file-item)) + "Main")] + [:box {:border (if diff-focused :double :single) :title title + :padding [0 1] :width :fill :height :fill} (if (empty? lines) [:text {:fg :gray} "Select an item to view diff"] - [:col - (for [line lines] - [:text {:fg (cond - (str/starts-with? line "+") :green - (str/starts-with? line "-") :red - (str/starts-with? line "@@") :cyan - (or (str/starts-with? line "diff") - (str/starts-with? line "commit")) :yellow - :else :white)} - line])])])) + (if diff-staging + ;; Interactive staging: highlight cursor line, click selects line + (into [:scroll {:cursor (or diff-cursor 0)}] + (for [[idx line] (map-indexed vector lines)] + [:text {:fg (diff-line-color line) + :inverse (= idx diff-cursor) + :on-click {:type :select-diff-line :index idx}} + line])) + ;; Read-only scroll + (into [:scroll {:cursor (if diff-focused (or diff-scroll 0) 0)}] + (for [line lines] + [:text {:fg (diff-line-color line)} line]))))])) ;; Command Log (placeholder) (defn command-log-panel [] @@ -719,22 +953,27 @@ ;; Bottom help bar (defn help-bar [model] - (let [panel (:panel model) - panel-help (case panel - :files "spc:stage a:all c:commit" - :commits "[]:tabs spc:checkout" - :branches "[]:tabs n:new d:del" - :stash "spc:apply g:pop d:drop" - "")] - (into [:row {:gap 1}] - (remove nil? - [[:text {:fg :gray} "q:quit"] - [:text {:fg :gray} "?:help"] - [:text {:fg :gray} "h/l:panels"] - [:text {:fg :gray} "j/k:nav"] - (when (seq panel-help) - [:text {:fg :gray} panel-help]) - [:text {:fg :gray} "p/P:pull/push"]])))) + (if (:diff-staging model) + [:row {:gap 1} + [:text {:fg :gray} "esc:back"] + [:text {:fg :gray} "j/k:hunks"] + [:text {:fg :gray} "spc:stage/unstage hunk"]] + (let [panel (:panel model) + panel-help (case panel + :files "spc:stage a:all c:commit enter:staging" + :commits "[]:tabs spc:checkout enter:view" + :branches "[]:tabs n:new d:del" + :stash "spc:apply g:pop d:drop enter:view" + "")] + (into [:row {:gap 1}] + (remove nil? + [[:text {:fg :gray} "q:quit"] + [:text {:fg :gray} "?:help"] + [:text {:fg :gray} "h/l:panels"] + [:text {:fg :gray} "j/k:nav"] + (when (seq panel-help) + [:text {:fg :gray} panel-help]) + [:text {:fg :gray} "p/P:pull/push"]]))))) (defn stash-menu-view [{:keys [menu-mode]}] (when (= menu-mode :stash-options) @@ -769,7 +1008,8 @@ [:text {:fg :cyan :bold true} "Global:"] [:text " q - Quit r - Refresh"] [:text " h/l - Prev/Next panel 2-5 - Jump to panel"] - [:text " j/k - Move down/up z/Z - Undo/Redo"] + [:text " j/k - Move down/up enter - Focus diff"] + [:text " z/Z - Undo/Redo esc - Return from diff"] [:text " p - Pull P - Push"] [:text " ? - Help D - Reset options"] [:text ""] @@ -887,7 +1127,8 @@ (println "Starting lazygitclj...") (tui/run {:init (initial-model) :update update-model - :view view}) + :view view + :mouse true}) (println "Goodbye!")) (do (println "Error: Not a git repository") diff --git a/src/lazygitclj/git.clj b/src/lazygitclj/git.clj index ef578b5..7f85666 100644 --- a/src/lazygitclj/git.clj +++ b/src/lazygitclj/git.clj @@ -436,3 +436,21 @@ "Checkout a tag (detached HEAD)." [name] (sh "git" "checkout" name)) + +;; === Patch Application === + +(defn apply-patch-cached + "Apply a patch to the index via stdin (stage a hunk)." + [patch-text] + (try + (shell {:in patch-text :out :string :err :string} "git" "apply" "--cached") + true + (catch Exception _ false))) + +(defn unapply-patch-cached + "Reverse-apply a patch from the index via stdin (unstage a hunk)." + [patch-text] + (try + (shell {:in patch-text :out :string :err :string} "git" "apply" "--cached" "-R") + true + (catch Exception _ false)))