mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
Merge branch 'master' into master
This commit is contained in:
commit
adad436715
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -195,7 +195,7 @@ jobs:
|
|||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
|
||||
with:
|
||||
# If you change this, make sure to also update scripts/golangci-lint-shim.sh
|
||||
version: v2.4.0
|
||||
version: v2.12.2
|
||||
upload-coverage:
|
||||
# List all jobs that produce coverage files
|
||||
needs: [unit-tests, integration-tests]
|
||||
|
|
|
|||
2
.github/workflows/sponsors.yml
vendored
2
.github/workflows/sponsors.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Sponsors 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
|
||||
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
||||
file: "README.md"
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ linters:
|
|||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- vendor/
|
||||
|
|
|
|||
29
AGENTS.md
29
AGENTS.md
|
|
@ -82,11 +82,26 @@ while still being meaningful and self-contained.
|
|||
excuse bundling it in. Before committing, review your diff and split out any
|
||||
hunk that is behavior-preserving (an extraction, a rename, a move) into a
|
||||
preceding commit, by staging hunks or resetting and recommitting in order.
|
||||
- **A preparatory refactor is a new commit only when it prepares something
|
||||
new.** Before adding one, find the commit that introduced the code you are
|
||||
about to restructure. If that commit is on this branch, the refactor is a
|
||||
`fixup!` for it rather than a commit of its own: a branch must never contain
|
||||
a commit whose code a later commit on the same branch tidies up. A prep
|
||||
refactor earns a commit of its own only when the shape it corrects came from
|
||||
before the branch. This holds across a branch stack too — if the commit that
|
||||
introduced the code is in an earlier branch of the stack, the fixup belongs
|
||||
there, and the branches above it get replayed. The one exception is when
|
||||
fixing it there turns out to be unreasonably difficult; ask me what to do
|
||||
rather than deciding to leave the repair at the tip.
|
||||
- **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
|
||||
Match the plain English imperative style of the existing history.
|
||||
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
|
||||
characters, or even a little more if needed to convey a good single-line
|
||||
summary; the body should be wrapped at 72 exactly, no more, no less.
|
||||
- **End every commit message with the `Co-authored-by:` trailer** naming the
|
||||
model that wrote it, exactly as your harness instructions spell it. Nothing
|
||||
in `just check` catches a missing one, so it has to be part of writing the
|
||||
message rather than something to notice afterwards.
|
||||
|
||||
## Iterate with `fixup!` commits
|
||||
|
||||
|
|
@ -105,6 +120,20 @@ separate, reviewable commit that the user decides when to fold in. A bare
|
|||
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
|
||||
treat "I'm only touching the tip commit" as an exception.
|
||||
|
||||
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
|
||||
you naturally would because "the branch isn't pushed yet". The user always wants
|
||||
to review what you changed, so make this transparent; no exceptions.
|
||||
|
||||
**When the tip is the wrong place for a fixup, insert it mid-branch.**
|
||||
Committing a fixup at the tip of the branch only works while the code it
|
||||
touches still looks the same there; once later commits have rewritten that
|
||||
code — or the target has since been split — the fixup won't apply, and
|
||||
rewriting the later commits to accommodate it defeats the point. Check out the
|
||||
target, make the change, `git commit --fixup=<target>`, then
|
||||
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
|
||||
branch. The fixup stays a separate, reviewable commit; only its position
|
||||
changes.
|
||||
|
||||
If the changes don't map cleanly onto existing commits — say they cut
|
||||
across several of them, or restructure something at a different layer
|
||||
than any existing commit naturally owns — stop and ask the user how to
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Fields only for `extDiff`:
|
|||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ git:
|
|||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
|
||||
|
||||
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
|
||||
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
|
||||
|
||||
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
|
||||
|
||||
## Menu filtering
|
||||
|
||||
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
|
||||
|
||||
## Filtering files by status
|
||||
|
||||
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` w `` | New worktree | |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
|
||||
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
|
||||
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
|
|
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <enter> `` | Bekijk branches | |
|
||||
| `` n `` | Voeg een nieuwe remote toe | |
|
||||
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
|
||||
| `` e `` | Edit | Wijzig remote |
|
||||
| `` f `` | Fetch | Fetch remote |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
|
|
|
|||
|
|
@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
|
||||
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
|
||||
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
|
||||
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
|
||||
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
|
||||
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
|
||||
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
|
||||
| `` q, <ctrl+c> `` | 結束 | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+z> `` | 掛起應用程式 | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
|
||||
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
|
||||
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
|
||||
|
|
@ -44,21 +44,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <, <home> `` | 捲動到頂部 | |
|
||||
| `` >, <end> `` | 捲動到底部 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <shift+down> `` | Range select down | |
|
||||
| `` <shift+up> `` | Range select up | |
|
||||
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
|
||||
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
| `` H `` | 向左捲動 | |
|
||||
| `` L `` | 向右捲動 | |
|
||||
| `` ] `` | 下一個索引標籤 | |
|
||||
| `` [ `` | 上一個索引標籤 | |
|
||||
|
||||
## Input prompt
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 主面板 (補丁生成)
|
||||
|
||||
| Key | Action | Info |
|
||||
|
|
@ -66,12 +59,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
|
||||
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
|
||||
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
|
||||
| `` <esc> `` | 退出自訂補丁建立器 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -81,8 +74,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
|
|
@ -90,15 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 挑選程式碼片段 | |
|
||||
| `` b `` | Pick both hunks | |
|
||||
| `` b `` | 選取兩個區塊 | |
|
||||
| `` <up>, k `` | 選擇上一段 | |
|
||||
| `` <down>, j `` | 選擇下一段 | |
|
||||
| `` <left>, h `` | 選擇上一個衝突 | |
|
||||
| `` <right>, l `` | 選擇下一個衝突 | |
|
||||
| `` z `` | 復原 | Undo last merge conflict resolution. |
|
||||
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
|
|
@ -108,19 +101,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 功能表
|
||||
|
|
@ -135,19 +128,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -156,12 +149,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
|
||||
| `` <enter> `` | Enter | 進入子模組 |
|
||||
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
|
||||
| `` u `` | Update | 更新子模組 |
|
||||
| `` <enter> `` | 進入 | 進入子模組 |
|
||||
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
|
||||
| `` u `` | 更新 | 更新子模組 |
|
||||
| `` n `` | 新增子模組 | |
|
||||
| `` e `` | 更新子模組 URL | |
|
||||
| `` i `` | Initialize | 初始化子模組 |
|
||||
| `` i `` | 初始化 | 初始化子模組 |
|
||||
| `` b `` | 查看批量子模組選項 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -169,27 +162,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` n `` | New worktree | |
|
||||
| `` <space> `` | Switch | Switch to the selected worktree. |
|
||||
| `` n `` | 新建工作樹 | |
|
||||
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
|
||||
| `` o `` | 在編輯器中開啟 | |
|
||||
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
|
||||
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 提交
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` b `` | 查看二分選項 | |
|
||||
| `` s `` | 壓縮 (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
|
||||
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
|
||||
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
|
||||
| `` r `` | 改寫提交 | 改寫選中的提交訊息 |
|
||||
| `` R `` | 使用編輯器改寫提交 | |
|
||||
| `` d `` | 刪除提交 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
|
||||
| `` e `` | 編輯(開始互動變基) | 編輯提交 |
|
||||
| `` i `` | 開始互動變基 | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
|
||||
| `` p `` | 挑選 | 挑選提交 (於變基過程中) |
|
||||
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
|
||||
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
|
||||
|
|
@ -198,22 +191,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` V `` | 貼上提交 (揀選) | |
|
||||
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
|
||||
| `` A `` | 修改 | 使用已預存的更改修正提交 |
|
||||
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
|
||||
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
|
||||
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -231,30 +224,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 檢出 | 檢出檔案 |
|
||||
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
|
||||
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 收藏 (Stash)
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
|
||||
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
|
||||
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
|
||||
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
|
||||
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
|
||||
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
|
||||
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` r `` | 重新命名收藏 | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -262,19 +255,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -286,18 +279,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` i `` | 顯示 git-flow 選項 | |
|
||||
| `` <space> `` | 檢出 | 檢出選定的項目。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` o `` | 建立拉取請求 | |
|
||||
| `` O `` | 建立拉取請求選項 | |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
|
||||
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
|
||||
| `` - `` | Checkout previous branch | |
|
||||
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
|
||||
| `` - `` | 簽出上一個分支 | |
|
||||
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
|
||||
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||
| `` T `` | 建立標籤 | |
|
||||
| `` s `` | 排序規則 | |
|
||||
|
|
@ -305,7 +298,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` R `` | 重新命名分支 | |
|
||||
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -313,15 +306,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
|
||||
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | 刪除 | View delete options for local/remote tag. |
|
||||
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
|
||||
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
|
||||
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
|
||||
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -330,40 +323,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
|
||||
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
|
||||
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` A `` | 修改上次提交 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` i `` | 忽略或排除檔案 | |
|
||||
| `` r `` | 重新整理檔案 | |
|
||||
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
|
||||
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
|
||||
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
|
||||
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
|
||||
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
|
||||
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
|
||||
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
|
||||
| `` g `` | 檢視遠端重設選項 | |
|
||||
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` f `` | 擷取 | 同步遠端異動 |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 次要
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
|
@ -373,9 +366,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` u `` | 檢查更新 | |
|
||||
| `` <enter> `` | 切換到最近使用的版本庫 | |
|
||||
| `` a `` | Show/cycle all branch logs | |
|
||||
| `` A `` | Show/cycle all branch logs (reverse) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` a `` | 顯示/迴圈所有分支日誌 | |
|
||||
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
|
||||
## 確認面板
|
||||
|
||||
|
|
@ -385,16 +378,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <esc> `` | 關閉/取消 | |
|
||||
| `` <ctrl+o> `` | 複製到剪貼簿 | |
|
||||
|
||||
## 輸入提示
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 遠端
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | View branches | |
|
||||
| `` <enter> `` | 檢視分支 | |
|
||||
| `` n `` | 新增遠端 | |
|
||||
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
|
||||
| `` e `` | 編輯 | 編輯遠端 |
|
||||
| `` f `` | 擷取 | 擷取遠端 |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 遠端分支
|
||||
|
|
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
|
||||
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` w `` | New worktree | |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` d `` | 刪除 | Delete the remote branch from the remote. |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
|
||||
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
|
||||
| `` s `` | 排序規則 | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Fields only for `extDiff`:
|
|||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ git:
|
|||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
|
||||
|
||||
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
|
||||
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
|
||||
|
||||
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
|
||||
|
||||
## Menu filtering
|
||||
|
||||
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
|
||||
|
||||
## Filtering files by status
|
||||
|
||||
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` w `` | New worktree | |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
|
||||
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
|
||||
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
|
|
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <enter> `` | Bekijk branches | |
|
||||
| `` n `` | Voeg een nieuwe remote toe | |
|
||||
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
|
||||
| `` e `` | Edit | Wijzig remote |
|
||||
| `` f `` | Fetch | Fetch remote |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
|
|
|
|||
|
|
@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
|
||||
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
|
||||
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
|
||||
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
|
||||
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
|
||||
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
|
||||
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
|
||||
| `` q, <ctrl+c> `` | 結束 | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+z> `` | 掛起應用程式 | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
|
||||
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
|
||||
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
|
||||
|
|
@ -44,21 +44,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <, <home> `` | 捲動到頂部 | |
|
||||
| `` >, <end> `` | 捲動到底部 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <shift+down> `` | Range select down | |
|
||||
| `` <shift+up> `` | Range select up | |
|
||||
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
|
||||
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
| `` H `` | 向左捲動 | |
|
||||
| `` L `` | 向右捲動 | |
|
||||
| `` ] `` | 下一個索引標籤 | |
|
||||
| `` [ `` | 上一個索引標籤 | |
|
||||
|
||||
## Input prompt
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 主面板 (補丁生成)
|
||||
|
||||
| Key | Action | Info |
|
||||
|
|
@ -66,12 +59,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
|
||||
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
|
||||
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
|
||||
| `` <esc> `` | 退出自訂補丁建立器 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -81,8 +74,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
|
|
@ -90,15 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 挑選程式碼片段 | |
|
||||
| `` b `` | Pick both hunks | |
|
||||
| `` b `` | 選取兩個區塊 | |
|
||||
| `` <up>, k `` | 選擇上一段 | |
|
||||
| `` <down>, j `` | 選擇下一段 | |
|
||||
| `` <left>, h `` | 選擇上一個衝突 | |
|
||||
| `` <right>, l `` | 選擇下一個衝突 | |
|
||||
| `` z `` | 復原 | Undo last merge conflict resolution. |
|
||||
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
|
|
@ -108,19 +101,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 功能表
|
||||
|
|
@ -135,19 +128,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -156,12 +149,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
|
||||
| `` <enter> `` | Enter | 進入子模組 |
|
||||
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
|
||||
| `` u `` | Update | 更新子模組 |
|
||||
| `` <enter> `` | 進入 | 進入子模組 |
|
||||
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
|
||||
| `` u `` | 更新 | 更新子模組 |
|
||||
| `` n `` | 新增子模組 | |
|
||||
| `` e `` | 更新子模組 URL | |
|
||||
| `` i `` | Initialize | 初始化子模組 |
|
||||
| `` i `` | 初始化 | 初始化子模組 |
|
||||
| `` b `` | 查看批量子模組選項 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -169,27 +162,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` n `` | New worktree | |
|
||||
| `` <space> `` | Switch | Switch to the selected worktree. |
|
||||
| `` n `` | 新建工作樹 | |
|
||||
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
|
||||
| `` o `` | 在編輯器中開啟 | |
|
||||
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
|
||||
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 提交
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` b `` | 查看二分選項 | |
|
||||
| `` s `` | 壓縮 (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
|
||||
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
|
||||
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
|
||||
| `` r `` | 改寫提交 | 改寫選中的提交訊息 |
|
||||
| `` R `` | 使用編輯器改寫提交 | |
|
||||
| `` d `` | 刪除提交 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
|
||||
| `` e `` | 編輯(開始互動變基) | 編輯提交 |
|
||||
| `` i `` | 開始互動變基 | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
|
||||
| `` p `` | 挑選 | 挑選提交 (於變基過程中) |
|
||||
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
|
||||
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
|
||||
|
|
@ -198,22 +191,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` V `` | 貼上提交 (揀選) | |
|
||||
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
|
||||
| `` A `` | 修改 | 使用已預存的更改修正提交 |
|
||||
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
|
||||
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
|
||||
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -231,30 +224,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 檢出 | 檢出檔案 |
|
||||
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
|
||||
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 收藏 (Stash)
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
|
||||
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
|
||||
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
|
||||
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
|
||||
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
|
||||
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
|
||||
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` r `` | 重新命名收藏 | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -262,19 +255,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -286,18 +279,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` i `` | 顯示 git-flow 選項 | |
|
||||
| `` <space> `` | 檢出 | 檢出選定的項目。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` o `` | 建立拉取請求 | |
|
||||
| `` O `` | 建立拉取請求選項 | |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
|
||||
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
|
||||
| `` - `` | Checkout previous branch | |
|
||||
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
|
||||
| `` - `` | 簽出上一個分支 | |
|
||||
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
|
||||
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||
| `` T `` | 建立標籤 | |
|
||||
| `` s `` | 排序規則 | |
|
||||
|
|
@ -305,7 +298,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` R `` | 重新命名分支 | |
|
||||
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -313,15 +306,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
|
||||
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | 刪除 | View delete options for local/remote tag. |
|
||||
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
|
||||
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
|
||||
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
|
||||
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -330,40 +323,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
|
||||
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
|
||||
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` A `` | 修改上次提交 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` i `` | 忽略或排除檔案 | |
|
||||
| `` r `` | 重新整理檔案 | |
|
||||
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
|
||||
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
|
||||
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
|
||||
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
|
||||
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
|
||||
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
|
||||
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
|
||||
| `` g `` | 檢視遠端重設選項 | |
|
||||
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` f `` | 擷取 | 同步遠端異動 |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 次要
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
|
@ -373,9 +366,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` u `` | 檢查更新 | |
|
||||
| `` <enter> `` | 切換到最近使用的版本庫 | |
|
||||
| `` a `` | Show/cycle all branch logs | |
|
||||
| `` A `` | Show/cycle all branch logs (reverse) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` a `` | 顯示/迴圈所有分支日誌 | |
|
||||
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
|
||||
## 確認面板
|
||||
|
||||
|
|
@ -385,16 +378,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <esc> `` | 關閉/取消 | |
|
||||
| `` <ctrl+o> `` | 複製到剪貼簿 | |
|
||||
|
||||
## 輸入提示
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 遠端
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | View branches | |
|
||||
| `` <enter> `` | 檢視分支 | |
|
||||
| `` n `` | 新增遠端 | |
|
||||
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
|
||||
| `` e `` | 編輯 | 編輯遠端 |
|
||||
| `` f `` | 擷取 | 擷取遠端 |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 遠端分支
|
||||
|
|
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
|
||||
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` w `` | New worktree | |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` d `` | 刪除 | Delete the remote branch from the remote. |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
|
||||
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
|
||||
| `` s `` | 排序規則 | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
|
|
|||
25
go.mod
25
go.mod
|
|
@ -5,6 +5,9 @@ go 1.25.0
|
|||
// This is necessary to ignore test files when executing gofumpt.
|
||||
ignore ./test
|
||||
|
||||
// Likewise for worktrees that are nested in the main tree.
|
||||
ignore ./.worktrees
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/adrg/xdg v0.5.3
|
||||
|
|
@ -13,7 +16,7 @@ require (
|
|||
github.com/cli/go-gh/v2 v2.13.0
|
||||
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gdamore/tcell/v3 v3.4.1
|
||||
github.com/gdamore/tcell/v3 v3.4.2
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/gookit/color v1.6.1
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
|
|
@ -22,7 +25,7 @@ require (
|
|||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
|
||||
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
|
||||
github.com/kyokomi/emoji/v2 v2.2.14
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/mgutz/str v1.2.0
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
|
||||
|
|
@ -31,12 +34,12 @@ require (
|
|||
github.com/samber/lo v1.53.0
|
||||
github.com/sanity-io/litter v1.5.8
|
||||
github.com/sasha-s/go-deadlock v0.3.9
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spkg/bom v1.0.1
|
||||
github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/xo/terminfo v1.0.0
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
|
|
@ -50,11 +53,9 @@ require (
|
|||
github.com/cli/safeexec v1.0.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/color v1.9.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hpcloud/tail v1.0.0 // indirect
|
||||
github.com/invopop/jsonschema v0.10.0 // indirect
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
|
||||
|
|
@ -63,16 +64,16 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/onsi/ginkgo v1.10.3 // indirect
|
||||
github.com/onsi/gomega v1.34.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
mvdan.cc/gofumpt v0.11.0 // indirect
|
||||
)
|
||||
|
||||
tool mvdan.cc/gofumpt
|
||||
|
|
|
|||
53
go.sum
53
go.sum
|
|
@ -25,22 +25,20 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
|
||||
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
|
||||
github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU=
|
||||
github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
|
||||
|
|
@ -75,8 +73,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
|
|
@ -100,12 +98,11 @@ github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeD
|
|||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
|
||||
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
|
|
@ -114,8 +111,8 @@ github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rY
|
|||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
|
||||
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs=
|
||||
|
|
@ -125,28 +122,30 @@ github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304/go.
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -175,14 +174,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
@ -196,5 +195,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
|
||||
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/maps"
|
||||
"github.com/jesseduffield/lazycore/pkg/utils"
|
||||
"github.com/jesseduffield/lazygit/pkg/app"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ func CommandToRun() string {
|
|||
}
|
||||
|
||||
func GetKeybindingsDir() string {
|
||||
return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
|
||||
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings"
|
||||
}
|
||||
|
||||
func generateAtDir(cheatsheetDir string) {
|
||||
|
|
@ -196,7 +196,7 @@ func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
|
|||
|
||||
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
|
||||
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings)
|
||||
|
||||
for _, section := range bindingSections {
|
||||
content.WriteString(formatTitle(section.title))
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi
|
|||
}
|
||||
|
||||
if workingTreeState.Rebasing {
|
||||
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, addConflictedRebasingCommit)
|
||||
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, commits, addConflictedRebasingCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -251,8 +251,8 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line
|
|||
})
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) ([]*models.Commit, error) {
|
||||
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), false)
|
||||
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, existingCommits []*models.Commit, addConflictingCommit bool) ([]*models.Commit, error) {
|
||||
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), existingCommits, false)
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) {
|
||||
|
|
@ -271,39 +271,56 @@ func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool
|
|||
}
|
||||
}
|
||||
|
||||
return self.getHydratedTodoCommits(hashPool, commits, true)
|
||||
return self.getHydratedTodoCommits(hashPool, commits, nil, true)
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) {
|
||||
func (self *CommitLoader) getHydratedTodoCommits(
|
||||
hashPool *utils.StringPool,
|
||||
todoCommits []*models.Commit,
|
||||
existingCommits []*models.Commit,
|
||||
todoFileHasShortHashes bool,
|
||||
) ([]*models.Commit, error) {
|
||||
if len(todoCommits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
|
||||
return commit.Hash(), commit.Hash() != ""
|
||||
})
|
||||
|
||||
// note that we're not filtering these as we do non-rebasing commits just because
|
||||
// I suspect that will cause some damage
|
||||
cmdObj := self.cmd.New(
|
||||
NewGitCmd("show").
|
||||
Config("log.showSignature=false").
|
||||
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
|
||||
Arg(commitHashes...).
|
||||
ToArgv(),
|
||||
).DontLog()
|
||||
|
||||
// A refresh of only the rebasing todos should reuse the already loaded todos to avoid
|
||||
// unnecessary git show calls.
|
||||
fullCommits := map[string]*models.Commit{}
|
||||
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
|
||||
if line == "" || line[0] != '+' {
|
||||
return false, nil
|
||||
for _, commit := range existingCommits {
|
||||
if commit.IsTODO() && commit.Hash() != "" {
|
||||
// Make a copy of the commit; that's necessary to avoid mutating the original commit
|
||||
// when we later reuse it in the loop at the end of this function.
|
||||
fullCommits[commit.Hash()] = lo.ToPtr(*commit)
|
||||
}
|
||||
commit := self.extractCommitFromLine(hashPool, line[1:], false)
|
||||
fullCommits[commit.Hash()] = commit
|
||||
return false, nil
|
||||
}
|
||||
|
||||
commitHashesToFetch := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
|
||||
return commit.Hash(), commit.Hash() != "" && fullCommits[commit.Hash()] == nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
if len(commitHashesToFetch) > 0 {
|
||||
// note that we're not filtering these as we do non-rebasing commits just because
|
||||
// I suspect that will cause some damage
|
||||
cmdObj := self.cmd.New(
|
||||
NewGitCmd("show").
|
||||
Config("log.showSignature=false").
|
||||
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
|
||||
Arg(commitHashesToFetch...).
|
||||
ToArgv(),
|
||||
).DontLog()
|
||||
|
||||
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
|
||||
if line == "" || line[0] != '+' {
|
||||
return false, nil
|
||||
}
|
||||
commit := self.extractCommitFromLine(hashPool, line[1:], false)
|
||||
fullCommits[commit.Hash()] = commit
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
findFullCommit := lo.Ternary(todoFileHasShortHashes,
|
||||
|
|
|
|||
|
|
@ -538,6 +538,110 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommitLoaderGetHydratedTodoCommitsReusesExistingCommit(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
runner := oscommands.NewFakeRunner(t)
|
||||
loader := &CommitLoader{
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(runner),
|
||||
}
|
||||
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: "0123456789012345678901234567890123456789",
|
||||
Name: "hydrated subject",
|
||||
AuthorName: "Jane Doe",
|
||||
AuthorEmail: "jane@example.com",
|
||||
UnixTimestamp: 1234,
|
||||
Parents: []string{"1123456789012345678901234567890123456789"},
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
})
|
||||
refreshedTodo := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingCommit.Hash(),
|
||||
Name: "subject from the todo file",
|
||||
Status: models.StatusConflicted,
|
||||
Action: todo.Fixup,
|
||||
ActionFlag: "-C",
|
||||
})
|
||||
|
||||
commits, err := loader.getHydratedTodoCommits(
|
||||
hashPool,
|
||||
[]*models.Commit{refreshedTodo},
|
||||
[]*models.Commit{existingCommit},
|
||||
false,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*models.Commit{
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingCommit.Hash(),
|
||||
Name: "hydrated subject",
|
||||
AuthorName: "Jane Doe",
|
||||
AuthorEmail: "jane@example.com",
|
||||
UnixTimestamp: 1234,
|
||||
Parents: []string{"1123456789012345678901234567890123456789"},
|
||||
Status: models.StatusConflicted,
|
||||
Action: todo.Fixup,
|
||||
ActionFlag: "-C",
|
||||
}),
|
||||
}, commits)
|
||||
assert.Equal(t, todo.Pick, existingCommit.Action)
|
||||
assert.Equal(t, models.StatusRebasing, existingCommit.Status)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCommitLoaderGetHydratedTodoCommitsLoadsMissingCommit(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
existingHash := "0123456789012345678901234567890123456789"
|
||||
missingHash := "2123456789012345678901234567890123456789"
|
||||
missingCommitOutput := strings.ReplaceAll(
|
||||
`+2123456789012345678901234567890123456789|1235|John Doe|john@example.com||>|tag: new|new subject`,
|
||||
"|",
|
||||
"\x00",
|
||||
)
|
||||
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(
|
||||
[]string{
|
||||
"-c", "log.showSignature=false", "show", "--no-patch", "--oneline", "--abbrev=20",
|
||||
prettyFormat, missingHash,
|
||||
},
|
||||
missingCommitOutput,
|
||||
nil,
|
||||
)
|
||||
loader := &CommitLoader{
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(runner),
|
||||
}
|
||||
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingHash,
|
||||
Name: "existing subject",
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
})
|
||||
refreshedTodos := []*models.Commit{
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingHash,
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
}),
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: missingHash,
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Edit,
|
||||
}),
|
||||
}
|
||||
|
||||
commits, err := loader.getHydratedTodoCommits(
|
||||
hashPool,
|
||||
refreshedTodos,
|
||||
[]*models.Commit{existingCommit},
|
||||
false,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commits, 2)
|
||||
assert.Equal(t, "existing subject", commits[0].Name)
|
||||
assert.Equal(t, "new subject", commits[1].Name)
|
||||
assert.Equal(t, todo.Edit, commits[1].Action)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCommitLoader_setCommitStatuses(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package git_commands
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -91,26 +90,6 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
|||
|
||||
self.setConflictMarkerSizes(files)
|
||||
|
||||
// Go through the files to see if any of these files are actually worktrees
|
||||
// so that we can render them correctly
|
||||
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
|
||||
for _, file := range files {
|
||||
for _, worktreePath := range worktreePaths {
|
||||
absFilePath, err := filepath.Abs(file.Path)
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
continue
|
||||
}
|
||||
if absFilePath == worktreePath {
|
||||
file.IsWorktree = true
|
||||
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
|
||||
// If we include the slash, it will be rendered as a folder with a null file inside.
|
||||
file.Path = strings.TrimSuffix(file.Path, "/")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
|||
return queryString, variables
|
||||
}
|
||||
|
||||
// GetAuthToken returns the token to authenticate against the given host with,
|
||||
// or an empty string if there is none.
|
||||
//
|
||||
// The token has to come from gh itself rather than from an in-process lookup
|
||||
// with go-gh: that reads gh's config file once per process and answers from
|
||||
// that snapshot ever after, whereas gh rewrites the file whenever the active
|
||||
// account changes, and keeps the active account's token either there or in the
|
||||
// system keyring. Under a long-running lazygit the snapshot therefore drifts
|
||||
// out of date, leaving us with a token for an account that is no longer active,
|
||||
// or with no token at all.
|
||||
func (self *GitHubCommands) GetAuthToken(host string) string {
|
||||
token, _ := auth.TokenForHost(host)
|
||||
return token
|
||||
ghExe := ghExecutable()
|
||||
if ghExe == "" {
|
||||
// Without gh installed, the environment variables and config file that
|
||||
// gh would have consulted are still worth a look.
|
||||
token, _ := auth.TokenFromEnvOrConfig(host)
|
||||
return token
|
||||
}
|
||||
|
||||
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
|
||||
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
// Not being logged in to this host is a normal state rather than
|
||||
// something to report; the runner logs gh's stderr for the rest.
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
// ghExecutable returns the path of the gh binary, or an empty string if it
|
||||
// isn't installed.
|
||||
func ghExecutable() string {
|
||||
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// A gh found in the current directory rather than on PATH comes back as
|
||||
// exec.ErrDot, which we treat as not having found one at all.
|
||||
ghExe, err := exec.LookPath("gh")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
ioFs "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -10,7 +9,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RepoPaths struct {
|
||||
|
|
@ -302,41 +300,3 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
|
|||
}
|
||||
return strings.TrimSpace(res), nil
|
||||
}
|
||||
|
||||
// Returns the paths of linked worktrees
|
||||
func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string {
|
||||
result := []string{}
|
||||
// For each directory in this path we're going to cat the `gitdir` file and append its contents to our result
|
||||
// That file points us to the `.git` file in the worktree.
|
||||
worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees")
|
||||
|
||||
// ensure the directory exists
|
||||
_, err := fs.Stat(worktreeGitDirsPath)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
_ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitDirPath := filepath.Join(currPath, "gitdir")
|
||||
gitDirBytes, err := afero.ReadFile(fs, gitDirPath)
|
||||
if err != nil {
|
||||
// ignoring error
|
||||
return nil
|
||||
}
|
||||
trimmedGitDir := strings.TrimSpace(string(gitDirBytes))
|
||||
// removing the .git part
|
||||
worktreeDir := filepath.Dir(trimmedGitDir)
|
||||
result = append(result, worktreeDir)
|
||||
return nil
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,27 +385,22 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
|
|||
// WorktreeFileDiff returns the diff of a file
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
|
||||
return s
|
||||
}
|
||||
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
|
||||
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
|
||||
// the node's path (used to diff only filtered/visible files within a directory).
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
|
||||
// in the working tree. node is the item they belong to; all it decides is
|
||||
// whether git has to compare against /dev/null, which is the case for a file
|
||||
// that isn't in the index yet.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
prevPath := node.GetPreviousPath()
|
||||
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
|
||||
|
||||
paths := pathOverrides
|
||||
if len(paths) == 0 {
|
||||
paths = []string{node.GetPath()}
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
|
|
@ -415,7 +410,6 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
|
|||
Arg("--").
|
||||
ArgIf(noIndex, "/dev/null").
|
||||
Arg(paths...).
|
||||
ArgIf(prevPath != "", prevPath).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv()
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@ func (p *winPty) Close() error {
|
|||
// slave closes on child exit, but ConPTY keeps the pipe alive until we call
|
||||
// ClosePseudoConsole explicitly. Without doing that on child exit, the
|
||||
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and
|
||||
// the post-content view never gets cleared (FlushStaleCells never fires).
|
||||
// the render would never reach its end of input, so the new content would
|
||||
// never be swapped in.
|
||||
func startWaiter(proc *os.Process, p *winPty) func() error {
|
||||
done := make(chan struct{})
|
||||
var waitErr error
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ func (self *Hunk) lineCount() int {
|
|||
|
||||
// Returns all lines in the hunk, including the header line
|
||||
func (self *Hunk) allLines() []*PatchLine {
|
||||
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
|
||||
lines := make([]*PatchLine, 1, 1+len(self.bodyLines))
|
||||
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
|
||||
lines = append(lines, self.bodyLines...)
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ type editPreset struct {
|
|||
suspend func() bool
|
||||
}
|
||||
|
||||
func returnBool(a bool) func() bool { return (func() bool { return a }) }
|
||||
func returnBool(a bool) func() bool { return func() bool { return a } }
|
||||
|
||||
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
|
||||
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
|
||||
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
|
||||
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
|
||||
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") {
|
||||
if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
|
||||
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
|
||||
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
|
||||
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`
|
||||
|
|
|
|||
|
|
@ -191,6 +191,51 @@ func validateCustomCommandKey(key Keybinding) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidCustomCommandContexts lists the names a custom command's 'context' may
|
||||
// use. It mirrors context.AllContextKeys in the gui package, which this package
|
||||
// can't import; a test over there keeps the two in sync.
|
||||
var ValidCustomCommandContexts = []string{
|
||||
"global",
|
||||
"status",
|
||||
"files",
|
||||
"localBranches",
|
||||
"remotes",
|
||||
"worktrees",
|
||||
"remoteBranches",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflogCommits",
|
||||
"subCommits",
|
||||
"commitFiles",
|
||||
"stash",
|
||||
"normal",
|
||||
"normalSecondary",
|
||||
"staging",
|
||||
"stagingSecondary",
|
||||
"patchBuilding",
|
||||
"patchBuildingSecondary",
|
||||
"mergeConflicts",
|
||||
"menu",
|
||||
"confirmation",
|
||||
"prompt",
|
||||
"search",
|
||||
"commitMessage",
|
||||
"submodules",
|
||||
"suggestions",
|
||||
"cmdLog",
|
||||
}
|
||||
|
||||
func validateCustomCommandContext(context string) error {
|
||||
for _, name := range strings.Split(context, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if !slices.Contains(ValidCustomCommandContexts, name) {
|
||||
return fmt.Errorf("Unknown context '%s' for custom command. Allowed values: %s",
|
||||
name, strings.Join(ValidCustomCommandContexts, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomCommands(customCommands []CustomCommand) error {
|
||||
for _, customCommand := range customCommands {
|
||||
if err := validateCustomCommandKey(customCommand.Key); err != nil {
|
||||
|
|
@ -216,6 +261,15 @@ func validateCustomCommands(customCommands []CustomCommand) error {
|
|||
return err
|
||||
}
|
||||
} else {
|
||||
// A command in a menu may leave the context out, in which case it is
|
||||
// offered whatever is focused; a top-level one may not, but that is
|
||||
// only noticed when the keybindings are built.
|
||||
if customCommand.Context != "" {
|
||||
if err := validateCustomCommandContext(customCommand.Context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, prompt := range customCommand.Prompts {
|
||||
if err := validateCustomCommandPrompt(prompt); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -225,6 +225,43 @@ func TestUserConfigValidate_enums(t *testing.T) {
|
|||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Context: value,
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "global", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "commits, subCommits", valid: true},
|
||||
{value: "commits,subCommits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
{value: "commits, invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context in a sub menu",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Key: Keybinding{"X"},
|
||||
CommandMenu: []CustomCommand{
|
||||
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command sub menu",
|
||||
setup: func(config *UserConfig, _ string) {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
|
|||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
doubleClicks := []bool{}
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
for _, event := range []GocuiEvent{
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool {
|
|||
v.TextArea.GoToEndOfLine()
|
||||
case key.Equals(NewKeyStrMod("y", ModCtrl)):
|
||||
v.TextArea.Yank()
|
||||
case key.Str() != "" && key.Mod() == 0:
|
||||
case key.IsPrintable():
|
||||
v.TextArea.TypeCharacter(key.Str())
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
114
pkg/gocui/gui.go
114
pkg/gocui/gui.go
|
|
@ -38,6 +38,11 @@ var (
|
|||
|
||||
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
|
||||
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
|
||||
|
||||
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
|
||||
// returned. Nothing dequeues user events after that, so the callback it was
|
||||
// asked to run on the main goroutine never will be.
|
||||
ErrLoopExited = standardErrors.New("main loop exited")
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -217,6 +222,11 @@ type Gui struct {
|
|||
// worker goroutines, so it's atomic.
|
||||
uiThreadID atomic.Int64
|
||||
|
||||
// focused says whether the terminal we're running in has focus, as far as
|
||||
// its focus reports tell us (see IsFocused). Written by the event loop,
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
|
|
@ -301,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
|||
// runs during startup, before we reach MainLoop.
|
||||
g.uiThreadID.Store(goid.Get())
|
||||
|
||||
// Assume we start out focused: a terminal that supports focus reports sends
|
||||
// one for the state it is already in when we turn reporting on in MainLoop,
|
||||
// and passing that on as a change would have the app react to a change that
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
|
|
@ -659,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
|
|||
}
|
||||
|
||||
// SetTabClickBinding sets a binding for a tab click event
|
||||
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error {
|
||||
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) {
|
||||
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
|
||||
viewName: viewName,
|
||||
handler: handler,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
|
||||
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) {
|
||||
g.viewMouseBindings = append(g.viewMouseBindings, binding)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureMouse routes subsequent mouse events to view until the mouse button is
|
||||
|
|
@ -893,36 +905,50 @@ func (g *Gui) EndBlockingEvents() error {
|
|||
}
|
||||
|
||||
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
|
||||
// caller until f has run, returning f's error. Use it to read UI-thread-owned
|
||||
// state (the model, contexts) from a worker without racing the UI thread.
|
||||
// caller until f has run. Use it to read UI-thread-owned state (the model,
|
||||
// contexts) from a worker without racing the UI thread.
|
||||
//
|
||||
// The error it returns is the wait's own, never f's: it reports that f was not
|
||||
// run at all, which happens when the main loop has exited (ErrLoopExited). f
|
||||
// doesn't report an error because what callers want on the UI thread — reading
|
||||
// and mutating state — doesn't fail.
|
||||
//
|
||||
// It must be called from a worker goroutine, never from the UI thread itself:
|
||||
// the UI thread would block waiting for a callback only it can run, which
|
||||
// deadlocks. Callers arrange this by construction (see the refresh helper's
|
||||
// RefreshFromWorker); a debug-only assertion there guards against getting it
|
||||
// wrong.
|
||||
func (g *Gui) OnUIThreadAndWait(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWait(f func()) error {
|
||||
return g.onUIThreadAndWait(f, false)
|
||||
}
|
||||
|
||||
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
|
||||
// so it doesn't count towards the program being busy (see UpdateBackground).
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
|
||||
return g.onUIThreadAndWait(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
|
||||
func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
|
||||
enqueue := g.Update
|
||||
if background {
|
||||
enqueue = g.UpdateBackground
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
ran := make(chan struct{})
|
||||
enqueue(func(*Gui) error {
|
||||
result <- f()
|
||||
f()
|
||||
close(ran)
|
||||
return nil
|
||||
})
|
||||
return <-result
|
||||
|
||||
select {
|
||||
case <-ran:
|
||||
return nil
|
||||
case <-g.loopExited:
|
||||
// The queue we just enqueued onto is no longer being served, so waiting
|
||||
// on `ran` here would mean waiting for the rest of the process's life.
|
||||
return ErrLoopExited
|
||||
}
|
||||
}
|
||||
|
||||
// Calls a function in a goroutine. Handles panics gracefully and tracks
|
||||
|
|
@ -1246,7 +1272,7 @@ func calcScrollbarRune(
|
|||
|
||||
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
|
||||
height := v.InnerHeight()
|
||||
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
|
||||
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
|
||||
|
||||
if v.CanScrollPastBottom {
|
||||
fullHeight += height
|
||||
|
|
@ -1428,7 +1454,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
|
|||
currentBgColor = v.BgColor
|
||||
}
|
||||
|
||||
if i >= currentTabStart && i <= currentTabEnd {
|
||||
if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() {
|
||||
currentFgColor = v.SelFgColor
|
||||
if v != g.currentView {
|
||||
currentFgColor &= ^AttrBold
|
||||
|
|
@ -1467,7 +1493,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
|
|||
|
||||
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1589,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
|
|||
return g.flushContentOnly(views)
|
||||
}
|
||||
|
||||
// hasFocus reports whether a view is drawn as focused. Views that are embedded
|
||||
// in one another (see View.ParentView) form a single unit, so they are all drawn
|
||||
// as focused while any one of them is the current view.
|
||||
func (g *Gui) hasFocus(v *View) bool {
|
||||
return g.currentView != nil && outermostView(v) == outermostView(g.currentView)
|
||||
}
|
||||
|
||||
func outermostView(v *View) *View {
|
||||
for v.ParentView != nil {
|
||||
v = v.ParentView
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// draw manages the cursor and calls the draw function of a view.
|
||||
func (g *Gui) draw(v *View) error {
|
||||
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
|
||||
|
|
@ -1609,11 +1649,11 @@ func (g *Gui) draw(v *View) error {
|
|||
Screen.HideCursor()
|
||||
}
|
||||
|
||||
v.draw()
|
||||
v.draw(g.IsFocused())
|
||||
|
||||
if v.Frame {
|
||||
var fgColor, bgColor, frameColor Attribute
|
||||
if g.Highlight && v == g.currentView {
|
||||
if g.Highlight && g.hasFocus(v) && g.IsFocused() {
|
||||
fgColor = g.SelFgColor
|
||||
bgColor = g.SelBgColor
|
||||
frameColor = g.SelFrameColor
|
||||
|
|
@ -1717,13 +1757,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
if newY < 0 {
|
||||
newY = 0
|
||||
newCy = -v.oy
|
||||
} else if newY >= len(v.lines) {
|
||||
newY = len(v.lines) - 1
|
||||
} else if newY >= len(v.buf.lines) {
|
||||
newY = len(v.buf.lines) - 1
|
||||
newCy = newY - v.oy
|
||||
}
|
||||
|
||||
visibleLineWidth := 0
|
||||
for _, c := range v.lines[newY].cells {
|
||||
for _, c := range v.buf.lines[newY].cells {
|
||||
visibleLineWidth += c.width
|
||||
}
|
||||
if visibleLineWidth < newX {
|
||||
|
|
@ -1733,10 +1773,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
}
|
||||
|
||||
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
|
||||
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
|
||||
if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
if link := v.hyperlinkAt(newX, newY); link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1955,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
|
|||
matchingParentViewKb = nil
|
||||
break
|
||||
}
|
||||
if v != nil && g.matchView(v.ParentView, kb) {
|
||||
if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) {
|
||||
matchingParentViewKb = kb
|
||||
}
|
||||
if globalKb == nil && kb.viewName == "" {
|
||||
|
|
@ -1990,7 +2028,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// IsFocused reports whether the terminal we're running in has focus. Terminals
|
||||
// that don't report focus at all leave this true for good.
|
||||
func (g *Gui) IsFocused() bool {
|
||||
return g.focused.Load()
|
||||
}
|
||||
|
||||
func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// Terminals report their focus state when we turn focus reporting on, and
|
||||
// some report it again when their window is activated, so only pass on the
|
||||
// reports that actually change it.
|
||||
if ev.Focused == g.focused.Load() {
|
||||
return nil
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
|
|
@ -2053,13 +2105,15 @@ func (g *Gui) isSuspended() bool {
|
|||
return g.suspended
|
||||
}
|
||||
|
||||
// matchView returns if the keybinding matches the current view (and the view's context)
|
||||
// matchView returns if the keybinding matches the given view (and the view's context)
|
||||
func (g *Gui) matchView(v *View, kb *keybinding) bool {
|
||||
// if the user is typing in a field, ignore char keys
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
|
||||
// If the user is typing in a field, printable keys are theirs to type, so no
|
||||
// keybinding gets a look at them: not the field's own, and not those of the
|
||||
// view it is embedded in either.
|
||||
if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() {
|
||||
return false
|
||||
}
|
||||
if kb.viewName != v.name {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ func (k Key) IsSet() bool {
|
|||
return k.keyName != 0
|
||||
}
|
||||
|
||||
// IsPrintable reports whether the key stands for a character that can be typed
|
||||
// into a text field.
|
||||
func (k Key) IsPrintable() bool {
|
||||
return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone
|
||||
}
|
||||
|
||||
func (k Key) Equals(otherKey Key) bool {
|
||||
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
|
||||
}
|
||||
|
|
|
|||
16
pkg/gocui/key_test.go
Normal file
16
pkg/gocui/key_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeyIsPrintable(t *testing.T) {
|
||||
assert.True(t, NewKeyRune('x').IsPrintable())
|
||||
assert.True(t, NewKeyRune('界').IsPrintable())
|
||||
assert.True(t, NewKeyRune(' ').IsPrintable())
|
||||
assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable())
|
||||
assert.False(t, NewKeyName(KeyEnter).IsPrintable())
|
||||
assert.False(t, Key{}.IsPrintable())
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
|
|||
},
|
||||
},
|
||||
} {
|
||||
assert.NoError(t, g.SetViewClickBinding(binding))
|
||||
g.SetViewClickBinding(binding)
|
||||
}
|
||||
|
||||
g.captureMouse(view)
|
||||
|
|
@ -69,7 +69,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
|
|||
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
|
|
@ -77,7 +77,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
|
|||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
|
|
@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
|
|||
view.Tabs = []string{"first", "second"}
|
||||
|
||||
clickedTabs := []int{}
|
||||
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
|
||||
g.SetTabClickBinding("tabs", func(tabIndex int) error {
|
||||
clickedTabs = append(clickedTabs, tabIndex)
|
||||
return nil
|
||||
}))
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
|
|
@ -172,7 +172,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
|
|||
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
|
|
@ -180,7 +180,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
|
|||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
g.captureMouse(left)
|
||||
|
|
|
|||
142
pkg/gocui/parent_view_test.go
Normal file
142
pkg/gocui/parent_view_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A view and its parent view, with the child holding the focus.
|
||||
func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) {
|
||||
t.Helper()
|
||||
|
||||
parent, _ := g.SetView("parent", 0, 0, 20, 10, 0)
|
||||
child, _ := g.SetView("child", 0, 10, 20, 12, 0)
|
||||
child.ParentView = parent
|
||||
_, err := g.SetCurrentView(child.Name())
|
||||
assert.NoError(t, err)
|
||||
|
||||
return parent, child
|
||||
}
|
||||
|
||||
func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
|
||||
pressed := []string{}
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
pressed = append(pressed, "parent")
|
||||
return nil
|
||||
})
|
||||
g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error {
|
||||
pressed = append(pressed, "child")
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)}))
|
||||
|
||||
assert.Equal(t, []string{"parent", "child"}, pressed)
|
||||
}
|
||||
|
||||
func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, _ := setupParentAndChildView(t, g)
|
||||
|
||||
pressed := []string{}
|
||||
for _, name := range []string{"first", "second"} {
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
pressed = append(pressed, name)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
|
||||
assert.Equal(t, []string{"first"}, pressed)
|
||||
}
|
||||
|
||||
func TestEmbeddedViewsAreFocusedTogether(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0)
|
||||
sibling.ParentView = parent
|
||||
unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0)
|
||||
|
||||
assert.True(t, g.hasFocus(child))
|
||||
assert.True(t, g.hasFocus(parent))
|
||||
assert.True(t, g.hasFocus(sibling))
|
||||
assert.False(t, g.hasFocus(unrelated))
|
||||
|
||||
_, err := g.SetCurrentView(unrelated.Name())
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, g.hasFocus(unrelated))
|
||||
assert.False(t, g.hasFocus(parent))
|
||||
assert.False(t, g.hasFocus(child))
|
||||
}
|
||||
|
||||
func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
keybindOnEdit bool
|
||||
declineKeybinding bool
|
||||
expectedPresses int
|
||||
expectedEdits int
|
||||
}{
|
||||
{name: "the field gets the key", expectedEdits: 1},
|
||||
{name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1},
|
||||
{
|
||||
name: "the field gets the key the parent view declined",
|
||||
keybindOnEdit: true,
|
||||
declineKeybinding: true,
|
||||
expectedPresses: 1,
|
||||
expectedEdits: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
child.Editable = true
|
||||
child.KeybindOnEdit = test.keybindOnEdit
|
||||
|
||||
edits := 0
|
||||
child.Editor = EditorFunc(func(*View, Key) bool {
|
||||
edits++
|
||||
return true
|
||||
})
|
||||
presses := 0
|
||||
g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error {
|
||||
presses++
|
||||
if test.declineKeybinding {
|
||||
return ErrKeybindingNotHandled
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')}))
|
||||
|
||||
assert.Equal(t, test.expectedPresses, presses)
|
||||
assert.Equal(t, test.expectedEdits, edits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
|
||||
edited := []Key{}
|
||||
child.Editable = true
|
||||
child.Editor = EditorFunc(func(_ *View, key Key) bool {
|
||||
edited = append(edited, key)
|
||||
return true
|
||||
})
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
return ErrKeybindingNotHandled
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
|
||||
assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited)
|
||||
}
|
||||
58
pkg/gocui/search_test.go
Normal file
58
pkg/gocui/search_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// writeLines writes the given lines to the view, as a task rendering content into it
|
||||
// does: one line at a time.
|
||||
func writeLines(v *View, lines ...string) {
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(v, "%s\n", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchStatusAfterTheMatchesChange(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 40, 10, OutputNormal)
|
||||
writeLines(v, "match", "other", "match", "other", "match")
|
||||
|
||||
v.Search("match", nil)
|
||||
_ = v.gotoNextMatch()
|
||||
_ = v.gotoNextMatch()
|
||||
index, total := v.GetSearchStatus()
|
||||
assert.Equal(t, 2, index)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// The content is re-rendered with only the first of those matches left in it.
|
||||
v.Clear()
|
||||
writeLines(v, "match", "other", "other")
|
||||
|
||||
index, total = v.GetSearchStatus()
|
||||
assert.Equal(t, 0, index)
|
||||
assert.Equal(t, 1, total)
|
||||
}
|
||||
|
||||
func TestSearchPositionsFollowStreamedContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 40, 10, OutputNormal)
|
||||
v.Search("match", nil)
|
||||
|
||||
// A render arrives a line at a time, and the status describes all of it.
|
||||
writeLines(v, "other", "match", "other", "match")
|
||||
|
||||
_, total := v.GetSearchStatus()
|
||||
assert.Equal(t, 2, total)
|
||||
}
|
||||
|
||||
func BenchmarkWriteToSearchedView(b *testing.B) {
|
||||
for b.Loop() {
|
||||
v := NewView("name", 0, 0, 100, 40, OutputNormal)
|
||||
v.Search("match", nil)
|
||||
for i := range 2000 {
|
||||
fmt.Fprintf(v, "line %d of a diff, most of which does not match\n", i)
|
||||
}
|
||||
v.GetSearchStatus()
|
||||
}
|
||||
}
|
||||
|
|
@ -300,15 +300,15 @@ func (g *Gui) pollEvent() GocuiEvent {
|
|||
if g.playRecording {
|
||||
select {
|
||||
case ev := <-g.replayedEvents.Keys:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.Resizes:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
case ev := <-g.replayedEvents.MouseEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.FocusEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
43
pkg/gocui/ui_thread_test.go
Normal file
43
pkg/gocui/ui_thread_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// errStillWaiting stands in for the result of a wait that hasn't produced one.
|
||||
var errStillWaiting = errors.New("still waiting")
|
||||
|
||||
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
|
||||
// returned by the time we give up on it.
|
||||
func resultOrTimeout(result chan error) error {
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
return errStillWaiting
|
||||
}
|
||||
}
|
||||
|
||||
// A worker waiting for the UI thread must not be left parked there once the
|
||||
// main loop has stopped: nothing will ever run its callback, and the shutdown
|
||||
// that follows blocks until such workers have finished (see
|
||||
// tasks.ViewBufferManager.Close).
|
||||
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
|
||||
// Closing this is what MainLoop returning does. From here on nothing
|
||||
// dequeues user events, so the callback below is never going to run.
|
||||
close(g.loopExited)
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- g.OnUIThreadAndWait(func() {})
|
||||
}()
|
||||
|
||||
err := resultOrTimeout(result)
|
||||
assert.ErrorIs(t, err, ErrLoopExited)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/gdamore/tcell/v3/color"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -101,15 +102,13 @@ func TestWriteString(t *testing.T) {
|
|||
for _, test := range tests {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal)
|
||||
for _, l := range test.existingLines {
|
||||
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
|
||||
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
|
||||
}
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.lines {
|
||||
resultingLines = append(resultingLines, cellsToStrings(l.cells))
|
||||
}
|
||||
resultingLines := lo.Map(v.buf.lines,
|
||||
func(l lineType, _ int) []string { return cellsToStrings(l.cells) })
|
||||
assert.Equal(t, test.expectedLines, resultingLines)
|
||||
}
|
||||
}
|
||||
|
|
@ -144,19 +143,115 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
|||
|
||||
v.writeString("htt")
|
||||
// No hyperlinks are generated for incomplete URLs
|
||||
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same line makes the link complete (even
|
||||
// though we didn't see a newline yet)
|
||||
v.writeString("ps://example.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
|
||||
v.Clear()
|
||||
// Valid but incomplete URL
|
||||
v.writeString("https://exa")
|
||||
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same fixes the link
|
||||
v.writeString("mple.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
func TestOffscreenRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString("a\nb\nc")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Render new, longer content off-screen.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("w\nx\ny\nz")
|
||||
|
||||
// The displayed buffer is untouched: readers still see the previous render.
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Swapping in reveals the new content in one step.
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
|
||||
|
||||
// A further write now appends to the displayed buffer directly.
|
||||
v.writeString("\nmore")
|
||||
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// When a render produces fewer view lines than the previous one,
|
||||
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
|
||||
// than leaving the previous render's entries in the tail: with the off-screen
|
||||
// render there is no half-loaded buffer whose tail we'd want to keep showing,
|
||||
// and a leftover tail is just stale lines describing content that is gone.
|
||||
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Two lines of 27 characters each wrap into 3 view lines apiece.
|
||||
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
|
||||
// Re-render with three short, unwrapped lines: only 3 view lines remain.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("aaa\nbbb\nccc")
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
// snap back as the rest streams in. See View.scrollbarHeightFloor.
|
||||
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
// Initial render: 100 lines, scrolled well down.
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.SetOrigin(0, 80)
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A re-render begins while the previous render is still shown: hold the
|
||||
// scrollbar height at the current value.
|
||||
v.FreezeScrollbarHeight()
|
||||
|
||||
// The off-screen render swaps in only a screenful at its first paint.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Repeat("y\n", 30))
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
// The displayed buffer is now short, but the scrollbar height stays held, so
|
||||
// the thumb keeps its position instead of jumping.
|
||||
assert.Equal(t, 30, v.ViewLinesHeight())
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// The rest of the content streams in.
|
||||
v.writeString(strings.Repeat("y\n", 70))
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// Once the load ends, the scrollbar tracks the real content directly again.
|
||||
v.UnfreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
|
||||
// before it reaches its end, the held scrollbar height must be released, so the
|
||||
// scrollbar reflects the new content rather than the abandoned load's height.
|
||||
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.FreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A synchronous render replaces the content before the (notional) load ends.
|
||||
v.SetContent("just a few\nshort lines\nhere")
|
||||
assert.Equal(t, 3, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
|
|
@ -233,7 +328,7 @@ func TestContainsColoredText(t *testing.T) {
|
|||
for j, cells := range test.lines {
|
||||
lines[j] = lineType{cells: cells}
|
||||
}
|
||||
v := &View{lines: lines}
|
||||
v := &View{buf: &viewBuffer{lines: lines}}
|
||||
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
|
||||
}
|
||||
}
|
||||
|
|
@ -248,8 +343,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
|
|||
// "a", then "skip to row 3" (i.e. one blank row), then "b".
|
||||
v.writeString("a\r\n\x1b[3;1Hb\r\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +364,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
|
||||
v.writeString("c\x1b[5;1Hd\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -282,6 +377,31 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
|
||||
// Soft-wrap counting has to work in an off-screen render too: the content
|
||||
// width the parser counts wraps against is set by SetContentWidth before the
|
||||
// render starts, so the off-screen buffer's parser has to pick it up. If it
|
||||
// doesn't, no wraps are counted and the CUP below is evaluated against a
|
||||
// stale row, overshooting into an extra blank line.
|
||||
v := NewView("name", 0, 0, 30, 30, OutputNormal)
|
||||
v.SetContentWidth(5)
|
||||
|
||||
v.BeginOffscreenRender()
|
||||
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
|
||||
// row 2; CUP to row 3 should then skip no rows at all.
|
||||
v.writeString("aaaaaaa\x1b[3;1Hb\n")
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
{"a", "a", "a", "a", "a", "a", "a"},
|
||||
{"b"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorForwardEscape(t *testing.T) {
|
||||
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
|
||||
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
|
||||
|
|
@ -292,8 +412,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
|
|||
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
|
||||
v.writeString("a\x1b[5X\x1b[5Cb\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +432,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
|
|||
v.writeString("abcdefghij\n")
|
||||
v.writeString("\x1b[4;1Hxyz\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -344,11 +464,7 @@ func cellsToString(cells []cell) string {
|
|||
}
|
||||
|
||||
func cellsToStrings(cells []cell) []string {
|
||||
s := []string{}
|
||||
for _, c := range cells {
|
||||
s = append(s, c.chr)
|
||||
}
|
||||
return s
|
||||
return lo.Map(cells, func(c cell, _ int) string { return c.chr })
|
||||
}
|
||||
|
||||
func TestLineWrap(t *testing.T) {
|
||||
|
|
@ -534,7 +650,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
|
|||
// renders with bg=red. The trailing area past "foo" must NOT extend
|
||||
// the red bg because '\n' marks the line as cleanly terminated.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// First row: cells 1..3 are "foo" (render with red bg via reverse),
|
||||
// cells 4..10 are trailing and should be plain default.
|
||||
|
|
@ -560,7 +676,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
|
|||
// Reverse + red fg, "foo", no termination. The trailing cells past
|
||||
// "foo" should be plain default, NOT a continuation of the red bg.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Cells 4..10 are trailing and should be default with no reverse.
|
||||
for x := 4; x <= 10; x++ {
|
||||
|
|
@ -583,7 +699,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
|
|||
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
|
||||
// fill the remaining 8 cells with red.
|
||||
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All ten cells at (1..10, 1) should have red bg.
|
||||
for x := 1; x <= 10; x++ {
|
||||
|
|
@ -611,7 +727,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
|
|||
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
|
||||
// must pick up the red fill from \x1b[K.
|
||||
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All three wrapped rows should have the red fill background across
|
||||
// the full InnerWidth, including the trailing cells past each row's
|
||||
|
|
@ -645,7 +761,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
|||
// last cell red) and segment 2 is "ccc" (green, last cell green).
|
||||
// \x1b[K records the green bg on the source line.
|
||||
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Row 1's content ends with a red cell at x=7, so trailing columns
|
||||
// 8..10 should pick up red rather than the \x1b[K's green.
|
||||
|
|
|
|||
|
|
@ -119,13 +119,12 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
|
|||
var appStatusHelper *helpers.AppStatusHelper
|
||||
var branchesHelper *helpers.BranchesHelper
|
||||
var fetchGeneration int
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
appStatusHelper = self.gui.helpers.AppStatus
|
||||
branchesHelper = self.gui.helpers.BranchesHelper
|
||||
fetchGeneration = self.gui.c.State().GetRepoGeneration()
|
||||
self.gui.State.LastBackgroundFetchTime = time.Now()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -184,10 +183,9 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
|||
// reading them from this background goroutine would race the reassignment.
|
||||
var git *commands.GitCommand
|
||||
var refreshHelper *helpers.RefreshHelper
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
refreshHelper = self.gui.helpers.Refresh
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package gui
|
|||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
|
|
@ -179,11 +180,8 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
|||
self.gui.helpers.Window.SetWindowContext(c)
|
||||
|
||||
self.gui.helpers.Window.MoveToTopOfWindow(c)
|
||||
oldView := self.gui.c.GocuiGui().CurrentView()
|
||||
if oldView != nil && oldView.Name() != viewName {
|
||||
oldView.HighlightInactive = true
|
||||
}
|
||||
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
|
||||
inputViewName := c.GetInputViewName()
|
||||
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
|
|
@ -198,9 +196,37 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
|||
|
||||
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
|
||||
|
||||
self.updateSelectionHighlights()
|
||||
|
||||
c.HandleFocus(opts)
|
||||
}
|
||||
|
||||
// updateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// them draw theirs as the active one: a view shows a selection while its context is
|
||||
// on the stack and has something to select, and the context the user is in shows the
|
||||
// active selection while the ones behind it show inactive ones.
|
||||
//
|
||||
// Both of those can change, so this is called wherever they do: from Activate, which
|
||||
// every change to the stack goes through; after a refresh, since that is when the
|
||||
// contents of a list change; and from whoever tells a context that its content has
|
||||
// gained or lost something to select.
|
||||
func (self *ContextMgr) updateSelectionHighlights() {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
onStack := set.NewFromSlice(lo.Map(self.ContextStack,
|
||||
func(c types.Context, _ int) types.ContextKey { return c.GetKey() }))
|
||||
currentKey := self.currentContextWithoutLock().GetKey()
|
||||
|
||||
for _, c := range self.allContexts.Flatten() {
|
||||
// The global context has no view of its own.
|
||||
if view := c.GetView(); view != nil {
|
||||
view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent()
|
||||
view.HighlightInactive = c.GetKey() != currentKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ContextMgr) Current() types.Context {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type BaseContext struct {
|
|||
hasControlledBounds bool
|
||||
needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
|
||||
needsRerenderOnHeightChange bool
|
||||
highlightOnFocus bool
|
||||
hasSelectableContent bool
|
||||
|
||||
*ParentContextMgr
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ type NewBaseContextOpts struct {
|
|||
Focusable bool
|
||||
Transient bool
|
||||
HasUncontrolledBounds bool // negating for the sake of making false the default
|
||||
HighlightOnFocus bool
|
||||
HasSelectableContent bool
|
||||
NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
|
||||
NeedsRerenderOnHeightChange bool
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ func NewBaseContext(opts NewBaseContextOpts) *BaseContext {
|
|||
focusable: opts.Focusable,
|
||||
transient: opts.Transient,
|
||||
hasControlledBounds: hasControlledBounds,
|
||||
highlightOnFocus: opts.HighlightOnFocus,
|
||||
hasSelectableContent: opts.HasSelectableContent,
|
||||
needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange,
|
||||
needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange,
|
||||
ParentContextMgr: &ParentContextMgr{},
|
||||
|
|
@ -102,6 +102,10 @@ func (self *BaseContext) GetViewName() string {
|
|||
return self.view.Name()
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetInputViewName() string {
|
||||
return self.GetViewName()
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetView() *gocui.View {
|
||||
return self.view
|
||||
}
|
||||
|
|
@ -114,12 +118,16 @@ func (self *BaseContext) GetKind() types.ContextKind {
|
|||
return self.kind
|
||||
}
|
||||
|
||||
func (self *BaseContext) HasSelectableContent() bool {
|
||||
return self.hasSelectableContent
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKey() types.ContextKey {
|
||||
return self.key
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{}
|
||||
bindings := make([]*types.Binding, 0, len(self.keybindingsFns))
|
||||
for i := range self.keybindingsFns {
|
||||
// the first binding in the bindings array takes precedence but we want the
|
||||
// last keybindingsFn to take precedence to we add them in reverse
|
||||
|
|
@ -216,7 +224,7 @@ func (self *BaseContext) AddOnQuitFn(fn func()) {
|
|||
}
|
||||
|
||||
func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
bindings := []*gocui.ViewMouseBinding{}
|
||||
bindings := make([]*gocui.ViewMouseBinding, 0, len(self.mouseKeybindingsFns))
|
||||
for i := range self.mouseKeybindingsFns {
|
||||
// the first binding in the bindings array takes precedence but we want the
|
||||
// last keybindingsFn to take precedence to we add them in reverse
|
||||
|
|
|
|||
22
pkg/gui/context/context_test.go
Normal file
22
pkg/gui/context/context_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The config package validates a custom command's context against its own copy of
|
||||
// these names, being unable to import this package. A name in one list but not the
|
||||
// other would be either a context that validation rejects although you can bind to
|
||||
// it, or one it accepts although binding to it exits lazygit.
|
||||
func TestValidCustomCommandContextsMatchesAllContextKeys(t *testing.T) {
|
||||
keys := lo.Map(AllContextKeys, func(key types.ContextKey, _ int) string {
|
||||
return string(key)
|
||||
})
|
||||
|
||||
assert.Equal(t, keys, config.ValidCustomCommandContexts)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
package context
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
|
||||
type FilteredListViewModel[T HasID] struct {
|
||||
*FilteredList[T]
|
||||
*ListViewModel[T]
|
||||
|
|
@ -35,8 +33,3 @@ func (self *FilteredListViewModel[T]) ClearFilter() {
|
|||
|
||||
self.SetSelection(unfilteredIndex)
|
||||
}
|
||||
|
||||
// Default implementation of most filterable contexts. Can be overridden if needed.
|
||||
func (self *FilteredListViewModel[T]) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
return tr.FilterPrefix
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,19 @@ type ListContextTrait struct {
|
|||
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search
|
||||
// result index.
|
||||
inOnSearchSelect bool
|
||||
|
||||
// If set, this renders the "x of y" footer instead of the default, which puts
|
||||
// it on the bottom border of the list's own view. A list that is part of a
|
||||
// composite panel can use this to put it somewhere else; see MenuContext.
|
||||
renderFooter func(footer string)
|
||||
}
|
||||
|
||||
func (self *ListContextTrait) IsListContext() {}
|
||||
|
||||
func (self *ListContextTrait) HasSelectableContent() bool {
|
||||
return self.list.Len() > 0
|
||||
}
|
||||
|
||||
func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
|
||||
self.Context.FocusLine(scrollIntoView)
|
||||
|
||||
|
|
@ -81,7 +90,13 @@ func (self *ListContextTrait) refreshViewport() {
|
|||
}
|
||||
|
||||
func (self *ListContextTrait) setFooter() {
|
||||
self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()))
|
||||
footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())
|
||||
if self.renderFooter != nil {
|
||||
self.renderFooter(footer)
|
||||
return
|
||||
}
|
||||
|
||||
self.GetViewTrait().SetFooter(footer)
|
||||
}
|
||||
|
||||
func formatListFooter(selectedLineIdx int, length int) string {
|
||||
|
|
@ -89,9 +104,7 @@ func formatListFooter(selectedLineIdx int, length int) string {
|
|||
}
|
||||
|
||||
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
|
||||
self.FocusLine(opts.ScrollSelectionIntoView)
|
||||
|
||||
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
|
||||
self.FocusLine(!opts.KeepScrollPosition)
|
||||
|
||||
self.Context.HandleFocus(opts)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ func NewMainContext(
|
|||
ctx := &MainContext{
|
||||
SimpleContext: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: false,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HasSelectableContent: false,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
|
@ -45,6 +44,13 @@ func NewMenuContext(
|
|||
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
|
||||
getNonModelItems: viewModel.GetNonModelItems,
|
||||
},
|
||||
// While the filter row is showing, its top border covers the menu's bottom
|
||||
// border, so the footer has to be rendered on the row instead.
|
||||
renderFooter: func(footer string) {
|
||||
onFilterRow := viewModel.FilterStarted()
|
||||
c.Views().Menu.Footer = lo.Ternary(onFilterRow, "", footer)
|
||||
c.Views().MenuFilterFrame.Footer = lo.Ternary(onFilterRow, footer, "")
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
|
|
@ -58,6 +64,8 @@ type MenuViewModel struct {
|
|||
columnAlignment []utils.Alignment
|
||||
allowFilteringKeybindings bool
|
||||
keybindingsTakePrecedence bool
|
||||
filterAsYouType bool
|
||||
filterStarted bool
|
||||
onCancel func() error
|
||||
*FilteredListViewModel[*types.MenuItem]
|
||||
}
|
||||
|
|
@ -128,10 +136,37 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
|
|||
self.allowFilteringKeybindings = allow
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
|
||||
return self.allowFilteringKeybindings
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
|
||||
self.keybindingsTakePrecedence = value
|
||||
}
|
||||
|
||||
// Whether this menu has a filter row that filters the items as the user types,
|
||||
// instead of being filtered through the search prompt.
|
||||
func (self *MenuViewModel) SetFilterAsYouType(value bool) {
|
||||
self.filterAsYouType = value
|
||||
self.SetFilterStarted(false)
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) FilterAsYouType() bool {
|
||||
return self.filterAsYouType
|
||||
}
|
||||
|
||||
// Whether the user has started to filter, which is when the filter row appears.
|
||||
func (self *MenuViewModel) SetFilterStarted(value bool) {
|
||||
self.filterStarted = value
|
||||
// As long as there is nothing to type into, printable keys keep driving the
|
||||
// menu, so that the configured navigation keys work like in any other menu.
|
||||
self.c.Views().MenuFilter.KeybindOnEdit = !value
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) FilterStarted() bool {
|
||||
return self.filterStarted
|
||||
}
|
||||
|
||||
// TODO: move into presentation package
|
||||
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
|
||||
menuItems := self.FilteredListViewModel.GetItems()
|
||||
|
|
@ -209,6 +244,16 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
|
|||
|
||||
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
basicBindings := self.ListContextTrait.GetKeybindings(opts)
|
||||
|
||||
if self.filterAsYouType {
|
||||
// A menu item's keys are shown as a reminder of what they do outside the
|
||||
// menu, but pressing one types it into the filter rather than executing the
|
||||
// item, so we don't bind them at all. That leaves the bindings that drive
|
||||
// the menu itself, and the printable ones among those give way to the filter
|
||||
// as soon as there is something to type into (see View.KeybindOnEdit).
|
||||
return basicBindings
|
||||
}
|
||||
|
||||
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
|
||||
return len(item.Keys) > 0
|
||||
})
|
||||
|
|
@ -267,10 +312,13 @@ func (self *MenuContext) RangeSelectEnabled() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
if self.allowFilteringKeybindings {
|
||||
return tr.FilterPrefixMenu
|
||||
// A menu that filters as you type points the keyboard at its filter input, so
|
||||
// that whatever the user types ends up there. Keys that the input doesn't take
|
||||
// still reach the menu, because the input view is embedded in the menu view.
|
||||
func (self *MenuContext) GetInputViewName() string {
|
||||
if self.filterAsYouType {
|
||||
return self.c.Views().MenuFilter.Name()
|
||||
}
|
||||
|
||||
return self.FilteredListViewModel.FilterPrefix(tr)
|
||||
return self.GetViewName()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ func NewMergeConflictsContext(
|
|||
viewModel: viewModel,
|
||||
Context: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: c.Views().MergeConflicts,
|
||||
WindowName: "main",
|
||||
Key: MERGE_CONFLICTS_CONTEXT_KEY,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: true,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: c.Views().MergeConflicts,
|
||||
WindowName: "main",
|
||||
Key: MERGE_CONFLICTS_CONTEXT_KEY,
|
||||
Focusable: true,
|
||||
HasSelectableContent: true,
|
||||
}),
|
||||
),
|
||||
c: c,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func NewPatchExplorerContext(
|
|||
Key: key,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: true,
|
||||
HasSelectableContent: true,
|
||||
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
|
|
|
|||
|
|
@ -33,21 +33,16 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string
|
|||
}
|
||||
|
||||
func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
|
||||
if self.highlightOnFocus {
|
||||
self.GetViewTrait().SetHighlight(true)
|
||||
}
|
||||
|
||||
for _, fn := range self.onFocusFns {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
if self.onRenderToMainFn != nil {
|
||||
if self.onRenderToMainFn != nil && !opts.SkipMainViewUpdate {
|
||||
self.onRenderToMainFn()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) {
|
||||
self.GetViewTrait().SetHighlight(false)
|
||||
self.view.SetOriginX(0)
|
||||
for _, fn := range self.onFocusLostFns {
|
||||
fn(opts)
|
||||
|
|
|
|||
|
|
@ -43,11 +43,6 @@ func (self *ViewTrait) SetContent(content string) {
|
|||
self.view.SetContent(content)
|
||||
}
|
||||
|
||||
func (self *ViewTrait) SetHighlight(highlight bool) {
|
||||
self.view.Highlight = highlight
|
||||
self.view.HighlightInactive = false
|
||||
}
|
||||
|
||||
func (self *ViewTrait) SetFooter(value string) {
|
||||
self.view.Footer = value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ var _ types.IListContext = (*WorktreesContext)(nil)
|
|||
func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Worktree { return c.Model().Worktrees },
|
||||
func(Worktree *models.Worktree) []string {
|
||||
return []string{Worktree.Name}
|
||||
func(worktree *models.Worktree) []string {
|
||||
return []string{worktree.Name, worktree.Branch}
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
common := controllers.NewControllerCommon(helperCommon, gui)
|
||||
|
||||
listControllerFactory := controllers.NewListControllerFactory(common)
|
||||
menuListController := listControllerFactory.Create(gui.State.Contexts.Menu)
|
||||
|
||||
syncController := controllers.NewSyncController(
|
||||
common,
|
||||
)
|
||||
|
|
@ -156,7 +159,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
remoteBranchesController := controllers.NewRemoteBranchesController(common)
|
||||
|
||||
menuController := controllers.NewMenuController(common)
|
||||
menuController := controllers.NewMenuController(common, menuListController)
|
||||
localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
|
||||
tagsController := controllers.NewTagsController(common)
|
||||
filesController := controllers.NewFilesController(
|
||||
|
|
@ -359,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
controllers.AttachControllers(gui.State.Contexts.Menu,
|
||||
menuController,
|
||||
menuListController,
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.CommitMessage,
|
||||
|
|
@ -412,8 +416,11 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
)
|
||||
|
||||
// this must come last so that we've got our click handlers defined against the context
|
||||
listControllerFactory := controllers.NewListControllerFactory(common)
|
||||
for _, context := range gui.c.Context().AllList() {
|
||||
if context == gui.State.Contexts.Menu {
|
||||
// already attached above, next to the menu controller that delegates to it
|
||||
continue
|
||||
}
|
||||
controllers.AttachControllers(context, listControllerFactory.Create(context))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,18 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
|
|||
}
|
||||
}
|
||||
|
||||
commitTagsItem := &types.MenuItem{
|
||||
Label: self.c.Tr.CommitTags,
|
||||
OnPress: func() error {
|
||||
return self.copyCommitTagsToClipboard(commit)
|
||||
},
|
||||
Keys: menuKey('t'),
|
||||
}
|
||||
|
||||
if len(commit.Tags) == 0 {
|
||||
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
|
||||
}
|
||||
|
||||
items := []*types.MenuItem{
|
||||
{
|
||||
Label: self.c.Tr.CommitHash,
|
||||
|
|
@ -207,22 +219,9 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
|
|||
},
|
||||
Keys: menuKey('a'),
|
||||
},
|
||||
commitTagsItem,
|
||||
}
|
||||
|
||||
commitTagsItem := types.MenuItem{
|
||||
Label: self.c.Tr.CommitTags,
|
||||
OnPress: func() error {
|
||||
return self.copyCommitTagsToClipboard(commit)
|
||||
},
|
||||
Keys: menuKey('t'),
|
||||
}
|
||||
|
||||
if len(commit.Tags) == 0 {
|
||||
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
|
||||
}
|
||||
|
||||
items = append(items, &commitTagsItem)
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
|
||||
Items: items,
|
||||
|
|
|
|||
|
|
@ -616,24 +616,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
|
|||
}
|
||||
}
|
||||
|
||||
// pathsForDiff returns the file paths to use for a diff command. When a text
|
||||
// filter is active and the node is a directory, only the visible (filtered)
|
||||
// file paths are returned so the diff reflects what the user sees.
|
||||
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
|
||||
if !node.IsFile() && self.context().IsFiltering() {
|
||||
var paths []string
|
||||
_ = node.ForEachFile(func(file *models.CommitFile) error {
|
||||
// For a rename we need to pass both paths so that git detects it as
|
||||
// a rename rather than an unrelated delete and add.
|
||||
paths = append(paths, file.Names()...)
|
||||
return nil
|
||||
})
|
||||
return paths
|
||||
}
|
||||
if file := node.GetFile(); file != nil {
|
||||
return file.Names()
|
||||
}
|
||||
return []string{node.GetPath()}
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
|
||||
}
|
||||
|
||||
// NOTE: these functions are identical to those in files_controller.go (except for types) and
|
||||
|
|
|
|||
132
pkg/gui/controllers/diff_paths.go
Normal file
132
pkg/gui/controllers/diff_paths.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Both models.File and models.CommitFile satisfy this. Names returns the file's
|
||||
// path, plus the path it was renamed from if it is a rename.
|
||||
type fileWithNames[T any] interface {
|
||||
*T
|
||||
GetPath() string
|
||||
GetPreviousPath() string
|
||||
Names() []string
|
||||
}
|
||||
|
||||
// diffPathsForNode returns the paths to limit a diff command to for showing the
|
||||
// changes of the given node. files are all the files that the diff contains,
|
||||
// while root is the root of the tree the node belongs to, which holds only the
|
||||
// files matching the text filter when there is one.
|
||||
func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string {
|
||||
if file := node.GetFile(); file != nil {
|
||||
return PT(file).Names()
|
||||
}
|
||||
|
||||
dir := node.GetPath()
|
||||
|
||||
if isFiltering {
|
||||
// Passing the directory would bring back the files that the filter hides,
|
||||
// so we spell out the ones it leaves.
|
||||
var paths []string
|
||||
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
|
||||
paths = append(paths, PT(file).Names()...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// The directory covers everything below it, but git only pairs up the two
|
||||
// ends of a rename if both are in the pathspec, and one end can well be
|
||||
// outside the directory. Without that end we would get an addition or a
|
||||
// deletion where the diff has a rename.
|
||||
var outsidePaths []string
|
||||
for _, f := range filesInDir[T, PT](files, dir) {
|
||||
file := PT(f)
|
||||
if p := file.GetPath(); !isInDir(p, dir) {
|
||||
outsidePaths = append(outsidePaths, p)
|
||||
}
|
||||
if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) {
|
||||
outsidePaths = append(outsidePaths, p)
|
||||
}
|
||||
}
|
||||
|
||||
return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...))
|
||||
}
|
||||
|
||||
// dropContainedPaths removes the paths that another one of them contains, since
|
||||
// a pathspec that matches a directory matches everything below it anyway.
|
||||
func dropContainedPaths(paths []string) []string {
|
||||
return lo.Filter(paths, func(p string, _ int) bool {
|
||||
return !lo.SomeBy(paths, func(other string) bool {
|
||||
return other != p && isInDir(p, other)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// collapseToDirs replaces each of the given paths with the highest directory
|
||||
// that can stand in for it, so that moving a whole directory elsewhere costs a
|
||||
// single pathspec rather than one per file. There is a limit to how long a
|
||||
// command line may get, and a commit can move a great many files at once.
|
||||
func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A directory can stand in for the paths under it as long as everything it
|
||||
// contains ends up in the diff anyway, which is to say as long as all of it
|
||||
// is in the directory we are diffing too.
|
||||
canStandIn := make(map[string]bool)
|
||||
standsIn := func(candidate string) bool {
|
||||
if result, ok := canStandIn[candidate]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
result := lo.EveryBy(files, func(file *T) bool {
|
||||
return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir)
|
||||
})
|
||||
canStandIn[candidate] = result
|
||||
return result
|
||||
}
|
||||
|
||||
return lo.Uniq(lo.Map(paths, func(p string, _ int) string {
|
||||
// A directory that can't stand in for the path rules out its parents
|
||||
// too, since they contain everything it contains. We stop short of the
|
||||
// repository root: it would leave the command with nothing to say about
|
||||
// the directory whose diff we are showing.
|
||||
for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) {
|
||||
p = candidate
|
||||
}
|
||||
return p
|
||||
}))
|
||||
}
|
||||
|
||||
func filesInTree[T any](root *filetree.Node[T]) []*T {
|
||||
files := []*T{}
|
||||
_ = root.ForEachFile(func(file *T) error {
|
||||
files = append(files, file)
|
||||
return nil
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
// filesInDir returns the files that the given directory contains, either at
|
||||
// their current or at their previous path.
|
||||
func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T {
|
||||
return lo.Filter(files, func(file *T, _ int) bool {
|
||||
return fileIsInDir[T, PT](file, dir)
|
||||
})
|
||||
}
|
||||
|
||||
func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool {
|
||||
file := PT(f)
|
||||
previousPath := file.GetPreviousPath()
|
||||
return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir))
|
||||
}
|
||||
|
||||
func isInDir(path string, dir string) bool {
|
||||
// "." is the root item, which contains every file
|
||||
return dir == "." || strings.HasPrefix(path, dir+"/")
|
||||
}
|
||||
113
pkg/gui/controllers/diff_paths_test.go
Normal file
113
pkg/gui/controllers/diff_paths_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiffPathsForNode(t *testing.T) {
|
||||
files := []*models.CommitFile{
|
||||
{Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"},
|
||||
{Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"},
|
||||
{Path: "dir/sub/file3", ChangeStatus: "M"},
|
||||
{Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"},
|
||||
{Path: "file5", ChangeStatus: "M"},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
files []*models.CommitFile // defaults to the files above
|
||||
selectedPath string
|
||||
isFiltering bool
|
||||
expectedPaths []string
|
||||
}{
|
||||
{
|
||||
testName: "file",
|
||||
selectedPath: "dir/sub/file3",
|
||||
expectedPaths: []string{"dir/sub/file3"},
|
||||
},
|
||||
{
|
||||
testName: "renamed file",
|
||||
selectedPath: "dir/file1",
|
||||
expectedPaths: []string{"dir/file1", "file1"},
|
||||
},
|
||||
{
|
||||
testName: "directory: pass the other end of each rename that crosses its boundary",
|
||||
selectedPath: "dir",
|
||||
// dir/file2-renamed was renamed within the directory, so both of its
|
||||
// paths are covered by it already
|
||||
expectedPaths: []string{"dir", "file1", "file4"},
|
||||
},
|
||||
{
|
||||
testName: "directory without renames crossing its boundary",
|
||||
selectedPath: "dir/sub",
|
||||
expectedPaths: []string{"dir/sub", "file4"},
|
||||
},
|
||||
{
|
||||
testName: "root",
|
||||
selectedPath: ".",
|
||||
expectedPaths: []string{"."},
|
||||
},
|
||||
{
|
||||
testName: "a whole directory moved into the selected one collapses to that directory",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
|
||||
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
|
||||
{Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"},
|
||||
{Path: "unrelated", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "dir",
|
||||
expectedPaths: []string{"dir", "src"},
|
||||
},
|
||||
{
|
||||
testName: "a directory that stands in for the selected one as well",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"},
|
||||
{Path: "a/b/d", ChangeStatus: "M"},
|
||||
{Path: "unrelated", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "a/b",
|
||||
expectedPaths: []string{"a"},
|
||||
},
|
||||
{
|
||||
testName: "a directory with changes of its own doesn't collapse",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
|
||||
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
|
||||
{Path: "src/nested/c", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "dir",
|
||||
// src/nested is left out of it, so that only src/a stays behind
|
||||
expectedPaths: []string{"dir", "src/a", "src/nested/b"},
|
||||
},
|
||||
{
|
||||
testName: "directory while filtering",
|
||||
selectedPath: "dir",
|
||||
isFiltering: true,
|
||||
expectedPaths: []string{
|
||||
"dir/file1", "file1",
|
||||
"dir/file2-renamed", "dir/file2",
|
||||
"dir/sub/file3",
|
||||
"file4", "dir/sub/file4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
files := lo.Ternary(s.files != nil, s.files, files)
|
||||
cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true)
|
||||
root := filetree.BuildTreeFromCommitFiles(files, true, cmp)
|
||||
node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool {
|
||||
return node.GetPath() == s.selectedPath
|
||||
})
|
||||
assert.True(t, found, "no node for path %s", s.selectedPath)
|
||||
|
||||
assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -130,10 +130,11 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
|||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
||||
Handler: self.toggleStagedAll,
|
||||
Description: self.c.Tr.ToggleStagedAll,
|
||||
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
|
||||
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
||||
Handler: self.toggleStagedAll,
|
||||
GetDisabledReason: self.require(self.anyFilesDisplayed),
|
||||
Description: self.c.Tr.ToggleStagedAll,
|
||||
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
|
||||
|
|
@ -368,8 +369,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
|
|
@ -384,7 +385,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
|
|
@ -642,19 +643,9 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// pathOverridesForDiff returns file paths to override the node's path in diff
|
||||
// commands when a text filter is active and the node is a directory. This
|
||||
// ensures the diff only shows filtered/visible files.
|
||||
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
|
||||
if !node.IsFile() && self.context().IsFiltering() {
|
||||
var paths []string
|
||||
_ = node.ForEachFile(func(file *models.File) error {
|
||||
paths = append(paths, file.Path)
|
||||
return nil
|
||||
})
|
||||
return paths
|
||||
}
|
||||
return nil
|
||||
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string {
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering())
|
||||
}
|
||||
|
||||
// unstageFilteredFiles unstages only the visible (filtered) files from the
|
||||
|
|
@ -916,6 +907,17 @@ func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error
|
|||
})
|
||||
}
|
||||
|
||||
// The stage-all command acts on the file tree as it is displayed, so there has
|
||||
// to be something in it. This is also the case before the first files refresh
|
||||
// has come in, when there is no tree at all yet.
|
||||
func (self *FilesController) anyFilesDisplayed() *types.DisabledReason {
|
||||
if self.context().FileTreeViewModel.Len() == 0 {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoChangedFiles}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) toggleStagedAll() error {
|
||||
if err := self.toggleStagedAllWithLock(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ func (self *FilterController) Context() types.Context {
|
|||
return self.context
|
||||
}
|
||||
|
||||
// A context that filters as the user types has an input field of its own, so it
|
||||
// has no use for the filter prompt.
|
||||
type contextThatFiltersAsYouType interface {
|
||||
FilterAsYouType() bool
|
||||
}
|
||||
|
||||
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*types.Binding{
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
|
||||
|
|
@ -44,5 +54,6 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type
|
|||
}
|
||||
|
||||
func (self *FilterController) OpenFilterPrompt() error {
|
||||
return self.c.Helpers().Search.OpenFilterPrompt(self.context)
|
||||
self.c.Helpers().Search.OpenFilterPrompt(self.context)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type ConfirmationHelper struct {
|
||||
|
|
@ -323,19 +324,70 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() {
|
|||
}
|
||||
}
|
||||
|
||||
// The rows that a filter row adds to a menu popup: one for the input, and one
|
||||
// for its bottom border. Its top border is the menu's bottom border.
|
||||
const menuFilterRowHeight = 2
|
||||
|
||||
// The prompts for the filter row, from the most to the least informative. The
|
||||
// keybindings menu can also filter by keybinding, which is worth spelling out
|
||||
// when there is room for it.
|
||||
func (self *ConfirmationHelper) menuFilterPromptCandidates() []string {
|
||||
if self.c.Contexts().Menu.AllowFilteringKeybindings() {
|
||||
return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix}
|
||||
}
|
||||
|
||||
return []string{self.c.Tr.FilterPrefix}
|
||||
}
|
||||
|
||||
// Returns the first prompt that still leaves room to type in, or no prompt at
|
||||
// all if the row is too narrow even for the shortest one.
|
||||
func menuFilterPrompt(candidates []string, contentWidth int) string {
|
||||
const minimumInputWidth = 4
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) {
|
||||
menuContext := self.c.Contexts().Menu
|
||||
// we want the unfiltered length here so that if we're filtering we don't
|
||||
// resize the window
|
||||
itemCount := self.c.Contexts().Menu.UnfilteredLen()
|
||||
itemCount := menuContext.UnfilteredLen()
|
||||
offset := 3
|
||||
panelWidth := self.getPopupPanelWidth(90)
|
||||
contentWidth := panelWidth - 2 // minus 2 for the frame
|
||||
promptLinesCount := self.layoutMenuPrompt(contentWidth)
|
||||
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext)
|
||||
menuBottom := y1 - offset
|
||||
// The row is reserved for the whole time the menu is open, even though it only
|
||||
// becomes visible once the user starts typing, so that revealing it doesn't
|
||||
// move the menu.
|
||||
filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0)
|
||||
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(
|
||||
contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext)
|
||||
menuBottom := y1 - offset - filterRowHeight
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
|
||||
|
||||
tooltipTop := menuBottom + 1
|
||||
if menuContext.FilterAsYouType() {
|
||||
filterRowBottom := menuBottom + filterRowHeight
|
||||
// The row hangs off the bottom of the menu, sharing its bottom border.
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0)
|
||||
|
||||
prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth)
|
||||
self.c.Views().MenuFilterFrame.SetContent(prompt)
|
||||
// A view's content starts one column inside its bounds, so the input field
|
||||
// starts one column to the left of where its text is to appear.
|
||||
inputLeft := x0 + utils.StringWidth(prompt)
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0)
|
||||
|
||||
if menuContext.FilterStarted() {
|
||||
tooltipTop = filterRowBottom + 1
|
||||
}
|
||||
}
|
||||
tooltip := ""
|
||||
selectedItem := self.c.Contexts().Menu.GetSelected()
|
||||
if selectedItem != nil {
|
||||
|
|
|
|||
31
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal file
31
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMenuFilterPrompt(t *testing.T) {
|
||||
longPrompt := "Filter ('@' for keybindings): "
|
||||
shortPrompt := "Filter: "
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []string
|
||||
contentWidth int
|
||||
expected string
|
||||
}{
|
||||
{name: "room for four characters", candidates: []string{shortPrompt}, contentWidth: 12, expected: shortPrompt},
|
||||
{name: "room for three characters", candidates: []string{shortPrompt}, contentWidth: 11, expected: ""},
|
||||
{name: "prefers the first candidate", candidates: []string{longPrompt, shortPrompt}, contentWidth: 34, expected: longPrompt},
|
||||
{name: "falls back to the next one", candidates: []string{longPrompt, shortPrompt}, contentWidth: 33, expected: shortPrompt},
|
||||
{name: "measures display width", candidates: []string{"篩選: "}, contentWidth: 9, expected: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, menuFilterPrompt(test.candidates, test.contentWidth))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +141,6 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
|
|||
}
|
||||
|
||||
self.c.Contexts().LocalCommits.SetSelection(index)
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
},
|
||||
|
|
|
|||
|
|
@ -188,9 +188,8 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
|
|||
}
|
||||
|
||||
result := false
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
|
||||
result = check()
|
||||
return nil
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,11 +270,6 @@ func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) e
|
|||
|
||||
selectCommit()
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
|
||||
// The list we just selected in has nothing to do with the one
|
||||
// that was showing, so wherever it was scrolled to says nothing
|
||||
// about where the selection now is. PostRefreshUpdate leaves the
|
||||
// scroll position alone, so ask for it separately.
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ type refreshEnv struct {
|
|||
// persist its refreshed stat cache.
|
||||
backgroundRoutine bool
|
||||
|
||||
// Whether the views this refresh updates must keep the scroll position they
|
||||
// have. Focusing a list scrolls its selection into view, which is what a
|
||||
// user action should do — but a refresh that no user action is behind must
|
||||
// leave the viewport wherever the user last scrolled it to. That's the case
|
||||
// for the unattended background routines, and for the refreshes that merely
|
||||
// reload state (see RefreshOptions.DontBlockRepoSwitch).
|
||||
keepScrollPosition bool
|
||||
|
||||
// Whether refreshing a side context should leave the main view unchanged.
|
||||
skipMainViewUpdate bool
|
||||
|
||||
// the repo generation captured when the refresh started
|
||||
generation int
|
||||
|
||||
|
|
@ -220,13 +231,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// against the repo it started in, and the generation guard drops its
|
||||
// writes.
|
||||
env := refreshEnv{
|
||||
background: options.Background || options.DontBlockRepoSwitch,
|
||||
backgroundRoutine: options.Background,
|
||||
background: options.Background || options.DontBlockRepoSwitch,
|
||||
backgroundRoutine: options.Background,
|
||||
keepScrollPosition: options.Background || options.DontBlockRepoSwitch,
|
||||
skipMainViewUpdate: options.SkipMainViewUpdate,
|
||||
}
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
env.generation = self.c.State().GetRepoGeneration()
|
||||
env.git = self.c.Git()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if options.BatchUIUpdates {
|
||||
env.batch = &refreshBounceBatch{}
|
||||
}
|
||||
|
|
@ -262,6 +277,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// - merge conflicts are part of what the files refresh produces
|
||||
// - pull requests are fetched for the tracking branches against the
|
||||
// remotes, so refresh both alongside to fetch against fresh data
|
||||
// - commits and branches always go together: changing commits changes
|
||||
// the branches' upstream/downstream counts, and changing branches
|
||||
// (e.g. checking one out) changes the commits we show. This one comes
|
||||
// last, so that it also covers the branches the rules above add.
|
||||
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
|
||||
scopeSet.Add(types.COMMITS, types.BRANCHES)
|
||||
}
|
||||
|
|
@ -274,6 +293,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
if scopeSet.Includes(types.PULL_REQUESTS) {
|
||||
scopeSet.Add(types.BRANCHES, types.REMOTES)
|
||||
}
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
|
||||
scopeSet.Add(types.COMMITS, types.BRANCHES)
|
||||
}
|
||||
|
||||
// Capture the refs snapshot now, before we start reading git's state
|
||||
// below, rather than after. This is important to guard against the race
|
||||
|
|
@ -300,6 +322,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
})
|
||||
}
|
||||
|
||||
// The branches view shows worktrees against branches, so a branches render
|
||||
// that happens before the refreshed worktrees have landed in the model shows
|
||||
// stale ones, and rendering again once they land makes the view flicker.
|
||||
// Refresh the worktrees first, then, and let the branches refresh wait for
|
||||
// them: waitForWorktrees returns once the worktrees model write is queued,
|
||||
// so the branches write that follows is queued behind it and the view
|
||||
// renders once, with both.
|
||||
worktreesWg := sync.WaitGroup{}
|
||||
waitForWorktrees := func() { worktreesWg.Wait() }
|
||||
if scopeSet.Includes(types.WORKTREES) {
|
||||
worktreesWg.Add(1)
|
||||
refresh("worktrees", func() {
|
||||
defer worktreesWg.Done()
|
||||
self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES))
|
||||
})
|
||||
}
|
||||
|
||||
branchesAndRemotesWg := sync.WaitGroup{}
|
||||
// The pull-request fetch (below) needs the just-loaded branches and
|
||||
// remotes. Their model writes are bounced onto the UI thread, so the
|
||||
|
|
@ -309,32 +348,49 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// branchesAndRemotesWg gives the fetch the happens-before to read them.
|
||||
var loadedBranches []*models.Branch
|
||||
var loadedRemotes []*models.Remote
|
||||
includeWorktreesWithBranches := false
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
|
||||
// whenever we change commits, we should update branches because the upstream/downstream
|
||||
// counts can change. Whenever we change branches we should also change commits
|
||||
// e.g. in the case of switching branches.
|
||||
// Capture the commits, reflog and branches refresh inputs (model,
|
||||
// contexts, modes) on the UI thread, before the git work is dispatched
|
||||
// to a worker, so the workers compute from an immutable snapshot
|
||||
// instead of reading state the UI thread concurrently mutates.
|
||||
if scopeSet.Includes(types.COMMITS) {
|
||||
// Capture the refresh's inputs (model, contexts, modes) on the UI
|
||||
// thread, before the git work is dispatched to a worker, so the worker
|
||||
// computes from an immutable snapshot instead of reading state the UI
|
||||
// thread concurrently mutates. Every scope below does the same.
|
||||
var capturedCommits capturedCommitState
|
||||
var capturedReflog capturedReflogState
|
||||
var capturedBranches capturedBranchState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedCommits = self.captureCommitsState()
|
||||
capturedReflog = self.captureReflogState()
|
||||
capturedBranches = self.captureBranchState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("commits and commit files", func() {
|
||||
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
|
||||
})
|
||||
} else if scopeSet.Includes(types.REBASE_COMMITS) {
|
||||
// the commits refresh above loads the rebase commits as well, so we only
|
||||
// need this one when the rebase commits are all that was asked for
|
||||
var rebaseHashPool *utils.StringPool
|
||||
var rebaseCommits []*models.Commit
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.BRANCHES) {
|
||||
// The reflog is refreshed here rather than in a scope of its own,
|
||||
// because sorting the branches by recency needs it to be loaded first.
|
||||
var capturedReflog capturedReflogState
|
||||
var capturedBranches capturedBranchState
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedReflog = self.captureReflogState()
|
||||
capturedBranches = self.captureBranchState()
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
|
||||
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
|
||||
branchesAndRemotesWg.Add(1)
|
||||
refresh("reflog and branches", func() {
|
||||
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env)
|
||||
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env)
|
||||
branchesAndRemotesWg.Done()
|
||||
})
|
||||
} else {
|
||||
|
|
@ -343,47 +399,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// Not a recency sort, so branches doesn't depend on the reflog
|
||||
// being fresh; it runs concurrently with the reflog refresh
|
||||
// below and uses the reflog we captured up front, as it always has.
|
||||
loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env)
|
||||
loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env)
|
||||
branchesAndRemotesWg.Done()
|
||||
})
|
||||
refresh("reflog", func() {
|
||||
_, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit)
|
||||
})
|
||||
}
|
||||
} else if scopeSet.Includes(types.REBASE_COMMITS) {
|
||||
// the above block handles rebase commits so we only need to call this one
|
||||
// if we've asked specifically for rebase commits and not those other things
|
||||
var rebaseHashPool *utils.StringPool
|
||||
var rebaseCommits []*models.Commit
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
|
||||
})
|
||||
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.SUB_COMMITS) {
|
||||
var capturedSubCommits capturedSubCommitState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedSubCommits = self.captureSubCommitState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
|
||||
}
|
||||
|
||||
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
|
||||
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
|
||||
var capturedCommitFiles capturedCommitFilesState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedCommitFiles = self.captureCommitFilesState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
|
||||
}
|
||||
|
||||
fileWg := sync.WaitGroup{}
|
||||
if scopeSet.Includes(types.FILES) {
|
||||
var capturedFiles capturedFilesState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedFiles = self.captureFilesState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
fileWg.Add(1)
|
||||
refresh("files", func() {
|
||||
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
|
||||
|
|
@ -393,9 +446,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
|
||||
if scopeSet.Includes(types.STASH) {
|
||||
var stashFilterPath string
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
stashFilterPath = self.c.Modes().Filtering.GetPath()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
|
||||
}
|
||||
|
||||
|
|
@ -408,9 +463,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// needs it to keep the remote-branches selection valid, and reading
|
||||
// the Remotes context off the UI thread races its render.
|
||||
var prevSelectedRemote *models.Remote
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
branchesAndRemotesWg.Add(1)
|
||||
refresh("remotes", func() {
|
||||
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
|
||||
|
|
@ -443,10 +500,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
})
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
|
||||
refresh("worktrees", func() { self.refreshWorktrees(env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.STAGING) {
|
||||
refresh("staging", func() {
|
||||
fileWg.Wait()
|
||||
|
|
@ -673,17 +726,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
|
||||
switch self.c.State().GetRepoState().GetStartupStage() {
|
||||
case types.INITIAL:
|
||||
// Return the immediate (non-recency) load's branches; the recency-sorted
|
||||
// reload below runs on its own worker after we return. Both hold the same
|
||||
// set of branches, which is all the caller (the PR fetch) needs.
|
||||
branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
|
||||
branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
|
||||
|
||||
self.onWorker(env.background, func(_ gocui.Task) error {
|
||||
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false)
|
||||
self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env)
|
||||
// The load above already waited for the worktrees, so this one has
|
||||
// nothing left to wait for.
|
||||
self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env)
|
||||
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
|
||||
return nil
|
||||
})
|
||||
|
|
@ -692,7 +747,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo
|
|||
|
||||
case types.COMPLETE:
|
||||
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit)
|
||||
return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env)
|
||||
return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -810,9 +865,11 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
|
|||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
var selectionRange *localCommitSelectionRange
|
||||
var newConflictedCommitIdx *int
|
||||
if commitSelection == types.KeepCommitSelectionByHash {
|
||||
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
|
||||
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
|
||||
newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits)
|
||||
}
|
||||
|
||||
self.c.Model().BisectInfo = bisectInfo
|
||||
|
|
@ -826,33 +883,23 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
|
|||
self.c.Model().CheckedOutBranch = ""
|
||||
}
|
||||
|
||||
scrollSelectionIntoView := false
|
||||
switch commitSelection {
|
||||
case types.SelectHeadCommit:
|
||||
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
|
||||
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
|
||||
scrollSelectionIntoView = true
|
||||
}
|
||||
case types.KeepCommitSelectionByHash:
|
||||
if selectionRange != nil {
|
||||
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
|
||||
if newConflictedCommitIdx != nil {
|
||||
self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx)
|
||||
} else if selectionRange != nil {
|
||||
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange)
|
||||
if found {
|
||||
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
|
||||
scrollSelectionIntoView = didMove
|
||||
}
|
||||
}
|
||||
case types.KeepCommitSelectionIndex:
|
||||
// The caller set the selection index deliberately; leave it untouched.
|
||||
}
|
||||
|
||||
if scrollSelectionIntoView {
|
||||
// Enqueued from within this bounce so it runs after refreshView's
|
||||
// render below (which was enqueued first), matching the previous
|
||||
// ordering where FocusLine ran after the view was re-rendered.
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
self.refreshView(self.c.Contexts().LocalCommits, env)
|
||||
|
|
@ -864,8 +911,6 @@ type localCommitSelectionRange struct {
|
|||
selectedIsTODO bool
|
||||
rangeStartHash string
|
||||
rangeStartIsTODO bool
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
mode traits.RangeSelectMode
|
||||
}
|
||||
|
||||
|
|
@ -884,8 +929,6 @@ func captureLocalCommitSelectionRange(
|
|||
selectedIsTODO: commits[selectedIdx].IsTODO(),
|
||||
rangeStartHash: commits[rangeStartIdx].Hash(),
|
||||
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
|
@ -893,17 +936,16 @@ func captureLocalCommitSelectionRange(
|
|||
func findLocalCommitSelectionRange(
|
||||
commits []*models.Commit,
|
||||
selectionRange *localCommitSelectionRange,
|
||||
) (int, int, bool, bool) {
|
||||
) (int, int, bool) {
|
||||
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
|
||||
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
|
||||
if !foundSelected || !foundRangeStart {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
|
||||
return selectedIdx, rangeStartIdx, didMove, true
|
||||
return selectedIdx, rangeStartIdx, true
|
||||
}
|
||||
|
||||
// findCommitByHashPreferringTODOStatus finds the commit with the given hash.
|
||||
|
|
@ -934,6 +976,24 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
|
|||
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
|
||||
}
|
||||
|
||||
// Returns the index of the conflicted commit in the new commits slice, if there is one and it has a
|
||||
// different hash than the one before had (or there wasn't one before). Otherwise returns nil.
|
||||
func findNewConflictedCommit(previousCommits []*models.Commit, commits []*models.Commit) *int {
|
||||
previousConflictedCommit, _ := lo.Find(previousCommits, func(commit *models.Commit) bool {
|
||||
return commit.Status == models.StatusConflicted
|
||||
})
|
||||
|
||||
newConflictedCommit, idx, hasConflict := lo.FindIndexOf(commits, func(commit *models.Commit) bool {
|
||||
return commit.Status == models.StatusConflicted
|
||||
})
|
||||
|
||||
if hasConflict && (previousConflictedCommit == nil || previousConflictedCommit.Hash() != newConflictedCommit.Hash()) {
|
||||
return &idx
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// capturedSubCommitState holds the sub-commits refresh's model/context/mode
|
||||
// inputs, gathered on the UI thread (see captureSubCommitState) before the git
|
||||
// work is dispatched to a worker.
|
||||
|
|
@ -1074,7 +1134,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
|
|||
|
||||
// self.refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a State.Model.Branches array to pick the current branch from
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
loadSeq := self.branchLoadSeq.Add(1)
|
||||
|
||||
branches, err := env.git.Loaders.BranchLoader.Load(
|
||||
|
|
@ -1108,10 +1168,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
self.c.Log.Error(err)
|
||||
}
|
||||
|
||||
var worktrees []*models.Worktree
|
||||
if refreshWorktrees {
|
||||
worktrees = self.loadWorktrees(env)
|
||||
}
|
||||
// Render only once the refreshed worktrees are in the model; the branches
|
||||
// view shows them against the branches (see performRefresh).
|
||||
waitForWorktrees()
|
||||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
// Drop this write if a branch load that started later has already applied
|
||||
|
|
@ -1134,11 +1193,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
// the branches we just wrote, on the UI thread.
|
||||
self.rebuildPullRequestsMap()
|
||||
|
||||
if refreshWorktrees {
|
||||
self.c.Model().Worktrees = worktrees
|
||||
self.refreshView(self.c.Contexts().Worktrees, env)
|
||||
}
|
||||
|
||||
// Setting the selection here, in the same bounce that writes the list,
|
||||
// keeps it on the UI thread and keeps the list and selection updating in
|
||||
// the same frame.
|
||||
|
|
@ -1154,10 +1208,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
}
|
||||
}
|
||||
case types.SelectCheckedOutBranch:
|
||||
// The checked-out branch is always at the top of the list. Setting
|
||||
// the selection doesn't scroll the view, so also reset the origin.
|
||||
// The checked-out branch is always at the top of the list.
|
||||
self.c.Contexts().Branches.SetSelectedLineIdx(0)
|
||||
self.c.Contexts().Branches.GetView().SetOriginY(0)
|
||||
}
|
||||
|
||||
// Need to re-render the commits view because the visualization of local
|
||||
|
|
@ -1248,21 +1300,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
|
|||
// waiting for a callback that only it can run), and capturing inline also
|
||||
// guarantees the snapshot reflects the state at the moment Refresh was called,
|
||||
// before the calling handler regains control and can mutate it.
|
||||
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
|
||||
//
|
||||
// It returns false when fn didn't run because the app is shutting down, in
|
||||
// which case the caller must abandon the refresh rather than compute from a
|
||||
// snapshot that was never taken.
|
||||
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool {
|
||||
if !calledFromWorker {
|
||||
fn()
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
wrapped := func() error {
|
||||
fn()
|
||||
return nil
|
||||
}
|
||||
if background {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped)
|
||||
} else {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
|
||||
return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil
|
||||
}
|
||||
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
|
||||
}
|
||||
|
||||
// capturedFilesState holds the files refresh's context/model inputs, gathered
|
||||
|
|
@ -1329,12 +1380,9 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
Background: env.backgroundRoutine,
|
||||
})
|
||||
|
||||
conflictFileCount := 0
|
||||
for _, file := range files {
|
||||
if file.HasMergeConflicts {
|
||||
conflictFileCount++
|
||||
}
|
||||
}
|
||||
conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) {
|
||||
return file.Path, file.HasMergeConflicts
|
||||
})
|
||||
|
||||
repoState := self.c.State().GetRepoState()
|
||||
workingTreeState := env.git.Status.WorkingTreeState()
|
||||
|
|
@ -1344,7 +1392,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
repoState.SetMergeOrRebaseStartedInLazygit(false)
|
||||
}
|
||||
|
||||
if workingTreeState.Any() && conflictFileCount == 0 {
|
||||
if workingTreeState.Any() && len(conflictedPaths) == 0 {
|
||||
if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() {
|
||||
// The conflicts of an operation we started have just been resolved
|
||||
// (e.g. in the user's editor). Offer to continue it. We only do this
|
||||
|
|
@ -1382,24 +1430,61 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
// only taking over the filter if it hasn't already been set by the user.
|
||||
if conflictFileCount > 0 && prevConflictFileCount == 0 {
|
||||
if len(conflictedPaths) > 0 && prevConflictFileCount == 0 {
|
||||
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
|
||||
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
|
||||
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
|
||||
}
|
||||
} else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.SetStatusFilter(filetree.DisplayAll)
|
||||
} else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll)
|
||||
self.c.Contexts().Files.GetView().Subtitle = ""
|
||||
}
|
||||
|
||||
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.RememberConflictedPaths(conflictedPaths)
|
||||
}
|
||||
|
||||
self.c.Model().Submodules = submoduleConfigs
|
||||
self.c.Model().Files = files
|
||||
markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath())
|
||||
fileTreeViewModel.SetTree()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markWorktreeFiles marks the files that are linked worktrees of this repo, so
|
||||
// that the files view can render them as such. `git status` reports a worktree
|
||||
// as an untracked directory, i.e. with a trailing slash, which we take off:
|
||||
// keeping it would build a directory node with a nameless file inside it.
|
||||
//
|
||||
// It must run on the UI thread, as it works on the model. Both models it needs
|
||||
// are written by refreshes of their own, so it is called after either of them
|
||||
// lands; it reports whether it changed anything.
|
||||
func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool {
|
||||
changed := false
|
||||
|
||||
for _, file := range files {
|
||||
absPath := filepath.Join(worktreePath, file.Path)
|
||||
isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool {
|
||||
return worktree.Path == absPath
|
||||
})
|
||||
|
||||
if isWorktree != file.IsWorktree {
|
||||
file.IsWorktree = isWorktree
|
||||
changed = true
|
||||
}
|
||||
if isWorktree {
|
||||
if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path {
|
||||
file.Path = trimmed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// the reflogs panel is the only panel where we cache data, in that we only
|
||||
// load entries that have been created since we last ran the call. This means
|
||||
// we need to be more careful with how we use this, and to ensure we're emptying
|
||||
|
|
@ -1449,11 +1534,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
|
|||
self.c.Model().ReflogCommits = reflogCommits
|
||||
self.c.Model().FilteredReflogCommits = filteredReflogCommits
|
||||
// Setting the selection here, in the same bounce that writes the list,
|
||||
// keeps it on the UI thread and atomic with the list update. Setting the
|
||||
// selection doesn't scroll the view, so also reset the origin.
|
||||
// keeps it on the UI thread and atomic with the list update.
|
||||
if selectTopEntry {
|
||||
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
|
||||
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1503,16 +1586,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {
|
|||
return worktrees
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshWorktrees(env refreshEnv) {
|
||||
func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) {
|
||||
worktrees := self.loadWorktrees(env)
|
||||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
self.c.Model().Worktrees = worktrees
|
||||
|
||||
// A worktree inside our working tree is one of the files, so the files
|
||||
// view has to be told about the ones we just loaded (see
|
||||
// markWorktreeFiles). Rebuild the tree because a file's path can change.
|
||||
if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) {
|
||||
self.c.Contexts().Files.FileTreeViewModel.SetTree()
|
||||
self.refreshView(self.c.Contexts().Files, env)
|
||||
}
|
||||
})
|
||||
|
||||
// need to refresh branches because the branches view shows worktrees against
|
||||
// branches
|
||||
self.refreshView(self.c.Contexts().Branches, env)
|
||||
// The branches view shows worktrees against branches, so it needs to be
|
||||
// rendered again as well. When the branches are being refreshed too, they
|
||||
// render after waiting for the write above, so leave it to them.
|
||||
if !branchesAreRefreshing {
|
||||
self.refreshView(self.c.Contexts().Branches, env)
|
||||
}
|
||||
self.refreshView(self.c.Contexts().Worktrees, env)
|
||||
}
|
||||
|
||||
|
|
@ -1580,7 +1674,10 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
|
|||
// the filtered list model is up to date for rendering.
|
||||
self.searchHelper.ReApplyFilter(context)
|
||||
|
||||
self.c.PostRefreshUpdate(context)
|
||||
self.c.PostRefreshUpdateWithOptions(context, types.OnFocusOpts{
|
||||
KeepScrollPosition: env.keepScrollPosition,
|
||||
SkipMainViewUpdate: env.skipMainViewUpdate,
|
||||
})
|
||||
|
||||
self.c.AfterLayout(func() error {
|
||||
// Re-applying the search must be done after re-rendering the view though,
|
||||
|
|
@ -1756,7 +1853,11 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
|
|||
// the branches and remotes as they are on the UI thread, after their
|
||||
// own refreshes' bounces have applied.
|
||||
self.rebuildPullRequestsMap()
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
|
||||
// This lands whenever the network call happens to return, and only
|
||||
// changes how the branches are rendered, not which one is selected, so
|
||||
// it has no business moving the viewport.
|
||||
self.c.PostRefreshUpdateWithOptions(self.c.Contexts().Branches,
|
||||
types.OnFocusOpts{KeepScrollPosition: true})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
|
|
@ -28,8 +29,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: &localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "a",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 0,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
},
|
||||
},
|
||||
|
|
@ -74,15 +73,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
type expectation struct {
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
moved bool
|
||||
found bool
|
||||
}
|
||||
|
||||
selectionRange := localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "c",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 2,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +93,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -126,7 +121,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -139,7 +133,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 0,
|
||||
rangeStartIdx: 1,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -147,11 +140,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
|
||||
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
|
||||
actual := expectation{
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
moved: moved,
|
||||
found: found,
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +152,62 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFindNewConflictedCommit(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
previousCommits []*models.Commit
|
||||
commits []*models.Commit
|
||||
expectedIdx *int
|
||||
}{
|
||||
{
|
||||
name: "finds a newly conflicted commit",
|
||||
previousCommits: makeCommits("a", "b"),
|
||||
commits: []*models.Commit{
|
||||
makeCommits("a")[0],
|
||||
makeConflictedCommit("b"),
|
||||
},
|
||||
expectedIdx: lo.ToPtr(1),
|
||||
},
|
||||
{
|
||||
name: "finds a different conflicted commit",
|
||||
previousCommits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
commits: []*models.Commit{
|
||||
makeConflictedCommit("b"),
|
||||
},
|
||||
expectedIdx: lo.ToPtr(0),
|
||||
},
|
||||
{
|
||||
name: "ignores the same conflicted commit",
|
||||
previousCommits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
commits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
expectedIdx: nil,
|
||||
},
|
||||
{
|
||||
name: "reports not found when there is no conflict",
|
||||
previousCommits: makeCommits("a"),
|
||||
commits: makeCommits("a", "b"),
|
||||
expectedIdx: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits)
|
||||
|
||||
assert.Equal(t, testCase.expectedIdx != nil, idx != nil)
|
||||
if idx != nil {
|
||||
assert.Equal(t, *testCase.expectedIdx, *idx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGithubBaseRemote(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
@ -252,6 +300,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) {
|
|||
}, callsByHost)
|
||||
}
|
||||
|
||||
func TestMarkWorktreeFiles(t *testing.T) {
|
||||
worktreePath := filepath.Join("/", "path", "to", "repo")
|
||||
worktrees := []*models.Worktree{
|
||||
{Path: worktreePath},
|
||||
{Path: filepath.Join(worktreePath, "worktree1")},
|
||||
{Path: filepath.Join(worktreePath, "dir", "worktree2")},
|
||||
{Path: filepath.Join("/", "path", "to", "worktree3")},
|
||||
}
|
||||
|
||||
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
|
||||
files := []*models.File{
|
||||
{Path: "file"},
|
||||
{Path: "worktree1/"},
|
||||
{Path: "dir/worktree2/"},
|
||||
{Path: "dir/"},
|
||||
}
|
||||
|
||||
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
|
||||
assert.Equal(t, []*models.File{
|
||||
{Path: "file"},
|
||||
{Path: "worktree1", IsWorktree: true},
|
||||
{Path: "dir/worktree2", IsWorktree: true},
|
||||
{Path: "dir/"},
|
||||
}, files)
|
||||
})
|
||||
|
||||
t.Run("reports no change when there is nothing to mark", func(t *testing.T) {
|
||||
files := []*models.File{{Path: "file"}, {Path: "dir/"}}
|
||||
|
||||
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
|
||||
})
|
||||
|
||||
t.Run("unmarks a file whose worktree is gone", func(t *testing.T) {
|
||||
files := []*models.File{{Path: "worktree1", IsWorktree: true}}
|
||||
|
||||
assert.True(t, markWorktreeFiles(files, nil, worktreePath))
|
||||
assert.Equal(t, []*models.File{{Path: "worktree1"}}, files)
|
||||
})
|
||||
}
|
||||
|
||||
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
|
||||
return lo.Map(names, func(name string, _ int) githubRemoteInfo {
|
||||
return makeGithubRemoteInfo(name, name)
|
||||
|
|
@ -288,3 +376,7 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit {
|
|||
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
|
||||
}
|
||||
|
||||
func makeConflictedCommit(hash string) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
|
|||
}
|
||||
})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.RecentRepos,
|
||||
Items: menuItems,
|
||||
FilterAsYouType: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SwitchToParentRepo switches back to the repo the current submodule was
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ func NewSearchHelper(
|
|||
}
|
||||
}
|
||||
|
||||
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
|
||||
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) {
|
||||
state := self.searchState()
|
||||
|
||||
state.PrevSearchIndex = -1
|
||||
|
||||
state.Context = context
|
||||
|
||||
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
|
||||
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
||||
promptView := self.promptView()
|
||||
promptView.ClearTextArea()
|
||||
self.OnPromptContentChanged("")
|
||||
|
|
@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err
|
|||
|
||||
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
|
||||
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) {
|
||||
state := self.searchState()
|
||||
|
||||
state.PrevSearchIndex = -1
|
||||
|
|
@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) err
|
|||
|
||||
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
|
||||
|
|
@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext)
|
|||
state.Context = context
|
||||
searchString := context.GetFilter()
|
||||
|
||||
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
|
||||
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
||||
|
||||
promptView := self.promptView()
|
||||
keybindingConfig := self.c.UserConfig().Keybinding
|
||||
|
|
@ -103,10 +103,11 @@ func (self *SearchHelper) promptContent() string {
|
|||
return self.c.Contexts().Search.GetView().TextArea.GetContent()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) Confirm() error {
|
||||
func (self *SearchHelper) Confirm() {
|
||||
state := self.searchState()
|
||||
if self.promptContent() == "" {
|
||||
return self.CancelPrompt()
|
||||
self.CancelPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
switch state.SearchType() {
|
||||
|
|
@ -118,7 +119,7 @@ func (self *SearchHelper) Confirm() error {
|
|||
self.c.Context().Pop()
|
||||
}
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ConfirmFilter() {
|
||||
|
|
@ -175,12 +176,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition
|
|||
return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
|
||||
}
|
||||
|
||||
func (self *SearchHelper) CancelPrompt() error {
|
||||
func (self *SearchHelper) CancelPrompt() {
|
||||
self.Cancel()
|
||||
|
||||
self.c.Context().Pop()
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
|
||||
|
|
@ -224,10 +225,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
|
|||
state := self.searchState()
|
||||
switch context := state.Context.(type) {
|
||||
case types.IFilterableContext:
|
||||
context.SetSelection(0)
|
||||
context.GetView().SetOriginY(0)
|
||||
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
self.c.PostRefreshUpdate(context)
|
||||
self.ApplyFilter(context, searchString)
|
||||
case types.ISearchableContext:
|
||||
// do nothing
|
||||
default:
|
||||
|
|
@ -235,12 +233,21 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ApplyFilter(context types.IFilterableContext, filter string) {
|
||||
context.SetSelection(0)
|
||||
context.SetFilter(filter, self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
self.c.PostRefreshUpdate(context)
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ReApplyFilter(context types.Context) {
|
||||
filterableContext, ok := context.(types.IFilterableContext)
|
||||
if ok {
|
||||
state := self.searchState()
|
||||
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
|
||||
filterableContext.SetSelection(0)
|
||||
// This runs as part of a refresh, and a refresh that no user action
|
||||
// is behind keeps the scroll position, which would leave the view
|
||||
// scrolled somewhere the filtered list no longer has anything at.
|
||||
filterableContext.GetView().SetOriginY(0)
|
||||
}
|
||||
filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
|
|||
subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix
|
||||
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
|
||||
subCommitsContext.FocusLine(true)
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
|
|||
repoState := self.c.State().GetRepoState()
|
||||
|
||||
var searchPrefix string
|
||||
if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
|
||||
searchPrefix = filterableContext.FilterPrefix(self.c.Tr)
|
||||
if _, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
|
||||
searchPrefix = self.c.Tr.FilterPrefix
|
||||
} else {
|
||||
searchPrefix = self.c.Tr.SearchPrefix
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error {
|
|||
self.context.SetNeedRerenderVisibleLines()
|
||||
}
|
||||
|
||||
self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
} else {
|
||||
// If the selection did not change (because, for example, we are at the top of the list and
|
||||
// press up), we still want to ensure that the selection is visible. This is useful after
|
||||
|
|
@ -205,9 +205,10 @@ func (self *ListController) handlePageChange(delta int) error {
|
|||
// must tell it explicitly to rerender.
|
||||
self.context.SetNeedRerenderVisibleLines()
|
||||
|
||||
// Since we are maintaining the scroll position ourselves above, there's no point in passing
|
||||
// ScrollSelectionIntoView=true here.
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
// This function scrolls the view itself, keeping the selection at the edge of
|
||||
// the viewport rather than in its middle, so the scroll position is ours to
|
||||
// maintain, not the focus mechanism's.
|
||||
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -280,7 +281,10 @@ func (self *ListController) selectRangeThroughViewIndex(viewIndex int) {
|
|||
newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex)
|
||||
list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx())
|
||||
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
// The pointer can be outside the viewport, in which case so is the end of
|
||||
// the range; the drag autoscroller takes care of following it, one line at a
|
||||
// time, for as long as the pointer stays there.
|
||||
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
|
||||
}
|
||||
|
||||
func (self *ListController) handleDragAutoscroll(viewIndex int) bool {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding
|
|||
|
||||
self.commitDrag.hasMoved = true
|
||||
if self.updateCommitDragInsertion(opts.Y) {
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
self.c.PostRefreshUpdateWithOptions(self.context(),
|
||||
types.OnFocusOpts{KeepScrollPosition: true})
|
||||
}
|
||||
originY := self.context().GetView().OriginY()
|
||||
self.dragAutoscroller.Update(opts.Y - originY)
|
||||
|
|
@ -344,7 +345,9 @@ func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex i
|
|||
func (self *LocalCommitsController) stopMovingCommitsIndicator() {
|
||||
self.stopMovingCommitsIndicatorTicker()
|
||||
self.context().ClearDropInsertionIndex()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
self.c.PostRefreshUpdateWithOptions(
|
||||
self.context(), types.OnFocusOpts{SkipMainViewUpdate: true},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() {
|
||||
|
|
@ -1170,16 +1173,21 @@ func (self *LocalCommitsController) move(
|
|||
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
|
||||
// Block input until the refresh has landed: a quick second press must
|
||||
// read the moved todo from the refreshed model, not grab whatever the
|
||||
// advanced selection index points at in the stale one.
|
||||
self.c.RefreshBlockingInput(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
Then: onComplete,
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
SkipMainViewUpdate: true,
|
||||
Then: func() error {
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().FocusLine(true)
|
||||
if onComplete != nil {
|
||||
return onComplete()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1204,7 +1212,7 @@ func (self *LocalCommitsController) move(
|
|||
Then: func() error {
|
||||
if err == nil {
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
self.context().HandleFocus(types.OnFocusOpts{})
|
||||
}
|
||||
if onComplete != nil {
|
||||
return onComplete()
|
||||
|
|
@ -1676,7 +1684,8 @@ func (self *LocalCommitsController) openSearch() error {
|
|||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
|
||||
}
|
||||
|
||||
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
|
||||
self.c.Helpers().Search.OpenSearchPrompt(self.context())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) handleOpenLogMenu() error {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ func (self *MainViewController) openSearch() error {
|
|||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MenuController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*types.MenuItem]
|
||||
c *ControllerCommon
|
||||
// for delegating navigation to, see physicalKeyBindings
|
||||
listController *ListController
|
||||
}
|
||||
|
||||
var _ types.IController = &MenuController{}
|
||||
|
||||
func NewMenuController(
|
||||
c *ControllerCommon,
|
||||
listController *ListController,
|
||||
) *MenuController {
|
||||
return &MenuController{
|
||||
baseController: baseController{},
|
||||
|
|
@ -24,12 +30,13 @@ func NewMenuController(
|
|||
c.Contexts().Menu.GetSelected,
|
||||
c.Contexts().Menu.GetSelectedItems,
|
||||
),
|
||||
c: c,
|
||||
c: c,
|
||||
listController: listController,
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: if you add a new keybinding here, you'll also need to add it to
|
||||
// `reservedKeys` in `pkg/gui/context/menu_context.go`
|
||||
// `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it
|
||||
func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{
|
||||
{
|
||||
|
|
@ -52,6 +59,51 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
|
|||
},
|
||||
}
|
||||
|
||||
if self.context().FilterAsYouType() {
|
||||
bindings = append(bindings, self.physicalKeyBindings(opts)...)
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
// In a menu that filters as you type, the keys configured for driving the menu
|
||||
// may all be printable, and printable keys become filter text once the user
|
||||
// starts typing. These keys can't, so binding them on top guarantees that the
|
||||
// menu stays usable no matter how the keybindings are configured.
|
||||
func (self *MenuController) physicalKeyBindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
candidates := []struct {
|
||||
key gocui.Key
|
||||
configured config.Keybinding
|
||||
binding *types.Binding
|
||||
}{
|
||||
{
|
||||
key: gocui.NewKeyName(gocui.KeyEnter),
|
||||
configured: opts.Config.Universal.ConfirmMenu,
|
||||
binding: &types.Binding{
|
||||
Handler: self.withItem(self.press),
|
||||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||||
},
|
||||
},
|
||||
{gocui.NewKeyName(gocui.KeyEsc), opts.Config.Universal.Return, &types.Binding{Handler: self.close}},
|
||||
{gocui.NewKeyName(gocui.KeyArrowUp), opts.Config.Universal.PrevItem, &types.Binding{Handler: self.listController.HandlePrevLine}},
|
||||
{gocui.NewKeyName(gocui.KeyArrowDown), opts.Config.Universal.NextItem, &types.Binding{Handler: self.listController.HandleNextLine}},
|
||||
{gocui.NewKeyName(gocui.KeyPgup), opts.Config.Universal.PrevPage, &types.Binding{Handler: self.listController.HandlePrevPage}},
|
||||
{gocui.NewKeyName(gocui.KeyPgdn), opts.Config.Universal.NextPage, &types.Binding{Handler: self.listController.HandleNextPage}},
|
||||
{gocui.NewKeyName(gocui.KeyHome), opts.Config.Universal.GotoTop, &types.Binding{Handler: self.listController.HandleGotoTop}},
|
||||
{gocui.NewKeyName(gocui.KeyEnd), opts.Config.Universal.GotoBottom, &types.Binding{Handler: self.listController.HandleGotoBottom}},
|
||||
}
|
||||
|
||||
bindings := []*types.Binding{}
|
||||
for _, candidate := range candidates {
|
||||
if lo.Contains(opts.GetKeys(candidate.configured), candidate.key) {
|
||||
// this key is the configured one, so it drives the menu already
|
||||
continue
|
||||
}
|
||||
|
||||
candidate.binding.Keys = []gocui.Key{candidate.key}
|
||||
bindings = append(bindings, candidate.binding)
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +125,11 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
|
|||
}
|
||||
|
||||
func (self *MenuController) close() error {
|
||||
if self.context().FilterStarted() {
|
||||
self.stopFiltering()
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.context().IsFiltering() {
|
||||
self.c.Helpers().Search.Cancel()
|
||||
return nil
|
||||
|
|
@ -81,6 +138,17 @@ func (self *MenuController) close() error {
|
|||
return self.context().OnMenuPress(nil)
|
||||
}
|
||||
|
||||
// Hides the filter row again and puts the menu back the way it was, keeping the
|
||||
// item that was selected. It takes another escape to close the menu.
|
||||
func (self *MenuController) stopFiltering() {
|
||||
self.c.Views().MenuFilter.ClearTextArea()
|
||||
self.c.Views().MenuFilter.RenderTextArea()
|
||||
|
||||
self.context().SetFilterStarted(false)
|
||||
self.context().ClearFilter()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
}
|
||||
|
||||
func (self *MenuController) context() *context.MenuContext {
|
||||
return self.c.Contexts().Menu
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error {
|
|||
ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft},
|
||||
AllowFilteringKeybindings: true,
|
||||
KeepConflictingKeybindings: true,
|
||||
FilterAsYouType: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -230,9 +230,8 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
|
|||
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
||||
// Escape pops the patch-building context, so run it on the UI thread
|
||||
// before the refresh below.
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
|
||||
self.c.Helpers().PatchBuilding.Escape()
|
||||
return nil
|
||||
})
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{})
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*type
|
|||
return []*types.Binding{
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
|
||||
Handler: self.OpenSearchPrompt,
|
||||
Handler: self.openSearchPrompt,
|
||||
Description: self.c.Tr.StartSearch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SearchController) OpenSearchPrompt() error {
|
||||
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
func (self *SearchController) openSearchPrompt() error {
|
||||
self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,11 +51,13 @@ func (self *SearchPromptController) context() types.Context {
|
|||
}
|
||||
|
||||
func (self *SearchPromptController) confirm() error {
|
||||
return self.c.Helpers().Search.Confirm()
|
||||
self.c.Helpers().Search.Confirm()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *SearchPromptController) cancel() error {
|
||||
return self.c.Helpers().Search.CancelPrompt()
|
||||
self.c.Helpers().Search.CancelPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *SearchPromptController) prevHistory() error {
|
||||
|
|
|
|||
|
|
@ -251,7 +251,6 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr
|
|||
return err
|
||||
}
|
||||
self.context().SetSelection(0) // Select the renamed stash
|
||||
self.context().FocusLine(true)
|
||||
// Renaming re-creates the stash at the top, shifting the other
|
||||
// entries' indices; block input so that a quick next action sees
|
||||
// the refreshed list rather than the stale indices.
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
|
|||
if file == nil {
|
||||
task = types.NewRenderStringTask(prefix)
|
||||
} else {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
|
||||
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ func (self *SuggestionsController) GetMouseKeybindings(opts types.KeybindingsOpt
|
|||
|
||||
func (self *SuggestionsController) switchToPrompt() error {
|
||||
self.c.Views().Suggestions.Subtitle = ""
|
||||
self.c.Views().Suggestions.Highlight = false
|
||||
self.c.Context().Replace(self.c.Contexts().Prompt)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,6 @@ func (self *ToggleWhitespaceAction) Call() error {
|
|||
|
||||
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
|
||||
|
||||
self.c.Context().CurrentSide().HandleFocus(types.OnFocusOpts{})
|
||||
self.c.Context().CurrentSide().HandleRenderToMain()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,32 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
|
|||
return matched
|
||||
}
|
||||
|
||||
func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool {
|
||||
contentBefore := v.TextArea.GetContent()
|
||||
|
||||
matched := gui.handleEditorKeypress(v, key, false)
|
||||
if !matched {
|
||||
// Give the global keybindings a chance at the key, e.g. so that ctrl-c
|
||||
// still quits while a menu is open.
|
||||
return false
|
||||
}
|
||||
|
||||
v.RenderTextArea()
|
||||
|
||||
content := v.TextArea.GetContent()
|
||||
if content == contentBefore {
|
||||
// The key just moved the cursor around within the filter; refiltering would
|
||||
// throw away the menu's selection for nothing.
|
||||
return true
|
||||
}
|
||||
|
||||
menuContext := gui.State.Contexts.Menu
|
||||
menuContext.SetFilterStarted(true)
|
||||
gui.helpers.Search.ApplyFilter(menuContext, content)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool {
|
||||
matched := gui.handleEditorKeypress(v, key, false)
|
||||
v.RenderTextArea()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
|
@ -263,10 +262,6 @@ func (self *CommitFileTreeViewModel) IsFiltering() bool {
|
|||
// used for type switch
|
||||
func (self *CommitFileTreeViewModel) IsFilterableContext() {}
|
||||
|
||||
func (self *CommitFileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
return tr.FilterPrefix
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
|
||||
return self.searchHistory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package filetree
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
|
|
@ -42,6 +43,7 @@ type IFileTree interface {
|
|||
|
||||
FilterFiles(test func(*models.File) bool) []*models.File
|
||||
SetStatusFilter(filter FileTreeDisplayFilter)
|
||||
RememberConflictedPaths(paths []string)
|
||||
ForceShowUntracked() bool
|
||||
Get(index int) *FileNode
|
||||
GetFile(path string) *models.File
|
||||
|
|
@ -54,25 +56,31 @@ type IFileTree interface {
|
|||
}
|
||||
|
||||
type FileTree struct {
|
||||
getFiles func() []*models.File
|
||||
tree *Node[models.File]
|
||||
showTree bool
|
||||
common *common.Common
|
||||
filter FileTreeDisplayFilter
|
||||
collapsedPaths *CollapsedPaths
|
||||
textFilter string
|
||||
useFuzzySearch bool
|
||||
getFiles func() []*models.File
|
||||
tree *Node[models.File]
|
||||
showTree bool
|
||||
common *common.Common
|
||||
filter FileTreeDisplayFilter
|
||||
// Paths of the files that had conflicts while the current filter has been
|
||||
// active. The DisplayConflicted filter keeps showing them after their
|
||||
// conflicts have been resolved, so that their diffs can be reviewed while
|
||||
// the remaining files are still being worked on.
|
||||
conflictedPaths *set.Set[string]
|
||||
collapsedPaths *CollapsedPaths
|
||||
textFilter string
|
||||
useFuzzySearch bool
|
||||
}
|
||||
|
||||
var _ IFileTree = &FileTree{}
|
||||
|
||||
func NewFileTree(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTree {
|
||||
return &FileTree{
|
||||
getFiles: getFiles,
|
||||
common: common,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
collapsedPaths: NewCollapsedPaths(),
|
||||
getFiles: getFiles,
|
||||
common: common,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
conflictedPaths: set.New[string](),
|
||||
collapsedPaths: NewCollapsedPaths(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +108,9 @@ func (self *FileTree) getFilesForDisplay() []*models.File {
|
|||
case DisplayUntracked:
|
||||
files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) })
|
||||
case DisplayConflicted:
|
||||
files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
|
||||
files = self.FilterFiles(func(file *models.File) bool {
|
||||
return file.HasMergeConflicts || self.conflictedPaths.Includes(file.Path)
|
||||
})
|
||||
default:
|
||||
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
|
||||
}
|
||||
|
|
@ -122,9 +132,16 @@ func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File {
|
|||
|
||||
func (self *FileTree) SetStatusFilter(filter FileTreeDisplayFilter) {
|
||||
self.filter = filter
|
||||
self.conflictedPaths = set.New[string]()
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
// RememberConflictedPaths records which files have conflicts right now, so that
|
||||
// the DisplayConflicted filter keeps showing them once they are resolved.
|
||||
func (self *FileTree) RememberConflictedPaths(paths []string) {
|
||||
self.conflictedPaths.Add(paths...)
|
||||
}
|
||||
|
||||
func (self *FileTree) ToggleShowTree() {
|
||||
self.showTree = !self.showTree
|
||||
self.SetTree()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
|
|
@ -12,10 +13,11 @@ import (
|
|||
|
||||
func TestFilterAction(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
filter FileTreeDisplayFilter
|
||||
files []*models.File
|
||||
expected []*models.File
|
||||
name string
|
||||
filter FileTreeDisplayFilter
|
||||
conflictedPaths []string
|
||||
files []*models.File
|
||||
expected []*models.File
|
||||
}{
|
||||
{
|
||||
name: "filter files with unstaged changes",
|
||||
|
|
@ -84,11 +86,29 @@ func TestFilterAction(t *testing.T) {
|
|||
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keep showing conflicted files whose conflicts have been resolved",
|
||||
filter: DisplayConflicted,
|
||||
conflictedPaths: []string{"dir2/dir2/file4", "file1"},
|
||||
files: []*models.File{
|
||||
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter}
|
||||
mngr := &FileTree{
|
||||
getFiles: func() []*models.File { return s.files },
|
||||
filter: s.filter,
|
||||
conflictedPaths: set.NewFromSlice(s.conflictedPaths),
|
||||
}
|
||||
result := mngr.getFilesForDisplay()
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
|
@ -167,6 +166,31 @@ func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) {
|
|||
self.IListCursor.SetSelection(0)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetStatusFilterPreservingSelection(filter FileTreeDisplayFilter) {
|
||||
self.preserveSelection(func() {
|
||||
self.SetStatusFilter(filter)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) preserveSelection(f func()) {
|
||||
selectedNode := self.GetSelected()
|
||||
var selectedPath string
|
||||
if selectedNode != nil {
|
||||
selectedPath = selectedNode.GetInternalPath()
|
||||
}
|
||||
|
||||
f()
|
||||
|
||||
if selectedPath != "" {
|
||||
self.ExpandToPath(selectedPath)
|
||||
if idx, found := self.GetIndexForPath(selectedPath); found {
|
||||
self.SetSelection(idx)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.ClampSelection()
|
||||
}
|
||||
|
||||
// If we're going from flat to tree we want to select the same file.
|
||||
// If we're going from tree to flat and we have a file selected we want to select that.
|
||||
// If instead we've selected a directory we need to select the first file in that directory.
|
||||
|
|
@ -233,22 +257,9 @@ func (self *FileTreeViewModel) GetFilter() string {
|
|||
}
|
||||
|
||||
func (self *FileTreeViewModel) ClearFilter() {
|
||||
selectedNode := self.GetSelected()
|
||||
var selectedPath string
|
||||
if selectedNode != nil {
|
||||
selectedPath = selectedNode.GetInternalPath()
|
||||
}
|
||||
|
||||
self.IFileTree.SetTextFilter("", false)
|
||||
|
||||
if selectedPath != "" {
|
||||
self.ExpandToPath(selectedPath)
|
||||
if idx, found := self.GetIndexForPath(selectedPath); found {
|
||||
self.SetSelection(idx)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.ClampSelection()
|
||||
self.preserveSelection(func() {
|
||||
self.IFileTree.SetTextFilter("", false)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) {
|
||||
|
|
@ -262,10 +273,6 @@ func (self *FileTreeViewModel) IsFiltering() bool {
|
|||
// used for type switch
|
||||
func (self *FileTreeViewModel) IsFilterableContext() {}
|
||||
|
||||
func (self *FileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
return tr.FilterPrefix
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
|
||||
return self.searchHistory
|
||||
}
|
||||
|
|
|
|||
32
pkg/gui/filetree/file_tree_view_model_test.go
Normal file
32
pkg/gui/filetree/file_tree_view_model_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package filetree
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetStatusFilterPreservingSelection(t *testing.T) {
|
||||
files := []*models.File{
|
||||
{Path: "file1"},
|
||||
{Path: "file2", HasMergeConflicts: true},
|
||||
{Path: "file3", HasMergeConflicts: true},
|
||||
}
|
||||
viewModel := NewFileTreeViewModel(
|
||||
func() []*models.File { return files },
|
||||
common.NewDummyCommon(),
|
||||
false,
|
||||
)
|
||||
viewModel.SetTree()
|
||||
viewModel.SetStatusFilter(DisplayConflicted)
|
||||
viewModel.SetSelection(viewModel.Len() - 2)
|
||||
viewModel.ToggleStickyRange()
|
||||
viewModel.MoveSelectedLine(1)
|
||||
|
||||
viewModel.SetStatusFilterPreservingSelection(DisplayAll)
|
||||
|
||||
assert.Equal(t, "file3", viewModel.GetSelectedPath())
|
||||
assert.False(t, viewModel.IsSelectingRange())
|
||||
}
|
||||
|
|
@ -368,10 +368,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
|
|||
contextToPush := gui.resetState(startArgs)
|
||||
|
||||
gui.resetHelpersAndControllers()
|
||||
|
||||
if err := gui.resetKeybindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
gui.resetKeybindings()
|
||||
|
||||
gui.g.SetFocusHandler(func(Focused bool) error {
|
||||
if Focused {
|
||||
|
|
@ -383,9 +380,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
|
|||
gui.c.Log.Info("User config changed - reloading")
|
||||
reloadErr = gui.onUserConfigLoaded()
|
||||
gui.reloadSidePanels()
|
||||
if err := gui.resetKeybindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
gui.resetKeybindings()
|
||||
|
||||
if err := gui.checkForChangedConfigsThatDontAutoReload(oldConfig, gui.Config.GetUserConfig()); err != nil {
|
||||
return err
|
||||
|
|
@ -603,13 +598,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
|||
// RefreshHelper.onUIThreadUnlessRepoChanged).
|
||||
gui.repoGeneration.Add(1)
|
||||
|
||||
// Un-highlight the current view if there is one. The reason we do this is
|
||||
// that the repo we are switching to might have a different view focused,
|
||||
// and would then show an inactive highlight for the previous view.
|
||||
if oldCurrentView := gui.g.CurrentView(); oldCurrentView != nil {
|
||||
oldCurrentView.Highlight = false
|
||||
}
|
||||
|
||||
worktreePath := gui.git.RepoPaths.WorktreePath()
|
||||
|
||||
if state := gui.RepoStateMap[Repo(worktreePath)]; state != nil {
|
||||
|
|
@ -927,6 +915,21 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView {
|
|||
return result
|
||||
}
|
||||
|
||||
// The views that each popup panel is made up of. A panel's views share the
|
||||
// keyboard focus, so clicking from one of them to another stays within the
|
||||
// panel.
|
||||
var popupPanelViewGroups = [][]string{
|
||||
{"commitMessage", "commitDescription"},
|
||||
{"prompt", "suggestions"},
|
||||
{"menu", "menuFilterFrame", "menuFilter"},
|
||||
}
|
||||
|
||||
func viewsBelongToSamePopupPanel(viewName string, otherViewName string) bool {
|
||||
return lo.SomeBy(popupPanelViewGroups, func(group []string) bool {
|
||||
return lo.Contains(group, viewName) && lo.Contains(group, otherViewName)
|
||||
})
|
||||
}
|
||||
|
||||
// Run: setup the gui with keybindings and start the mainloop
|
||||
func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
g, err := gui.initGocui(Headless(), startArgs.IntegrationTest)
|
||||
|
|
@ -942,15 +945,11 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
|||
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
|
||||
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
|
||||
!gocui.IsMouseScrollKey(key) {
|
||||
// we ignore click events on views that aren't popup panels, when a popup panel is focused.
|
||||
// Unless both the current view and the clicked-on view are either commit message or commit
|
||||
// description, or a prompt and the suggestions view, because we want to allow switching
|
||||
// between those two views by clicking.
|
||||
isCommitMessageOrSuggestionsView := func(viewName string) bool {
|
||||
return viewName == "commitMessage" || viewName == "commitDescription" ||
|
||||
viewName == "prompt" || viewName == "suggestions"
|
||||
}
|
||||
if !isCommitMessageOrSuggestionsView(gui.currentViewName()) || !isCommitMessageOrSuggestionsView(view.Name()) {
|
||||
// we ignore click events on views that aren't popup panels, when a popup
|
||||
// panel is focused. Unless the clicked-on view is part of the same popup
|
||||
// panel as the current one, because we want to allow switching between the
|
||||
// views of a panel by clicking.
|
||||
if !viewsBelongToSamePopupPanel(gui.currentViewName(), view.Name()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) {
|
|||
}
|
||||
|
||||
func (self *guiCommon) PostRefreshUpdate(context types.Context) {
|
||||
self.gui.postRefreshUpdate(context)
|
||||
self.gui.postRefreshUpdate(context, types.OnFocusOpts{})
|
||||
}
|
||||
|
||||
func (self *guiCommon) PostRefreshUpdateWithOptions(context types.Context, opts types.OnFocusOpts) {
|
||||
self.gui.postRefreshUpdate(context, opts)
|
||||
}
|
||||
|
||||
func (self *guiCommon) RunSubprocessAndRefresh(cmdObj *oscommands.CmdObj) error {
|
||||
|
|
@ -197,8 +201,8 @@ func (self *guiCommon) CallKeybindingHandler(binding *types.Binding) error {
|
|||
return self.gui.callKeybindingHandler(binding)
|
||||
}
|
||||
|
||||
func (self *guiCommon) ResetKeybindings() error {
|
||||
return self.gui.resetKeybindings()
|
||||
func (self *guiCommon) ResetKeybindings() {
|
||||
self.gui.resetKeybindings()
|
||||
}
|
||||
|
||||
func (self *guiCommon) IsAnyModeActive() bool {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ func (self *GuiDriver) MouseMove(x, y int) {
|
|||
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) ScrollWheelDown(x, y int) {
|
||||
self.replayMouseEvent(x, y, tcell.WheelDown)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) MouseRelease(x, y int) {
|
||||
self.replayMouseEvent(x, y, tcell.ButtonNone)
|
||||
}
|
||||
|
|
@ -82,7 +86,7 @@ func (self *GuiDriver) WaitUntilIdle() {
|
|||
}
|
||||
|
||||
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
|
||||
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
|
||||
_ = self.gui.g.OnUIThreadAndWait(f)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
|
||||
|
|
@ -97,14 +101,25 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.Bu
|
|||
))
|
||||
}
|
||||
|
||||
// FocusIn simulates the terminal window regaining focus, which is how lazygit
|
||||
// learns to reload changed config files. Tests use it to exercise the live
|
||||
// config-reload path.
|
||||
func (self *GuiDriver) FocusIn() {
|
||||
// replayFocusIn takes the focus away before handing it back, because that's the
|
||||
// only way a terminal can report regaining it, and lazygit only reacts to focus
|
||||
// reports that change the focus (see gocui.Gui.IsFocused).
|
||||
func (self *GuiDriver) replayFocusIn() {
|
||||
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
|
||||
tcell.NewEventFocus(false),
|
||||
0,
|
||||
))
|
||||
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
|
||||
tcell.NewEventFocus(true),
|
||||
0,
|
||||
))
|
||||
}
|
||||
|
||||
// FocusIn simulates the terminal window regaining focus, which is how lazygit
|
||||
// learns to reload changed config files. Tests use it to exercise the live
|
||||
// config-reload path.
|
||||
func (self *GuiDriver) FocusIn() {
|
||||
self.replayFocusIn()
|
||||
|
||||
self.waitTillIdle()
|
||||
}
|
||||
|
|
@ -112,10 +127,7 @@ func (self *GuiDriver) FocusIn() {
|
|||
func (self *GuiDriver) FocusInAndClick(x, y int) {
|
||||
self.CheckAllToastsAcknowledged()
|
||||
|
||||
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
|
||||
tcell.NewEventFocus(true),
|
||||
0,
|
||||
))
|
||||
self.replayFocusIn()
|
||||
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
|
||||
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
|
||||
0,
|
||||
|
|
@ -128,6 +140,16 @@ func (self *GuiDriver) FocusInAndClick(x, y int) {
|
|||
self.waitTillIdle()
|
||||
}
|
||||
|
||||
// RefreshInBackground performs the refresh that the background routines perform
|
||||
// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than
|
||||
// turning those routines on, so that they neither wait for a timer nor depend on
|
||||
// one firing at a particular moment.
|
||||
func (self *GuiDriver) RefreshInBackground() {
|
||||
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
|
||||
|
||||
self.waitTillIdle()
|
||||
}
|
||||
|
||||
func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
|
||||
self.gui.onUIThread(func() error {
|
||||
self.gui.State.SetMergeOrRebaseStartedInLazygit(true)
|
||||
|
|
@ -159,6 +181,10 @@ func (self *GuiDriver) CurrentContext() types.Context {
|
|||
return self.gui.State.ContextMgr.Current()
|
||||
}
|
||||
|
||||
func (self *GuiDriver) CursorVisible() bool {
|
||||
return self.gui.g.Cursor
|
||||
}
|
||||
|
||||
func (self *GuiDriver) ContextForView(viewName string) types.Context {
|
||||
context, ok := self.gui.helpers.View.ContextForView(viewName)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -295,8 +295,9 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin
|
|||
},
|
||||
}
|
||||
|
||||
mouseKeybindings := []*gocui.ViewMouseBinding{}
|
||||
for _, c := range gui.State.Contexts.Flatten() {
|
||||
contexts := gui.State.Contexts.Flatten()
|
||||
mouseKeybindings := make([]*gocui.ViewMouseBinding, 0, len(contexts))
|
||||
for _, c := range contexts {
|
||||
viewName := c.GetViewName()
|
||||
for _, binding := range c.GetKeybindings(opts) {
|
||||
// TODO: move all mouse keybindings into the mouse keybindings approach below
|
||||
|
|
@ -350,7 +351,7 @@ func (gui *Gui) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []*
|
|||
return bindings, mouseBindings
|
||||
}
|
||||
|
||||
func (gui *Gui) resetKeybindings() error {
|
||||
func (gui *Gui) resetKeybindings() {
|
||||
gui.g.DeleteAllKeybindings()
|
||||
|
||||
bindings, mouseBindings := gui.GetInitialKeybindingsWithCustomCommands()
|
||||
|
|
@ -360,9 +361,7 @@ func (gui *Gui) resetKeybindings() error {
|
|||
}
|
||||
|
||||
for _, binding := range mouseBindings {
|
||||
if err := gui.SetMouseKeybinding(binding); err != nil {
|
||||
return err
|
||||
}
|
||||
gui.SetMouseKeybinding(binding)
|
||||
}
|
||||
|
||||
for _, values := range gui.viewTabMap() {
|
||||
|
|
@ -372,13 +371,9 @@ func (gui *Gui) resetKeybindings() error {
|
|||
return gui.onViewTabClick(gui.helpers.Window.WindowForView(viewName), tabIndex)
|
||||
}
|
||||
|
||||
if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil {
|
||||
return err
|
||||
}
|
||||
gui.g.SetTabClickBinding(viewName, tabClickCallback)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) SetKeybinding(binding *types.Binding) {
|
||||
|
|
@ -391,8 +386,8 @@ func (gui *Gui) SetKeybinding(binding *types.Binding) {
|
|||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error {
|
||||
return gui.g.SetViewClickBinding(binding)
|
||||
func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) {
|
||||
gui.g.SetViewClickBinding(binding)
|
||||
}
|
||||
|
||||
func (gui *Gui) callKeybindingHandler(binding *types.Binding) error {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,13 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
|||
if !view.CanScrollPastBottom {
|
||||
maxOriginY -= newHeight - 1
|
||||
}
|
||||
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY {
|
||||
// Don't scroll up while the view's content is still being loaded: its
|
||||
// height only reflects what has been read so far, so clamping to it now
|
||||
// would yank the view to the top even though more content is on the way
|
||||
// (e.g. when re-rendering a diff the user was scrolled into).
|
||||
manager := gui.getViewBufferManagerForView(view)
|
||||
stillLoading := manager != nil && manager.IsLoading()
|
||||
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading {
|
||||
view.ScrollUp(oldOriginY - maxOriginY)
|
||||
// the view might not have scrolled actually (if it was at the limit
|
||||
// already), so we need to check if it did
|
||||
|
|
@ -138,15 +144,25 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
|||
}
|
||||
}
|
||||
|
||||
// When the screen is too short the side panels are squashed, with the
|
||||
// unfocused ones taking one row each and the focused one taking the rest. The
|
||||
// more panels there are, the more rows the unfocused ones reserve, so the
|
||||
// floor below which there's no room left for the focused panel grows with the
|
||||
// panel count. Keep the historical floor of 9 for the default five panels.
|
||||
minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4)
|
||||
menuWithFilterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterAsYouType()
|
||||
minimumHeight := minimumScreenHeight(len(gui.helpers.Window.SideWindows()), menuWithFilterRowVisible)
|
||||
minimumWidth := 10
|
||||
gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
|
||||
|
||||
filterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterStarted()
|
||||
gui.Views.MenuFilterFrame.Visible = filterRowVisible
|
||||
gui.Views.MenuFilter.Visible = filterRowVisible
|
||||
if gui.Views.Menu.Visible {
|
||||
// Until the user types something there is no filter row to advertise the
|
||||
// filter, so the menu says that typing is a thing.
|
||||
gui.Views.Menu.Subtitle = lo.Ternary(menuWithFilterRowVisible && !filterRowVisible, gui.c.Tr.MenuFilterHint, "")
|
||||
}
|
||||
if menuWithFilterRowVisible {
|
||||
// The filter input is the current view for as long as such a menu is open,
|
||||
// so without this the cursor would sit on the menu's bottom border, where
|
||||
// the filter row is yet to appear.
|
||||
gui.g.Cursor = filterRowVisible
|
||||
}
|
||||
gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != ""
|
||||
|
||||
for _, context := range gui.transientContexts() {
|
||||
|
|
@ -223,6 +239,26 @@ outer:
|
|||
return nil
|
||||
}
|
||||
|
||||
// The height below which we show the "not enough space" view instead of the
|
||||
// layout.
|
||||
func minimumScreenHeight(sideWindowCount int, menuWithFilterRowVisible bool) int {
|
||||
// When the screen is too short the side panels are squashed, with the
|
||||
// unfocused ones taking one row each and the focused one taking the rest. The
|
||||
// more panels there are, the more rows the unfocused ones reserve, so the
|
||||
// floor below which there's no room left for the focused panel grows with the
|
||||
// panel count. Keep the historical floor of 9 for the default five panels.
|
||||
minimumHeight := max(9, sideWindowCount+4)
|
||||
|
||||
// A menu popup gets three quarters of the screen, of which its frame, the
|
||||
// tooltip gap below it and a reserved filter row take seven rows, so below 11
|
||||
// rows there is no room left for even one menu item.
|
||||
if menuWithFilterRowVisible {
|
||||
minimumHeight = max(minimumHeight, 11)
|
||||
}
|
||||
|
||||
return minimumHeight
|
||||
}
|
||||
|
||||
func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {
|
||||
// arbitrarily giving the view enough size so that we don't get an error, but
|
||||
// it's expected that the view will be given the correct size before being shown
|
||||
|
|
|
|||
14
pkg/gui/layout_test.go
Normal file
14
pkg/gui/layout_test.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMinimumScreenHeight(t *testing.T) {
|
||||
assert.Equal(t, 9, minimumScreenHeight(5, false))
|
||||
assert.Equal(t, 12, minimumScreenHeight(8, false))
|
||||
assert.Equal(t, 11, minimumScreenHeight(5, true))
|
||||
assert.Equal(t, 12, minimumScreenHeight(8, true))
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package gui
|
|||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
|
|
@ -107,16 +108,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
|
|||
}
|
||||
|
||||
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
// need to reset scroll positions of all other main views
|
||||
for _, pair := range gui.allMainContextPairs() {
|
||||
if pair.Main != opts.Pair.Main {
|
||||
pair.Main.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
|
||||
pair.Secondary.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
gui.moveMainContextPairToTop(opts.Pair)
|
||||
|
||||
if opts.Main != nil {
|
||||
|
|
@ -129,9 +120,37 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
|||
opts.Pair.Secondary.GetView().Clear()
|
||||
}
|
||||
|
||||
// Reset the scroll positions of all the other main views. We do this after
|
||||
// moving this pair to the top (which copies the previously-shown view's
|
||||
// content into the now-visible one to avoid a blank frame): resetting first
|
||||
// would zero that source view's scroll before it gets copied, forcing the
|
||||
// placeholder to the top instead of leaving it where the screen already was.
|
||||
for _, pair := range gui.allMainContextPairs() {
|
||||
if pair.Main != opts.Pair.Main {
|
||||
pair.Main.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
|
||||
pair.Secondary.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
gui.splitMainPanel(opts.Secondary != nil)
|
||||
}
|
||||
|
||||
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
|
||||
gui.State.SplitMainPanel = splitMainPanel
|
||||
}
|
||||
|
||||
// reApplySearch runs a search the view holds again over the content a render has just
|
||||
// finished putting there, so that the matches highlighted and the "x of y" status
|
||||
// describe what the view shows now rather than what it showed when the search was
|
||||
// typed. Call it once the content is final.
|
||||
func (gui *Gui) reApplySearch(view *gocui.View) {
|
||||
// While the prompt is open, the search view holds what the user is typing, and the
|
||||
// status would be written over it.
|
||||
if gui.State.ContextMgr.Current().GetKey() == context.SEARCH_CONTEXT_KEY {
|
||||
return
|
||||
}
|
||||
|
||||
view.RefreshSearch()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
|
|||
gui.State.Contexts.Menu.SetPrompt(opts.Prompt)
|
||||
gui.State.Contexts.Menu.SetAllowFilteringKeybindings(opts.AllowFilteringKeybindings)
|
||||
gui.State.Contexts.Menu.SetKeybindingsTakePrecedence(!opts.KeepConflictingKeybindings)
|
||||
gui.State.Contexts.Menu.SetFilterAsYouType(opts.FilterAsYouType)
|
||||
gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel)
|
||||
gui.State.Contexts.Menu.SetSelection(0)
|
||||
|
||||
gui.Views.Menu.SetOriginY(0)
|
||||
gui.Views.MenuFilter.ClearTextArea()
|
||||
gui.Views.MenuFilter.RenderTextArea()
|
||||
|
||||
gui.Views.Menu.Title = opts.Title
|
||||
gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor
|
||||
|
|
@ -82,9 +84,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
|
|||
gui.Views.Tooltip.Visible = true
|
||||
|
||||
// resetting keybindings so that the menu-specific keybindings are registered
|
||||
if err := gui.resetKeybindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
gui.resetKeybindings()
|
||||
|
||||
gui.c.PostRefreshUpdate(gui.State.Contexts.Menu)
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,16 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
|||
|
||||
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
|
||||
|
||||
// Mark the view as loading synchronously now, before the layout pass: the
|
||||
// actual task is created in afterLayout (below), which runs after layout, so
|
||||
// without this the next layout pass would clamp the scroll position to the
|
||||
// not-yet-loaded content.
|
||||
gui.getManager(view).StartLoading()
|
||||
// Hold the scrollbar at its current height while the re-render loads, so the
|
||||
// thumb doesn't shrink and snap back when the first partial paint swaps in
|
||||
// (see the matching call in newCmdTask).
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Run the pty after layout so that it gets the correct size
|
||||
gui.afterLayout(func() error {
|
||||
// Need to get the width and the pager command again because the layout might have
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
|||
).Debug("RunCommand")
|
||||
|
||||
manager := gui.getManager(view)
|
||||
// Mark the view as loading synchronously (before the task's goroutine runs
|
||||
// and before the next layout pass) so the layout doesn't clamp the scroll
|
||||
// position to the not-yet-loaded content.
|
||||
manager.StartLoading()
|
||||
// Hold the scrollbar at the height the view has now (the previous render),
|
||||
// while it still shows that render: once the re-render swaps in its first
|
||||
// partial paint the displayed buffer is briefly short, and we don't want the
|
||||
// thumb to shrink and snap back as the rest loads.
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Snapshot the view width here, on the UI thread, so the task goroutine
|
||||
// doesn't read the view's live dimensions while it streams output. It's
|
||||
|
|
@ -80,9 +89,9 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
|||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -97,10 +106,10 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
|
|||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
return nil
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -115,10 +124,10 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
|||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -136,12 +145,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
|||
gui.Log,
|
||||
view,
|
||||
func() {
|
||||
// we could clear here, but that actually has the effect of causing a flicker
|
||||
// where the view may contain no content momentarily as the gui refreshes.
|
||||
// Instead, we're rewinding the write pointer so that we will just start
|
||||
// overwriting the existing content from the top down. Once we've reached
|
||||
// the end of the content do display, we call view.FlushStaleCells() to
|
||||
// clear out the remaining content from the previous render.
|
||||
// Called before showing the "loading..." indicator: clear the
|
||||
// displayed buffer so only "loading..." is shown. The actual content
|
||||
// is rendered off-screen (beginRender below) and swapped in, so it
|
||||
// never overwrites the displayed buffer incrementally.
|
||||
view.Reset()
|
||||
},
|
||||
func() {
|
||||
|
|
@ -153,6 +160,11 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
|||
gui.renderContentOnly()
|
||||
},
|
||||
func() {
|
||||
// The content is fully loaded now, so let the scrollbar track it
|
||||
// directly again (it was held at the previous render's height while
|
||||
// loading, see FreezeScrollbarHeight).
|
||||
view.UnfreezeScrollbarHeight()
|
||||
|
||||
// Need to check if the content of the view is well past the origin.
|
||||
linesHeight := view.ViewLinesHeight()
|
||||
_, originY := view.Origin()
|
||||
|
|
@ -162,11 +174,13 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
|||
view.SetOrigin(0, newOriginY)
|
||||
}
|
||||
|
||||
view.FlushStaleCells()
|
||||
gui.reApplySearch(view)
|
||||
},
|
||||
func() {
|
||||
view.SetOrigin(0, 0)
|
||||
},
|
||||
view.BeginOffscreenRender,
|
||||
view.SwapInOffscreenRender,
|
||||
func() gocui.Task {
|
||||
// A background task: rendering content into a view is display
|
||||
// work, not lazygit driving a git operation, so it must not
|
||||
|
|
|
|||
|
|
@ -48,8 +48,12 @@ type IGuiCommon interface {
|
|||
RefreshFromWorker(RefreshOptions)
|
||||
// we call this when we've changed something in the view model but not the actual model,
|
||||
// e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
|
||||
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
|
||||
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'.
|
||||
// It re-focuses the context's selection, which scrolls it into view.
|
||||
PostRefreshUpdate(Context)
|
||||
// Like PostRefreshUpdate, with control over scrolling and whether to update
|
||||
// the main view.
|
||||
PostRefreshUpdateWithOptions(Context, OnFocusOpts)
|
||||
|
||||
// renders string to a view without resetting its origin
|
||||
SetViewContent(view *gocui.View, content string)
|
||||
|
|
@ -141,7 +145,7 @@ type IGuiCommon interface {
|
|||
KeybindingsOpts() KeybindingsOpts
|
||||
CallKeybindingHandler(binding *Binding) error
|
||||
|
||||
ResetKeybindings() error
|
||||
ResetKeybindings()
|
||||
|
||||
// hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct.
|
||||
GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding)
|
||||
|
|
@ -209,6 +213,11 @@ type CreateMenuOptions struct {
|
|||
ColumnAlignment []utils.Alignment
|
||||
AllowFilteringKeybindings bool
|
||||
KeepConflictingKeybindings bool // if true, the keybindings that match essential bindings such as confirm or return will not be removed from menu items
|
||||
// if true, the menu has a filter row of its own and filters its items as the
|
||||
// user types, instead of being filtered through the search prompt. Only for
|
||||
// menus whose items don't have keybindings of their own, because those keys
|
||||
// would clash with typing.
|
||||
FilterAsYouType bool
|
||||
}
|
||||
|
||||
type CreatePopupPanelOpts struct {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
)
|
||||
|
|
@ -57,6 +56,10 @@ type IBaseContext interface {
|
|||
|
||||
GetKind() ContextKind
|
||||
GetViewName() string
|
||||
// The view that keyboard input goes to while this context is focused. That is
|
||||
// the context's own view, unless the context has an editable view embedded in
|
||||
// it which takes the keyboard instead, like the menu's filter input.
|
||||
GetInputViewName() string
|
||||
GetView() *gocui.View
|
||||
GetViewTrait() IViewTrait
|
||||
GetWindowName() string
|
||||
|
|
@ -72,6 +75,10 @@ type IBaseContext interface {
|
|||
// determined independently.
|
||||
HasControlledBounds() bool
|
||||
|
||||
// true if the context holds something for a selection to sit on. Contexts that
|
||||
// don't show a selection at all say false, and so do lists with nothing in them.
|
||||
HasSelectableContent() bool
|
||||
|
||||
// the total height of the content that the view is currently showing
|
||||
TotalContentHeight() int
|
||||
|
||||
|
|
@ -136,7 +143,6 @@ type IFilterableContext interface {
|
|||
ReApplyFilter(bool)
|
||||
IsFiltering() bool
|
||||
IsFilterableContext()
|
||||
FilterPrefix(tr *i18n.TranslationSet) string
|
||||
}
|
||||
|
||||
type ISearchableContext interface {
|
||||
|
|
@ -223,13 +229,20 @@ type IViewTrait interface {
|
|||
ScrollDown(value int)
|
||||
PageDelta() int
|
||||
SelectedLineIdx() int
|
||||
SetHighlight(bool)
|
||||
}
|
||||
|
||||
type OnFocusOpts struct {
|
||||
ClickedWindowName string
|
||||
ClickedViewLineIdx int
|
||||
ScrollSelectionIntoView bool
|
||||
ClickedWindowName string
|
||||
ClickedViewLineIdx int
|
||||
|
||||
// Focusing a list context scrolls its selection into view. Set this to leave
|
||||
// the view's scroll position alone instead; only for callers that maintain
|
||||
// it themselves, e.g. by keeping the selection at the edge of the viewport.
|
||||
KeepScrollPosition bool
|
||||
|
||||
// Set this when the focused item hasn't changed and the main view's current
|
||||
// content is still valid.
|
||||
SkipMainViewUpdate bool
|
||||
}
|
||||
|
||||
type OnFocusLostOpts struct {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue