Compare commits

7 Commits

Author SHA1 Message Date
890243d7a3 add mouse support
SCIP Index / index (push) Failing after 1m53s
2026-03-09 23:13:31 -04:00
c69c254512 Update .github/workflows/scip-index.yml
SCIP Index / index (push) Successful in 3m32s
2026-02-04 05:44:12 -10:00
6378c4910b Update .github/workflows/scip-index.yml
SCIP Index / index (push) Failing after 6s
2026-02-04 05:43:22 -10:00
0c5d69e7b2 Update .github/workflows/scip-index.yml
SCIP Index / index (push) Failing after 9s
2026-02-04 05:41:49 -10:00
9940d53f79 Update .github/workflows/scip-index.yml
SCIP Index / index (push) Failing after 3s
2026-02-04 05:37:21 -10:00
c814f3618a Update .github/workflows/scip-index.yml
SCIP Index / index (push) Failing after 9s
2026-02-04 05:34:49 -10:00
1ec1171c9b Delete file1.txt
SCIP Index / index (push) Successful in 1m35s
2026-02-03 22:07:47 -10:00
6 changed files with 343 additions and 72 deletions
+16 -1
View File
@@ -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
+3 -1
View File
@@ -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)}
+3
View File
@@ -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"}}}
View File
+303 -70
View File
@@ -6,8 +6,6 @@
[lazygitclj.git :as git]
[clojure.string :as str]))
(def temp tui/run)
;; === Model ===
(defn load-git-data []
@@ -57,8 +55,47 @@
(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
"Change doc string for test"
"Get the diff for the currently selected item based on panel and cursor."
[model]
(let [panel (:panel model)
items (current-items model)
@@ -92,8 +129,7 @@
;; === Update ===
(defn update-diff [model]
;; never update lol
model)
(assoc model :diff (get-current-diff model)))
(defn initial-model []
(let [base (merge
@@ -106,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)))
@@ -159,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})))
@@ -212,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})))
@@ -234,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))
@@ -312,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})))
@@ -494,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)]}
@@ -510,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)}
@@ -598,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)
@@ -626,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 []
@@ -722,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)
@@ -772,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 ""]
@@ -882,20 +1119,16 @@
:else
background)))
(defn test-view-2 [& args]
[:box "foo"])
;; === Main ===
(def temp2 get-current-diff)
(defn -main [& _args]
(if (git/repo-root)
(do
(println (str "String with initial model: " (initial-model)))
(println "Starting lazygitclj...")
(tui/run {:init (initial-model)
:update update-model
:view test-view-2})
:view view
:mouse true})
(println "Goodbye!"))
(do
(println "Error: Not a git repository")
+18
View File
@@ -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)))