Compare commits

..

No commits in common. "master" and "v0.64.0" have entirely different histories.

330 changed files with 8079 additions and 21631 deletions

View file

@ -8,7 +8,7 @@ jobs:
check-required-label: check-required-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5 - uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
with: with:
mode: exactly mode: exactly
count: 1 count: 1

View file

@ -30,7 +30,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x
- name: Test code - name: Test code
@ -93,7 +93,7 @@ jobs:
path: ~/git-${{matrix.git-version}} path: ~/git-${{matrix.git-version}}
key: ${{runner.os}}-git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}}
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x
- name: Print git version - name: Print git version
@ -130,7 +130,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x
- name: Build linux binary - name: Build linux binary
@ -157,7 +157,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x
- name: Check Vendor Directory - name: Check Vendor Directory
@ -183,7 +183,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x
- name: Check formatting - name: Check formatting
@ -195,7 +195,7 @@ jobs:
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with: with:
# If you change this, make sure to also update scripts/golangci-lint-shim.sh # If you change this, make sure to also update scripts/golangci-lint-shim.sh
version: v2.12.2 version: v2.4.0
upload-coverage: upload-coverage:
# List all jobs that produce coverage files # List all jobs that produce coverage files
needs: [unit-tests, integration-tests] needs: [unit-tests, integration-tests]
@ -206,7 +206,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x

View file

@ -160,7 +160,7 @@ jobs:
git push origin "refs/tags/$NEW_TAG" git push origin "refs/tags/$NEW_TAG"
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: 1.25.x go-version: 1.25.x

View file

@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Generate Sponsors 💖 - name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1 uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
with: with:
token: ${{ secrets.SPONSORS_TOKEN }} token: ${{ secrets.SPONSORS_TOKEN }}
file: "README.md" file: "README.md"

View file

@ -99,6 +99,8 @@ linters:
generated: lax generated: lax
presets: presets:
- comments - comments
- common-false-positives
- legacy
- std-error-handling - std-error-handling
paths: paths:
- vendor/ - vendor/

View file

@ -82,26 +82,11 @@ while still being meaningful and self-contained.
excuse bundling it in. Before committing, review your diff and split out any 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 hunk that is behavior-preserving (an extraction, a rename, a move) into a
preceding commit, by staging hunks or resetting and recommitting in order. 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). - **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
Match the plain English imperative style of the existing history. 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 - **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 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. 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 ## Iterate with `fixup!` commits
@ -120,20 +105,6 @@ 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 `--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception. 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 If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to than any existing commit naturally owns — stop and ask the user how to
@ -419,32 +390,12 @@ Avoid phrasings like:
- "cleaner than the previous approach" - "cleaner than the previous approach"
- "we used to ... but ..." - "we used to ... but ..."
- "after trying X, we found Y" - "after trying X, we found Y"
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The commit message, which is the durable record of *why this change was made*. The
code comment should make sense to someone who has never seen any prior version code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists. and is just trying to understand the file as it currently exists.
The tell is subtler than an explicit "we used to". A comment that justifies the
code against an alternative — "run it on a worker rather than blocking the UI",
"switch panels in `Then` rather than a moment earlier" — is history in disguise
whenever that alternative is what the code did before the change. It reads as
ordinary rationale, but the reader has no way to know the contrast is with a
version that no longer exists.
So the check to apply is: would you have written this comment if you were
writing the file from scratch, with no diff in mind? If not, the sentence
belongs in the commit message.
## Don't justify routine call sites
If the codebase calls a helper in twenty places without explanation, your
twenty-first call site doesn't need one either. A comment there says "something
here is unusual"; when nothing is, it's noise — and it invites exactly the kind
of before/after justification the section above warns about. Look at the
neighboring call sites before writing one: if they're bare, match them.
## Don't present "live with the bug" as an option ## Don't present "live with the bug" as an option
When you're investigating a defect and laying out fix options for the user, When you're investigating a defect and laying out fix options for the user,

BIN
cpu.out Normal file

Binary file not shown.

View file

@ -27,7 +27,7 @@ Fields only for `extDiff`:
Fields only for `rawGit`: Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings. - **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
Here's an example for a multi-renderer setup: Here's an example for a multi-renderer setup:
@ -40,7 +40,7 @@ git:
- type: extDiff - type: extDiff
command: difft --color=always --context={{diffContext}} command: difft --color=always --context={{diffContext}}
- type: rawGit - type: rawGit
args: [--color-words] args: --color-words
name: color-words name: color-words
- type: rawGit # git's default diff - type: rawGit # git's default diff
name: default name: default

View file

@ -4,14 +4,10 @@
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`. 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`.
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. 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.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. 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 ## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view. You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

View file

@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | | | `` 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) | | `` 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. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. | | `` d `` | Verwijderen | Delete the remote branch from the remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | | | `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` 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 | | | `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | | | `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. | | `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Wijzig remote | | `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch 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. | | `` 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. |

View file

@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | | | `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | | | `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | | | `` <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 `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 | | `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 | | `` ) `` | 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'. |
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 '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'. |
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 | | `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 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'. |
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 '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'. |
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
| `` <ctrl+p> `` | 檢視自訂補丁選項 | | | `` <ctrl+p> `` | 檢視自訂補丁選項 | |
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 | | `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
| `` R `` | 重新整理 | 重新整理Git狀態即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 | | `` 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`. |
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
| `` _ `` | 上一個螢幕模式 | | | `` _ `` | 上一個螢幕模式 | |
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 | | `` \| `` | 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> `` | 取消 | | | `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | | | `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 | | `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref然後反轉比較方向。 | | `` 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. |
| `` q, <ctrl+c> `` | 結束 | | | `` q, <ctrl+c> `` | 結束 | |
| `` <ctrl+z> `` | 掛起應用程式 | | | `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 | | `` <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'. |
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | | `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
@ -44,14 +44,21 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <, <home> `` | 捲動到頂部 | | | `` <, <home> `` | 捲動到頂部 | |
| `` >, <end> `` | 捲動到底部 | | | `` >, <end> `` | 捲動到底部 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` <shift+down> `` | 向下擴充套件選擇範圍 | | | `` <shift+down> `` | Range select down | |
| `` <shift+up> `` | 向上擴充套件選擇範圍 | | | `` <shift+up> `` | Range select up | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
| `` H `` | 向左捲動 | | | `` H `` | 向左捲動 | |
| `` L `` | 向右捲動 | | | `` L `` | 向右捲動 | |
| `` ] `` | 下一個索引標籤 | | | `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | | | `` [ `` | 上一個索引標籤 | |
## Input prompt
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 主面板 (補丁生成) ## 主面板 (補丁生成)
| Key | Action | Info | | Key | Action | Info |
@ -59,12 +66,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | | | `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | | | `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | | | `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | | | `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 | | `` 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. |
| `` <esc> `` | 退出自訂補丁建立器 | | | `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -74,8 +81,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------| |-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | | | `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | | | `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | 退出回到側邊面板 | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 主面板(合併) ## 主面板(合併)
@ -83,15 +90,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <space> `` | 挑選程式碼片段 | | | `` <space> `` | 挑選程式碼片段 | |
| `` b `` | 選取兩個區塊 | | | `` b `` | Pick both hunks | |
| `` <up>, k `` | 選擇上一段 | | | `` <up>, k `` | 選擇上一段 | |
| `` <down>, j `` | 選擇下一段 | | | `` <down>, j `` | 選擇下一段 | |
| `` <left>, h `` | 選擇上一個衝突 | | | `` <left>, h `` | 選擇上一個衝突 | |
| `` <right>, l `` | 選擇下一個衝突 | | | `` <right>, l `` | 選擇下一個衝突 | |
| `` z `` | 復原 | 撤消上次合併衝突解決。 | | `` z `` | 復原 | Undo last merge conflict resolution. |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 | | `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | 返回檔案面板 | | | `` <esc> `` | 返回檔案面板 | |
## 主面板(預存) ## 主面板(預存)
@ -101,19 +108,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | | | `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | | | `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | | | `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 | | `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | | | `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 | | `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
| `` c `` | 提交變更 | 提交暫存區變更 | | `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | | | `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | | | `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> | | `` <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> |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 功能表 ## 功能表
@ -128,19 +135,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -149,12 +156,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
| `` <enter> `` | 進入 | 進入子模組 | | `` <enter> `` | Enter | 進入子模組 |
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
| `` u `` | 更新 | 更新子模組 | | `` u `` | Update | 更新子模組 |
| `` n `` | 新增子模組 | | | `` n `` | 新增子模組 | |
| `` e `` | 更新子模組 URL | | | `` e `` | 更新子模組 URL | |
| `` i `` | 初始化 | 初始化子模組 | | `` i `` | Initialize | 初始化子模組 |
| `` b `` | 查看批量子模組選項 | | | `` b `` | 查看批量子模組選項 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -162,27 +169,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` n `` | 新建工作樹 | | | `` n `` | New worktree | |
| `` <space> `` | 切換 | 切換到選中的工作樹。 | | `` <space> `` | Switch | Switch to the selected worktree. |
| `` o `` | 在編輯器中開啟 | | | `` o `` | 在編輯器中開啟 | |
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 | | `` 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. |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 提交 ## 提交
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` b `` | 查看二分選項 | | | `` b `` | 查看二分選項 | |
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 | | `` 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) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 | | `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 | | `` 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. |
| `` r `` | 改寫提交 | 改寫選中的提交訊息 | | `` r `` | 改寫提交 | 改寫選中的提交訊息 |
| `` R `` | 使用編輯器改寫提交 | | | `` R `` | 使用編輯器改寫提交 | |
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 | | `` 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. |
| `` e `` | 編輯(開始互動變基) | 編輯提交 | | `` e `` | 編輯(開始互動變基) | 編輯提交 |
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `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`. |
| `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` p `` | 挑選 | 挑選提交 (於變基過程中) |
| `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` F `` | 建立修復提交 | 為此提交建立修復提交 |
| `` S `` | 壓縮上方所有「fixup」提交自動壓縮 | 是否壓縮上方 {{.commit}} 所有「fixup」提交 | | `` S `` | 壓縮上方所有「fixup」提交自動壓縮 | 是否壓縮上方 {{.commit}} 所有「fixup」提交 |
@ -191,22 +198,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` V `` | 貼上提交 (揀選) | | | `` V `` | 貼上提交 (揀選) | |
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
| `` A `` | 修改 | 使用已預存的更改修正提交 | | `` A `` | 修改 | 使用已預存的更改修正提交 |
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 | | `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 | | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 | | `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | 在瀏覽器中開啟拉取請求 | | | `` G `` | Open pull request in browser | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -224,30 +231,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` y `` | 複製到剪貼簿 | | | `` y `` | 複製到剪貼簿 | |
| `` c `` | 檢出 | 檢出檔案 | | `` c `` | 檢出 | 檢出檔案 |
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 | | `` 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. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯 | 使用外部編輯器開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 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> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案則Enter進入該檔案以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 | | `` <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. |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 | | `` ` `` | 顯示檔案樹狀視圖 | 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 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 收藏 (Stash) ## 收藏 (Stash)
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 | | `` <space> `` | 套用 | Apply the stash entry to your working directory. |
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 | | `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 | | `` d `` | 捨棄 | Remove the stash entry from the stash list. |
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` r `` | 重新命名收藏 | | | `` r `` | 重新命名收藏 | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -255,19 +262,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -279,18 +286,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` i `` | 顯示 git-flow 選項 | | | `` i `` | 顯示 git-flow 選項 | |
| `` <space> `` | 檢出 | 檢出選定的項目。 | | `` <space> `` | 檢出 | 檢出選定的項目。 |
| `` n `` | 新分支 | | | `` n `` | 新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` o `` | 建立拉取請求 | | | `` o `` | 建立拉取請求 | |
| `` O `` | 建立拉取請求選項 | | | `` O `` | 建立拉取請求選項 | |
| `` G `` | 在瀏覽器中開啟拉取請求 | | | `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | | | `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 | | `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | 簽出上一個分支 | | | `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 | | `` d `` | 刪除 | View delete options for local/remote branch. |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
| `` T `` | 建立標籤 | | | `` T `` | 建立標籤 | |
| `` s `` | 排序規則 | | | `` s `` | 排序規則 | |
@ -298,7 +305,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新命名分支 | | | `` R `` | 重新命名分支 | |
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) | | `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -306,15 +313,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | | | `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 | | `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` w `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 | | `` d `` | 刪除 | View delete options for local/remote tag. |
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -323,40 +330,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 | | `` <space> `` | 切換預存 | Toggle staged for selected file. |
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | | | `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
| `` y `` | 複製到剪貼簿 | | | `` y `` | 複製到剪貼簿 | |
| `` c `` | 提交變更 | 提交暫存區變更 | | `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | | | `` w `` | 沒有預提交 hook 就提交更改 | |
| `` A `` | 修改上次提交 | | | `` A `` | 修改上次提交 | |
| `` C `` | 使用 git 編輯器提交變更 | | | `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> | | `` <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> |
| `` e `` | 編輯 | 使用外部編輯器開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` i `` | 忽略或排除檔案 | | | `` i `` | 忽略或排除檔案 | |
| `` r `` | 重新整理檔案 | | | `` r `` | 重新整理檔案 | |
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 | | `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 | | `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 | | `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 | | `` <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. |
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 | | `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
| `` g `` | 檢視遠端重設選項 | | | `` g `` | 檢視遠端重設選項 | |
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 | | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 | | `` ` `` | 顯示檔案樹狀視圖 | 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'. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 | | `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` f `` | 擷取 | 同步遠端異動 | | `` f `` | 擷取 | 同步遠端異動 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 | | `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 | | `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 次要 ## 次要
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | 退出回到側邊面板 | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 狀態 ## 狀態
@ -366,9 +373,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` u `` | 檢查更新 | | | `` u `` | 檢查更新 | |
| `` <enter> `` | 切換到最近使用的版本庫 | | | `` <enter> `` | 切換到最近使用的版本庫 | |
| `` a `` | 顯示/迴圈所有分支日誌 | | | `` a `` | Show/cycle all branch logs | |
| `` A `` | 顯示/迴圈所有分支日誌(反向) | | | `` A `` | Show/cycle all branch logs (reverse) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
## 確認面板 ## 確認面板
@ -378,23 +385,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 關閉/取消 | | | `` <esc> `` | 關閉/取消 | |
| `` <ctrl+o> `` | 複製到剪貼簿 | | | `` <ctrl+o> `` | 複製到剪貼簿 | |
## 輸入提示
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 遠端 ## 遠端
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <enter> `` | 檢視分支 | | | `` <enter> `` | View branches | |
| `` n `` | 新增遠端 | | | `` n `` | 新增遠端 | |
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | 編輯 | 編輯遠端 | | `` e `` | 編輯 | 編輯遠端 |
| `` f `` | 擷取 | 擷取遠端 | | `` f `` | 擷取 | 擷取遠端 |
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 | | `` 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. |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 遠端分支 ## 遠端分支
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支或者將遠端分支作分離的HEAD。 | | `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
| `` n `` | 新分支 | | | `` n `` | 新分支 | |
| `` w `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` d `` | 刪除 | 從遠端刪除遠端分支。 | | `` d `` | 刪除 | Delete the remote branch from the remote. |
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 | | `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
| `` s `` | 排序規則 | | | `` s `` | 排序規則 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |

View file

@ -27,7 +27,7 @@ Fields only for `extDiff`:
Fields only for `rawGit`: Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings. - **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
Here's an example for a multi-renderer setup: Here's an example for a multi-renderer setup:
@ -40,7 +40,7 @@ git:
- type: extDiff - type: extDiff
command: difft --color=always --context={{diffContext}} command: difft --color=always --context={{diffContext}}
- type: rawGit - type: rawGit
args: [--color-words] args: --color-words
name: color-words name: color-words
- type: rawGit # git's default diff - type: rawGit # git's default diff
name: default name: default

View file

@ -4,14 +4,10 @@
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`. 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`.
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. 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.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. 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 ## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view. You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

View file

@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | | | `` 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) | | `` 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. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. | | `` d `` | Verwijderen | Delete the remote branch from the remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | | | `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` 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 | | | `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | | | `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. | | `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Wijzig remote | | `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch 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. | | `` 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. |

View file

@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | | | `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | | | `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | | | `` <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 `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 | | `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 | | `` ) `` | 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'. |
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 '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'. |
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 | | `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 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'. |
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 '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'. |
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
| `` <ctrl+p> `` | 檢視自訂補丁選項 | | | `` <ctrl+p> `` | 檢視自訂補丁選項 | |
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 | | `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
| `` R `` | 重新整理 | 重新整理Git狀態即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 | | `` 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`. |
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
| `` _ `` | 上一個螢幕模式 | | | `` _ `` | 上一個螢幕模式 | |
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 | | `` \| `` | 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> `` | 取消 | | | `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | | | `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 | | `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref然後反轉比較方向。 | | `` 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. |
| `` q, <ctrl+c> `` | 結束 | | | `` q, <ctrl+c> `` | 結束 | |
| `` <ctrl+z> `` | 掛起應用程式 | | | `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 | | `` <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'. |
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | | `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
@ -44,14 +44,21 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <, <home> `` | 捲動到頂部 | | | `` <, <home> `` | 捲動到頂部 | |
| `` >, <end> `` | 捲動到底部 | | | `` >, <end> `` | 捲動到底部 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` <shift+down> `` | 向下擴充套件選擇範圍 | | | `` <shift+down> `` | Range select down | |
| `` <shift+up> `` | 向上擴充套件選擇範圍 | | | `` <shift+up> `` | Range select up | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
| `` H `` | 向左捲動 | | | `` H `` | 向左捲動 | |
| `` L `` | 向右捲動 | | | `` L `` | 向右捲動 | |
| `` ] `` | 下一個索引標籤 | | | `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | | | `` [ `` | 上一個索引標籤 | |
## Input prompt
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 主面板 (補丁生成) ## 主面板 (補丁生成)
| Key | Action | Info | | Key | Action | Info |
@ -59,12 +66,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | | | `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | | | `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | | | `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | | | `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 | | `` 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. |
| `` <esc> `` | 退出自訂補丁建立器 | | | `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -74,8 +81,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------| |-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | | | `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | | | `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | 退出回到側邊面板 | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 主面板(合併) ## 主面板(合併)
@ -83,15 +90,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <space> `` | 挑選程式碼片段 | | | `` <space> `` | 挑選程式碼片段 | |
| `` b `` | 選取兩個區塊 | | | `` b `` | Pick both hunks | |
| `` <up>, k `` | 選擇上一段 | | | `` <up>, k `` | 選擇上一段 | |
| `` <down>, j `` | 選擇下一段 | | | `` <down>, j `` | 選擇下一段 | |
| `` <left>, h `` | 選擇上一個衝突 | | | `` <left>, h `` | 選擇上一個衝突 | |
| `` <right>, l `` | 選擇下一個衝突 | | | `` <right>, l `` | 選擇下一個衝突 | |
| `` z `` | 復原 | 撤消上次合併衝突解決。 | | `` z `` | 復原 | Undo last merge conflict resolution. |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 | | `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | 返回檔案面板 | | | `` <esc> `` | 返回檔案面板 | |
## 主面板(預存) ## 主面板(預存)
@ -101,19 +108,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | | | `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | | | `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | | | `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | | | `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 | | `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | | | `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 | | `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
| `` c `` | 提交變更 | 提交暫存區變更 | | `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | | | `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | | | `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> | | `` <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> |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 功能表 ## 功能表
@ -128,19 +135,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -149,12 +156,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
| `` <enter> `` | 進入 | 進入子模組 | | `` <enter> `` | Enter | 進入子模組 |
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
| `` u `` | 更新 | 更新子模組 | | `` u `` | Update | 更新子模組 |
| `` n `` | 新增子模組 | | | `` n `` | 新增子模組 | |
| `` e `` | 更新子模組 URL | | | `` e `` | 更新子模組 URL | |
| `` i `` | 初始化 | 初始化子模組 | | `` i `` | Initialize | 初始化子模組 |
| `` b `` | 查看批量子模組選項 | | | `` b `` | 查看批量子模組選項 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -162,27 +169,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` n `` | 新建工作樹 | | | `` n `` | New worktree | |
| `` <space> `` | 切換 | 切換到選中的工作樹。 | | `` <space> `` | Switch | Switch to the selected worktree. |
| `` o `` | 在編輯器中開啟 | | | `` o `` | 在編輯器中開啟 | |
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 | | `` 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. |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 提交 ## 提交
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` b `` | 查看二分選項 | | | `` b `` | 查看二分選項 | |
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 | | `` 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) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 | | `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 | | `` 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. |
| `` r `` | 改寫提交 | 改寫選中的提交訊息 | | `` r `` | 改寫提交 | 改寫選中的提交訊息 |
| `` R `` | 使用編輯器改寫提交 | | | `` R `` | 使用編輯器改寫提交 | |
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 | | `` 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. |
| `` e `` | 編輯(開始互動變基) | 編輯提交 | | `` e `` | 編輯(開始互動變基) | 編輯提交 |
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `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`. |
| `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` p `` | 挑選 | 挑選提交 (於變基過程中) |
| `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` F `` | 建立修復提交 | 為此提交建立修復提交 |
| `` S `` | 壓縮上方所有「fixup」提交自動壓縮 | 是否壓縮上方 {{.commit}} 所有「fixup」提交 | | `` S `` | 壓縮上方所有「fixup」提交自動壓縮 | 是否壓縮上方 {{.commit}} 所有「fixup」提交 |
@ -191,22 +198,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` V `` | 貼上提交 (揀選) | | | `` V `` | 貼上提交 (揀選) | |
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
| `` A `` | 修改 | 使用已預存的更改修正提交 | | `` A `` | 修改 | 使用已預存的更改修正提交 |
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 | | `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 | | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 | | `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | 在瀏覽器中開啟拉取請求 | | | `` G `` | Open pull request in browser | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -224,30 +231,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` y `` | 複製到剪貼簿 | | | `` y `` | 複製到剪貼簿 | |
| `` c `` | 檢出 | 檢出檔案 | | `` c `` | 檢出 | 檢出檔案 |
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 | | `` 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. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯 | 使用外部編輯器開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 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> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案則Enter進入該檔案以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 | | `` <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. |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 | | `` ` `` | 顯示檔案樹狀視圖 | 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 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 收藏 (Stash) ## 收藏 (Stash)
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 | | `` <space> `` | 套用 | Apply the stash entry to your working directory. |
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 | | `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 | | `` d `` | 捨棄 | Remove the stash entry from the stash list. |
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` r `` | 重新命名收藏 | | | `` r `` | 重新命名收藏 | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | | | `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -255,19 +262,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | | | `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 | | `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | | | `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | | | `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 | | `` 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. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | | | `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | | | `` * `` | Select commits of current branch | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -279,18 +286,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` i `` | 顯示 git-flow 選項 | | | `` i `` | 顯示 git-flow 選項 | |
| `` <space> `` | 檢出 | 檢出選定的項目。 | | `` <space> `` | 檢出 | 檢出選定的項目。 |
| `` n `` | 新分支 | | | `` n `` | 新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 | | `` 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 `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` o `` | 建立拉取請求 | | | `` o `` | 建立拉取請求 | |
| `` O `` | 建立拉取請求選項 | | | `` O `` | 建立拉取請求選項 | |
| `` G `` | 在瀏覽器中開啟拉取請求 | | | `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | | | `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 | | `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | 簽出上一個分支 | | | `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 | | `` d `` | 刪除 | View delete options for local/remote branch. |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
| `` T `` | 建立標籤 | | | `` T `` | 建立標籤 | |
| `` s `` | 排序規則 | | | `` s `` | 排序規則 | |
@ -298,7 +305,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新命名分支 | | | `` R `` | 重新命名分支 | |
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) | | `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -306,15 +313,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | | | `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 | | `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` w `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 | | `` d `` | 刪除 | View delete options for local/remote tag. |
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
@ -323,40 +330,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 | | `` <space> `` | 切換預存 | Toggle staged for selected file. |
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | | | `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
| `` y `` | 複製到剪貼簿 | | | `` y `` | 複製到剪貼簿 | |
| `` c `` | 提交變更 | 提交暫存區變更 | | `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | | | `` w `` | 沒有預提交 hook 就提交更改 | |
| `` A `` | 修改上次提交 | | | `` A `` | 修改上次提交 | |
| `` C `` | 使用 git 編輯器提交變更 | | | `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> | | `` <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> |
| `` e `` | 編輯 | 使用外部編輯器開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` i `` | 忽略或排除檔案 | | | `` i `` | 忽略或排除檔案 | |
| `` r `` | 重新整理檔案 | | | `` r `` | 重新整理檔案 | |
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 | | `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 | | `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 | | `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 | | `` <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. |
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 | | `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
| `` g `` | 檢視遠端重設選項 | | | `` g `` | 檢視遠端重設選項 | |
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 | | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 | | `` ` `` | 顯示檔案樹狀視圖 | 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'. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 | | `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` f `` | 擷取 | 同步遠端異動 | | `` f `` | 擷取 | 同步遠端異動 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 | | `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 | | `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 次要 ## 次要
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | 退出回到側邊面板 | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 狀態 ## 狀態
@ -366,9 +373,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` u `` | 檢查更新 | | | `` u `` | 檢查更新 | |
| `` <enter> `` | 切換到最近使用的版本庫 | | | `` <enter> `` | 切換到最近使用的版本庫 | |
| `` a `` | 顯示/迴圈所有分支日誌 | | | `` a `` | Show/cycle all branch logs | |
| `` A `` | 顯示/迴圈所有分支日誌(反向) | | | `` A `` | Show/cycle all branch logs (reverse) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
## 確認面板 ## 確認面板
@ -378,23 +385,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 關閉/取消 | | | `` <esc> `` | 關閉/取消 | |
| `` <ctrl+o> `` | 複製到剪貼簿 | | | `` <ctrl+o> `` | 複製到剪貼簿 | |
## 輸入提示
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 遠端 ## 遠端
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <enter> `` | 檢視分支 | | | `` <enter> `` | View branches | |
| `` n `` | 新增遠端 | | | `` n `` | 新增遠端 | |
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | 編輯 | 編輯遠端 | | `` e `` | 編輯 | 編輯遠端 |
| `` f `` | 擷取 | 擷取遠端 | | `` f `` | 擷取 | 擷取遠端 |
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 | | `` 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. |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 遠端分支 ## 遠端分支
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | | | `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支或者將遠端分支作分離的HEAD。 | | `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
| `` n `` | 新分支 | | | `` n `` | 新分支 | |
| `` w `` | 新建工作樹 | | | `` w `` | New worktree | |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` d `` | 刪除 | 從遠端刪除遠端分支。 | | `` d `` | 刪除 | Delete the remote branch from the remote. |
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 | | `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
| `` s `` | 排序規則 | | | `` s `` | 排序規則 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | | | `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | | | `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | | | `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |

View file

@ -7,7 +7,7 @@
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69, "revCount": 69,
"type": "tarball", "type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69" "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
}, },
"original": { "original": {
"type": "tarball", "type": "tarball",
@ -19,11 +19,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1785627969, "lastModified": 1759362264,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", "rev": "758cf7296bee11f1706a574c77d072b8a7baa881",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -34,11 +34,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785828668, "lastModified": 1759831965,
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=", "narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb", "rev": "c9b6fb798541223bbb396d287d16f43520250518",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -50,11 +50,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1785031560, "lastModified": 1754788789,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", "rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -65,11 +65,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1770107345, "lastModified": 1754340878,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", "narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", "rev": "cab778239e705082fe97bb4990e0d24c50924c04",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -108,11 +108,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1785360170, "lastModified": 1758728421,
"narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=", "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0", "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -109,8 +109,8 @@
}; };
treefmt = { treefmt = {
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler; programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
programs.nixfmt.package = pkgs.nixfmt; programs.nixfmt.package = pkgs.nixfmt-rfc-style;
programs.gofmt.enable = true; programs.gofmt.enable = true;
}; };

27
go.mod
View file

@ -5,9 +5,6 @@ go 1.25.0
// This is necessary to ignore test files when executing gofumpt. // This is necessary to ignore test files when executing gofumpt.
ignore ./test ignore ./test
// Likewise for worktrees that are nested in the main tree.
ignore ./.worktrees
require ( require (
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
@ -16,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0 github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.2 github.com/gdamore/tcell/v3 v3.4.1
github.com/go-errors/errors v1.5.1 github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1 github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0 github.com/integrii/flaggy v1.8.0
@ -24,8 +21,8 @@ require (
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
github.com/kyokomi/emoji/v2 v2.2.14 github.com/kyokomi/emoji/v2 v2.2.13
github.com/lucasb-eyer/go-colorful v1.4.1 github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mgutz/str v1.2.0 github.com/mgutz/str v1.2.0
github.com/mitchellh/go-ps v1.0.0 github.com/mitchellh/go-ps v1.0.0
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
@ -34,12 +31,12 @@ require (
github.com/samber/lo v1.53.0 github.com/samber/lo v1.53.0
github.com/sanity-io/litter v1.5.8 github.com/sanity-io/litter v1.5.8
github.com/sasha-s/go-deadlock v0.3.9 github.com/sasha-s/go-deadlock v0.3.9
github.com/sirupsen/logrus v1.10.2 github.com/sirupsen/logrus v1.9.4
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spkg/bom v1.0.1 github.com/spkg/bom v1.0.1
github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304 github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.11.1
github.com/xo/terminfo v1.0.0 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/sync v0.22.0 golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
@ -53,9 +50,11 @@ require (
github.com/cli/safeexec v1.0.1 // indirect github.com/cli/safeexec v1.0.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.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/fatih/color v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logfmt/logfmt v0.5.0 // 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/hpcloud/tail v1.0.0 // indirect
github.com/invopop/jsonschema v0.10.0 // indirect github.com/invopop/jsonschema v0.10.0 // indirect
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
@ -64,16 +63,16 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/onsi/ginkgo v1.10.3 // indirect github.com/onsi/ginkgo v1.10.3 // indirect
github.com/onsi/gomega v1.34.1 // 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 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect golang.org/x/tools v0.47.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect
) )
tool mvdan.cc/gofumpt tool mvdan.cc/gofumpt

57
go.sum
View file

@ -25,20 +25,22 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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 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.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.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 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 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 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU= github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo= github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= 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-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.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 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
@ -71,10 +73,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s= github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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= github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -98,11 +100,12 @@ 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 h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= 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/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@ -111,8 +114,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/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 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y= github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs= github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs=
@ -122,30 +125,28 @@ 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/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 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 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 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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-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/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 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -174,14 +175,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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -195,5 +196,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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo= mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=

View file

@ -16,7 +16,7 @@ type errorMapping struct {
func knownError(tr *i18n.TranslationSet, err error) (string, bool) { func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
errorMessage := err.Error() errorMessage := err.Error()
knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported} knownErrorMessages := []string{minGitVersionErrorMessage(tr)}
if lo.Contains(knownErrorMessages, errorMessage) { if lo.Contains(knownErrorMessages, errorMessage) {
return errorMessage, true return errorMessage, true

View file

@ -19,12 +19,12 @@ import (
"strings" "strings"
"github.com/jesseduffield/generics/maps" "github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -49,7 +49,7 @@ func CommandToRun() string {
} }
func GetKeybindingsDir() string { func GetKeybindingsDir() string {
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings" return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
} }
func generateAtDir(cheatsheetDir string) { 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 { func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
var content strings.Builder var content strings.Builder
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings) content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
for _, section := range bindingSections { for _, section := range bindingSections {
content.WriteString(formatTitle(section.title)) content.WriteString(formatTitle(section.title))

View file

@ -11,7 +11,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
) )
@ -68,24 +67,11 @@ func NewGitCommand(
return nil, errors.Errorf("Error getting repo paths: %v", err) return nil, errors.Errorf("Error getting repo paths: %v", err)
} }
// A bare repo has no worktree for us to work in. Callers that can offer the
// user something better (app.setupRepo) check for this first; getting here
// means nobody could, e.g. because --git-dir was pointed at a bare repo.
if repoPaths.IsBareRepo() {
return nil, errors.New(cmn.Tr.BareRepoNotSupported)
}
err = os.Chdir(repoPaths.WorktreePath()) err = os.Chdir(repoPaths.WorktreePath())
if err != nil { if err != nil {
return nil, utils.WrapError(err) return nil, utils.WrapError(err)
} }
// Everything we run through the command builder gets told where the repo is
// by the builder itself, but subprocesses don't go through it: user-defined
// custom commands, an editor, and the lazygit we re-enter as git's sequence
// editor during a rebase. Put it in the process env for those.
env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars())
// Pin the config reads to the repo directory like all other git commands // Pin the config reads to the repo directory like all other git commands
// (see NewGitCmdObjBuilder); the config commands run outside that builder. // (see NewGitCmdObjBuilder); the config commands run outside that builder.
gitConfig.SetDir(repoPaths.WorktreePath()) gitConfig.SetDir(repoPaths.WorktreePath())
@ -108,7 +94,7 @@ func NewGitCommandAux(
repoPaths *git_commands.RepoPaths, repoPaths *git_commands.RepoPaths,
diffRendererConfigManager *config.DiffRendererConfigManager, diffRendererConfigManager *config.DiffRendererConfigManager,
) *GitCommand { ) *GitCommand {
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars()) cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath())
// here we're doing a bunch of dependency injection for each of our commands structs. // here we're doing a bunch of dependency injection for each of our commands structs.
// This is admittedly messy, but allows us to test each command struct in isolation, // This is admittedly messy, but allows us to test each command struct in isolation,

View file

@ -20,13 +20,6 @@ type gitCmdObjBuilder struct {
// the old builder) must keep running its commands against the repo it // the old builder) must keep running its commands against the repo it
// started in, not whichever one the process has since moved to. // started in, not whichever one the process has since moved to.
repoDir string repoDir string
// The env vars every command we produce gets: the optional-locks one below,
// plus the repo's git location if it has one (see
// RepoPaths.GitLocationEnvVars). Those are in the process env too, but for
// the same reason as repoDir we don't rely on that: the process env belongs
// to whichever repo lazygit has since switched to.
envVars []string
} }
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
@ -37,7 +30,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar. // only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar.
var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0" var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0"
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []string) *gitCmdObjBuilder { func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder {
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{ return &gitCmdObjRunner{
@ -50,16 +43,15 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
return &gitCmdObjBuilder{ return &gitCmdObjBuilder{
innerBuilder: updatedBuilder, innerBuilder: updatedBuilder,
repoDir: repoDir, repoDir: repoDir,
envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...),
} }
} }
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir) return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
} }
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir) return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
} }
func (self *gitCmdObjBuilder) Quote(str string) string { func (self *gitCmdObjBuilder) Quote(str string) string {

View file

@ -18,7 +18,6 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
utils.NewDummyLog(), utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo", "/path/to/repo",
nil,
) )
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
@ -35,27 +34,8 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
utils.NewDummyLog(), utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo", "/path/to/repo",
nil,
) )
assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir) assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir)
assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir) assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir)
} }
// A repo whose git dir isn't in its worktree can't be found by running a
// command there, so the builder has to tell every command where it is; see
// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only
// for the repo lazygit is in right now, which isn't necessarily this one.
func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) {
builder := NewGitCmdObjBuilder(
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/worktree",
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
)
assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
}

View file

@ -174,7 +174,7 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi
} }
if workingTreeState.Rebasing { if workingTreeState.Rebasing {
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, commits, addConflictedRebasingCommit) rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, addConflictedRebasingCommit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -251,8 +251,8 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line
}) })
} }
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, existingCommits []*models.Commit, addConflictingCommit bool) ([]*models.Commit, error) { func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) ([]*models.Commit, error) {
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), existingCommits, false) return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), false)
} }
func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) { func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) {
@ -271,56 +271,39 @@ func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool
} }
} }
return self.getHydratedTodoCommits(hashPool, commits, nil, true) return self.getHydratedTodoCommits(hashPool, commits, true)
} }
func (self *CommitLoader) getHydratedTodoCommits( func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) {
hashPool *utils.StringPool,
todoCommits []*models.Commit,
existingCommits []*models.Commit,
todoFileHasShortHashes bool,
) ([]*models.Commit, error) {
if len(todoCommits) == 0 { if len(todoCommits) == 0 {
return nil, nil return nil, nil
} }
// A refresh of only the rebasing todos should reuse the already loaded todos to avoid commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
// unnecessary git show calls. return commit.Hash(), commit.Hash() != ""
fullCommits := map[string]*models.Commit{}
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)
}
}
commitHashesToFetch := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
return commit.Hash(), commit.Hash() != "" && fullCommits[commit.Hash()] == nil
}) })
if len(commitHashesToFetch) > 0 { // note that we're not filtering these as we do non-rebasing commits just because
// note that we're not filtering these as we do non-rebasing commits just because // I suspect that will cause some damage
// I suspect that will cause some damage cmdObj := self.cmd.New(
cmdObj := self.cmd.New( NewGitCmd("show").
NewGitCmd("show"). Config("log.showSignature=false").
Config("log.showSignature=false"). Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat). Arg(commitHashes...).
Arg(commitHashesToFetch...). ToArgv(),
ToArgv(), ).DontLog()
).DontLog()
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) { fullCommits := map[string]*models.Commit{}
if line == "" || line[0] != '+' { err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
return false, nil if line == "" || line[0] != '+' {
}
commit := self.extractCommitFromLine(hashPool, line[1:], false)
fullCommits[commit.Hash()] = commit
return false, nil return false, nil
})
if err != nil {
return nil, err
} }
commit := self.extractCommitFromLine(hashPool, line[1:], false)
fullCommits[commit.Hash()] = commit
return false, nil
})
if err != nil {
return nil, err
} }
findFullCommit := lo.Ternary(todoFileHasShortHashes, findFullCommit := lo.Ternary(todoFileHasShortHashes,

View file

@ -538,110 +538,6 @@ 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) { func TestCommitLoader_setCommitStatuses(t *testing.T) {
type scenario struct { type scenario struct {
testName string testName string

View file

@ -2,12 +2,12 @@ package git_commands
import ( import (
"fmt" "fmt"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
) )
type FileLoaderConfig interface { type FileLoaderConfig interface {
@ -88,66 +88,27 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
files = append(files, file) files = append(files, 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
return files worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
} for _, file := range files {
for _, worktreePath := range worktreePaths {
// Looks up how long the conflict markers in the conflicted files are. We ask absFilePath, err := filepath.Abs(file.Path)
// git for all of them at once, because spawning a process per file would be if err != nil {
// painfully slow when hundreds of files are conflicted (especially on Windows). self.Log.Error(err)
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) { continue
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool { }
return file.HasInlineMergeConflicts if absFilePath == worktreePath {
}) file.IsWorktree = true
if len(conflictedFiles) == 0 { // `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
return // If we include the slash, it will be rendered as a folder with a null file inside.
} file.Path = strings.TrimSuffix(file.Path, "/")
break
paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string { }
return file.Path
})
markerSizes, err := self.getConflictMarkerSizes(paths)
if err != nil {
self.Log.Error(err)
return
}
for _, file := range conflictedFiles {
file.ConflictMarkerSize = markerSizes[file.Path]
}
}
func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) {
cmdArgs := NewGitCmd("check-attr").
Arg("-z").
Arg("--stdin").
Arg("conflict-marker-size").
ToArgv()
// -z makes git both read the paths and write its output NUL-separated, so
// that paths containing newlines don't throw us off.
output, _, err := self.cmd.New(cmdArgs).
SetStdin(strings.Join(paths, "\x00")).
DontLog().
RunWithOutputs()
if err != nil {
return nil, err
}
markerSizes := map[string]int{}
fields := strings.Split(output, "\x00")
// Each path yields a path/attribute/value triple; the value is either a
// number or something like "unspecified", in which case we leave the marker
// size at 0 to say that git's default applies.
for i := 0; i+2 < len(fields); i += 3 {
if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 {
markerSizes[fields[i]] = markerSize
} }
} }
return markerSizes, nil return files
} }
type FileDiff struct { type FileDiff struct {

View file

@ -37,10 +37,6 @@ func TestFileGetStatusFiles(t *testing.T) {
ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"}, ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"},
"4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt", "4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt",
nil, nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file5.txt\x00conflict-marker-size\x00unspecified\x00",
nil,
), ),
showNumstatInFilesView: true, showNumstatInFilesView: true,
expectedFiles: []*models.File{ expectedFiles: []*models.File{
@ -116,58 +112,6 @@ func TestFileGetStatusFiles(t *testing.T) {
}, },
}, },
}, },
{
testName: "Conflicted files with a conflict-marker-size attribute",
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
"UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt",
nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file1.txt\x00conflict-marker-size\x0032\x00"+
"file2.txt\x00conflict-marker-size\x00unspecified\x00"+
"file3.txt\x00conflict-marker-size\x00nonsense\x00",
nil,
),
expectedFiles: []*models.File{
{
Path: "file1.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
ConflictMarkerSize: 32,
DisplayString: "UU file1.txt",
ShortStatus: "UU",
},
{
Path: "file2.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file2.txt",
ShortStatus: "UU",
},
{
Path: "file3.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file3.txt",
ShortStatus: "UU",
},
{
Path: "file4.txt",
HasUnstagedChanges: true,
Tracked: true,
DisplayString: " M file4.txt",
ShortStatus: " M",
},
},
},
{ {
testName: "File with new line char", testName: "File with new line char",
similarityThreshold: 50, similarityThreshold: 50,

View file

@ -6,7 +6,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
) )
// OptionalLocksEnvVar is the name of the environment variable that tells git // OptionalLocksEnvVar is the name of the environment variable that tells git
@ -19,15 +18,6 @@ import (
// that opts back in is the foreground files refresh; see FileLoader.gitStatus. // that opts back in is the foreground files refresh; see FileLoader.gitStatus.
const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS" const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS"
// forOtherRepo prepares a command that operates on a repo other than the one
// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE
// say where our repo is, and every command we run inherits them, so a command
// pointed at a different repo would be resolved against ours instead: `git -C
// <submodule> log` would silently log the superproject's commits.
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}
// convenience struct for building git commands. Especially useful when // convenience struct for building git commands. Especially useful when
// including conditional args // including conditional args
type GitCommandBuilder struct { type GitCommandBuilder struct {

View file

@ -6,8 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"os/exec"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -162,51 +160,9 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
return queryString, variables 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 { func (self *GitHubCommands) GetAuthToken(host string) string {
ghExe := ghExecutable() token, _ := auth.TokenForHost(host)
if ghExe == "" { return token
// 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 // FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo

View file

@ -1,14 +1,15 @@
package git_commands package git_commands
import ( import (
ioFs "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/go-errors/errors" "github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
) )
type RepoPaths struct { type RepoPaths struct {
@ -18,12 +19,10 @@ type RepoPaths struct {
repoGitDirPath string repoGitDirPath string
repoName string repoName string
isBareRepo bool isBareRepo bool
gitLocationEnvVars []string
} }
// Path to the current worktree. If we're in the main worktree, this will // Path to the current worktree. If we're in the main worktree, this will
// be the same as RepoPath(). It is empty for a bare repo, which has no // be the same as RepoPath()
// worktree at all.
func (self *RepoPaths) WorktreePath() string { func (self *RepoPaths) WorktreePath() string {
return self.worktreePath return self.worktreePath
} }
@ -54,33 +53,10 @@ func (self *RepoPaths) RepoName() string {
return self.repoName return self.repoName
} }
// Whether we found no worktree, so that there is nothing for lazygit to show.
// Note that this isn't quite git's core.bare: a repo that calls itself non-bare
// but whose worktree we couldn't find counts as bare for us too. Concretely,
// this is true when we're in
//
// - a genuinely bare repo;
// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is
// recorded but not somewhere we look;
// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such
// as a vcsh-style dotfiles repo that hasn't been given core.worktree.
//
// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir
// notices the worktree holding it and hands back that repo instead.
func (self *RepoPaths) IsBareRepo() bool { func (self *RepoPaths) IsBareRepo() bool {
return self.isBareRepo return self.isBareRepo
} }
// The environment that tells git where this repo is, as "NAME=value" entries.
// It is empty for the vast majority of repos, which git finds for itself by
// looking for a .git in the directory a command runs in. It is only non-empty
// when that doesn't work — when the git dir lives somewhere else entirely,
// because of core.worktree or --work-tree — and then every command addressing
// the repo has to carry it.
func (self *RepoPaths) GitLocationEnvVars() []string {
return self.gitLocationEnvVars
}
// Returns the repo paths for a typical repo // Returns the repo paths for a typical repo
func MockRepoPaths(currentPath string) *RepoPaths { func MockRepoPaths(currentPath string) *RepoPaths {
return &RepoPaths{ return &RepoPaths{
@ -108,76 +84,26 @@ func GetRepoPathsForDir(
dir string, dir string,
cmd oscommands.ICmdObjBuilder, cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) { ) (*RepoPaths, error) {
repoPaths, err := repoPathsForDir(dir, cmd) gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree")
if err != nil || !repoPaths.IsBareRepo() {
return repoPaths, err
}
// We're in a git dir rather than in a working tree, which usually just means
// somebody ran lazygit in the .git of an ordinary repo. git's convention is
// that a git dir called .git belongs to the directory holding it, so look
// there: if that is a working tree, it is the repo we were asked about, and
// there's no reason to make the user go up a directory and try again.
//
// The git dirs that aren't called .git keep the paths we have. A linked
// worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a
// working tree, but only the directory holding a .git tells us where, so we
// would be guessing. A bare repo's has none to find.
if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" {
return repoPaths, nil
}
pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd)
if err != nil || pathsFromWorkTree.IsBareRepo() {
return repoPaths, nil
}
return pathsFromWorkTree, nil
}
// repoPathsForDir asks git about the repo at dir, and reports a bare repo when
// there is no working tree there. Unlike GetRepoPathsForDir it never looks
// anywhere but dir, which is what keeps that one from going round in circles.
func repoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree")
if err != nil { if err != nil {
// --show-toplevel is the only one of these that needs a work tree, and return nil, err
// git makes it fatal when there isn't one. So this may just mean we're in
// a repo that has no work tree.
return getBareRepoPathsForDir(dir, cmd, err)
} }
gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n") gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n")
worktreePath := gitDirResults[0] worktreePath := gitDirResults[0]
worktreeGitDirPath := gitDirResults[1] worktreeGitDirPath := gitDirResults[1]
repoGitDirPath := gitDirResults[2] repoGitDirPath := gitDirResults[2]
isBareRepo := gitDirResults[3] == "true"
// A worktree that has the repo's common git dir to itself is the repo's main // If we're in a submodule, --show-superproject-working-tree will return
// worktree, so it is the repoPath. That holds for a submodule as well: its // a value, meaning gitDirResults will be length 5. In that case
// git dir lives under the superproject's .git/modules, but it is still the // return the worktree path as the repoPath. Otherwise we're in a
// submodule's own common dir. // normal repo or a worktree so return the parent of the git common
isMainWorktree := worktreeGitDirPath == repoGitDirPath // dir (repoGitDirPath)
isSubmodule := len(gitDirResults) == 5
// If we're in a submodule, --show-superproject-working-tree will return a
// value, meaning gitDirResults will be length 4. That only tells us anything
// new for a linked worktree of a submodule, which isMainWorktree misses.
isSubmodule := len(gitDirResults) == 4
// Otherwise we're in a linked worktree, and the repoPath is the repo's main
// worktree. git won't tell us where that is: `git worktree list` reports it
// as the common git dir with a trailing "/.git" removed, which is this same
// derivation. So take the directory holding the common git dir. That is the
// main worktree of an ordinary repo, and of a bare one it is the directory
// its worktrees live in. It is not the main worktree of a repo that moved
// that elsewhere with core.worktree; there we end up naming the git dir's
// directory, which means that the repo name we display in the status panel
// isn't correct, and we start looking for .lazygit.yml in the wrong place.
// Both of those are not severe enough to justify the extra git call to get
// the real main worktree, so we accept this for this rather niche use case.
var repoPath string var repoPath string
if isMainWorktree || isSubmodule { if isSubmodule {
repoPath = worktreePath repoPath = worktreePath
} else { } else {
repoPath = filepath.Dir(repoGitDirPath) repoPath = filepath.Dir(repoGitDirPath)
@ -190,113 +116,62 @@ func repoPathsForDir(
repoPath: repoPath, repoPath: repoPath,
repoGitDirPath: repoGitDirPath, repoGitDirPath: repoGitDirPath,
repoName: repoName, repoName: repoName,
isBareRepo: false, isBareRepo: isBareRepo,
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
}, nil }, nil
} }
// gitLocationEnvVars works out whether git can find the repo by itself when a
// command runs in its worktree, and if it can't, returns the environment that
// tells git where it is. See RepoPaths.GitLocationEnvVars.
func gitLocationEnvVars(
cmd oscommands.ICmdObjBuilder,
worktreePath string,
worktreeGitDirPath string,
) []string {
// The ordinary repo, where the git dir sits in the worktree. Both paths are
// git's own answers from the same invocation, so they are spelled alike and
// comparing them is safe.
if worktreeGitDirPath == filepath.Join(worktreePath, ".git") {
return nil
}
// A linked worktree or a submodule instead has a .git file naming its git
// dir, and git follows that just as happily. We could read the file, but the
// path in it may well name the same directory differently than git did
// above, so ask git to resolve it — from the worktree and nothing else.
discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir")
if err == nil && discoveredGitDirPath == worktreeGitDirPath {
return nil
}
return []string{
env.GitDirEnvVar + "=" + worktreeGitDirPath,
env.GitWorkTreeEnvVar + "=" + worktreePath,
}
}
// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the
// work tree. Everything but --show-toplevel works fine without one, so if the
// remaining queries succeed we are in a bare repo, and we return what we know
// about it with an empty worktreePath. If they fail too we simply aren't in a
// repo, and the caller's original error says so better than ours would.
func getBareRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
errWithWorktree error,
) (*RepoPaths, error) {
output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir")
if err != nil {
return nil, errWithWorktree
}
results := strings.Split(utils.NormalizeLinefeeds(output), "\n")
repoGitDirPath := results[1]
// A bare repo has no worktree, and so no repo path in the sense the caller
// with a worktree means. It doesn't matter much what we say here, because
// nobody reads it: whoever is handed a bare repo either offers to open a
// recent one instead (app.setupRepo) or is turned away by NewGitCommand. The
// directory holding the git dir is the nearest thing there is to a repo
// path.
repoPath := filepath.Dir(repoGitDirPath)
return &RepoPaths{
worktreePath: "",
worktreeGitDirPath: results[0],
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: filepath.Base(repoPath),
isBareRepo: true,
}, nil
}
// Asks git about the repo at dir. This is how we find our own repo, so it has
// to be answered the way git itself would answer it there, GIT_DIR and
// GIT_WORK_TREE included.
func callGitRevParseWithDir( func callGitRevParseWithDir(
cmd oscommands.ICmdObjBuilder, cmd oscommands.ICmdObjBuilder,
dir string, dir string,
gitRevArgs ...string, gitRevArgs ...string,
) (string, error) { ) (string, error) {
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// Asks git about a repo that isn't the one we have open; see forOtherRepo.
func callGitRevParseInOtherRepo(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
}
func newGitRevParseCmd(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) *oscommands.CmdObj {
gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...) gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...)
if dir != "" { if dir != "" {
gitRevParse.Dir(dir) gitRevParse.Dir(dir)
} }
return cmd.New(gitRevParse.ToArgv()).DontLog() gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog()
}
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
res, err := gitCmd.RunWithOutput() res, err := gitCmd.RunWithOutput()
if err != nil { if err != nil {
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err) return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
} }
return strings.TrimSpace(res), nil 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
}

View file

@ -38,6 +38,8 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git`, `C:\path\to\repo\.git`,
// --git-common-dir // --git-common-dir
`C:\path\to\repo\.git`, `C:\path\to\repo\.git`,
// --is-bare-repository
"false",
// --show-superproject-working-tree // --show-superproject-working-tree
}, []string{ }, []string{
// --show-toplevel // --show-toplevel
@ -46,10 +48,12 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git", "/path/to/repo/.git",
// --git-common-dir // --git-common-dir
"/path/to/repo/.git", "/path/to/repo/.git",
// --is-bare-repository
"false",
// --show-superproject-working-tree // --show-superproject-working-tree
}) })
runner.ExpectGitArgs( runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"), strings.Join(mockOutput, "\n"),
nil) nil)
}, },
@ -72,144 +76,50 @@ func TestGetRepoPaths(t *testing.T) {
Err: nil, Err: nil,
}, },
{ {
// git refuses to answer --show-toplevel when there's no work tree, so
// we have to ask a second time without it.
Name: "bare repo", Name: "bare repo",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
runner.ExpectGitArgs( // setup for main worktree
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --git-dir
`C:\path\to\project\bare.git`,
// --git-common-dir
`C:\path\to\project\bare.git`,
}, []string{
// --git-dir
"/path/to/project/bare.git",
// --git-common-dir
"/path/to/project/bare.git",
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join(mockOutput, "\n"),
nil)
},
Path: "/path/to/project",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: "",
worktreeGitDirPath: `C:\path\to\project\bare.git`,
repoPath: `C:\path\to\project`,
repoGitDirPath: `C:\path\to\project\bare.git`,
repoName: `project`,
isBareRepo: true,
}, &RepoPaths{
worktreePath: "",
worktreeGitDirPath: "/path/to/project/bare.git",
repoPath: "/path/to/project",
repoGitDirPath: "/path/to/project/bare.git",
repoName: "project",
isBareRepo: true,
}),
Err: nil,
},
{
// Standing in the .git dir of an ordinary repo: git refuses to name a
// work tree, but the directory holding the .git is one, so we open the
// repo from there.
Name: "in a repo's .git dir",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git")
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo")
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join([]string{gitDir, gitDir}, "\n"),
nil)
// asking again from the directory holding the .git
runner.ExpectGitArgs(
append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join([]string{worktree, gitDir, gitDir}, "\n"),
nil)
},
Path: "/path/to/repo/.git",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\repo\.git`,
repoPath: `C:\path\to\repo`,
repoGitDirPath: `C:\path\to\repo\.git`,
repoName: `repo`,
isBareRepo: false,
}, &RepoPaths{
worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/repo/.git",
repoPath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
repoName: "repo",
isBareRepo: false,
}),
Err: nil,
},
{
// A repo whose work tree lives somewhere else entirely, as set up by
// core.worktree or by --work-tree. We're in the main worktree, but the
// git dir is not inside it.
Name: "repo with a separate work tree",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --show-toplevel // --show-toplevel
`C:\path\to\worktree`, `C:\path\to\repo`,
// --git-dir // --git-dir
`C:\path\to\repo\.git`, `C:\path\to\bare_repo\bare.git`,
// --git-common-dir // --git-common-dir
`C:\path\to\repo\.git`, `C:\path\to\bare_repo\bare.git`,
// --is-bare-repository
`true`,
// --show-superproject-working-tree // --show-superproject-working-tree
}, []string{ }, []string{
// --show-toplevel // --show-toplevel
"/path/to/worktree", "/path/to/repo",
// --git-dir // --git-dir
"/path/to/repo/.git", "/path/to/bare_repo/bare.git",
// --git-common-dir // --git-common-dir
"/path/to/repo/.git", "/path/to/bare_repo/bare.git",
// --is-bare-repository
"true",
// --show-superproject-working-tree // --show-superproject-working-tree
}) })
runner.ExpectGitArgs( runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"), strings.Join(mockOutput, "\n"),
nil) nil)
// asking git to find the repo from the work tree gets us nowhere,
// because there is no .git there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
"",
errors.New("fatal: not a git repository (or any of the parent directories): .git"))
}, },
Path: "/path/to/repo", Path: "/path/to/repo",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\worktree`, worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\repo\.git`, worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoPath: `C:\path\to\worktree`, repoPath: `C:\path\to\bare_repo`,
repoGitDirPath: `C:\path\to\repo\.git`, repoGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoName: `worktree`, repoName: `bare_repo`,
isBareRepo: false, isBareRepo: true,
gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`},
}, &RepoPaths{ }, &RepoPaths{
worktreePath: "/path/to/worktree", worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/repo/.git", worktreeGitDirPath: "/path/to/bare_repo/bare.git",
repoPath: "/path/to/worktree", repoPath: "/path/to/bare_repo",
repoGitDirPath: "/path/to/repo/.git", repoGitDirPath: "/path/to/bare_repo/bare.git",
repoName: "worktree", repoName: "bare_repo",
isBareRepo: false, isBareRepo: true,
gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
}), }),
Err: nil, Err: nil,
}, },
@ -223,6 +133,8 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git\modules\submodule1`, `C:\path\to\repo\.git\modules\submodule1`,
// --git-common-dir // --git-common-dir
`C:\path\to\repo\.git\modules\submodule1`, `C:\path\to\repo\.git\modules\submodule1`,
// --is-bare-repository
`false`,
// --show-superproject-working-tree // --show-superproject-working-tree
`C:\path\to\repo`, `C:\path\to\repo`,
}, []string{ }, []string{
@ -232,22 +144,15 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git/modules/submodule1", "/path/to/repo/.git/modules/submodule1",
// --git-common-dir // --git-common-dir
"/path/to/repo/.git/modules/submodule1", "/path/to/repo/.git/modules/submodule1",
// --is-bare-repository
"false",
// --show-superproject-working-tree // --show-superproject-working-tree
"/path/to/repo", "/path/to/repo",
}) })
runner.ExpectGitArgs( runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"), strings.Join(mockOutput, "\n"),
nil) nil)
// git finds the submodule's git dir from its work tree, via the
// .git file there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1")
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
gitDir,
nil)
}, },
Path: "/path/to/repo/submodule1", Path: "/path/to/repo/submodule1",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
@ -271,12 +176,7 @@ func TestGetRepoPaths(t *testing.T) {
Name: "git rev-parse returns an error", Name: "git rev-parse returns an error",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
runner.ExpectGitArgs( runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
"",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
// we're not in a repo at all, so asking about a bare one fails too
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
"", "",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
}, },
@ -284,7 +184,7 @@ func TestGetRepoPaths(t *testing.T) {
Expected: nil, Expected: nil,
Err: func(getRevParseArgs argFn) error { Err: func(getRevParseArgs argFn) error {
args := strings.Join(getRevParseArgs(), " ") args := strings.Join(getRevParseArgs(), " ")
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
}, },
}, },
} }

View file

@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false"). Config("log.showsignature=false").
ToArgv() ToArgv()
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err return strings.TrimSpace(summary), err
} }
@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
// caller then stages the submodule to record the resolution. // caller then stages the submodule to record the resolution.
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error { func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv() cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run() return self.cmd.New(cmdArgs).Run()
} }
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits // ConflictSideLog returns a oneline log, run inside the submodule, of the commits
@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
Arg("--oneline", "--color=always", otherSide+".."+side). Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv() ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
} }
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
@ -195,15 +195,20 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked"). Arg("--include-untracked").
ToArgv() ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run() return self.cmd.New(cmdArgs).Run()
} }
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
parentDir := ""
if submodule.ParentModule != nil {
parentDir = submodule.ParentModule.FullPath()
}
cmdArgs := NewGitCmd("submodule"). cmdArgs := NewGitCmd("submodule").
Arg("update", "--init", "--force", "--", submodule.Path). Arg("update", "--init", "--force", "--", submodule.Path).
DirIf(parentDir != "", parentDir).
ToArgv() ToArgv()
return self.runInParentModule(submodule, self.cmd.New(cmdArgs)) return self.cmd.New(cmdArgs).Run()
} }
func (self *SubmoduleCommands) UpdateAll() error { func (self *SubmoduleCommands) UpdateAll() error {
@ -220,16 +225,9 @@ func (self *SubmoduleCommands) UpdateAll() error {
// temporarily chdir-ing the process there, which would leak the parent // temporarily chdir-ing the process there, which would leak the parent
// module's directory into whatever other commands run concurrently (e.g. a // module's directory into whatever other commands run concurrently (e.g. a
// background refresh's). // background refresh's).
//
// That directory is relative, so it resolves against the process working
// directory rather than against the repo directory the command builder
// otherwise pins commands to. Only foreground commands the user issued end up
// here, and lazygit won't switch repos while one of those is in flight, so the
// two are the same directory; don't call this from background work, where they
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil { if submodule.ParentModule != nil {
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath())) cmdObj.SetWd(submodule.ParentModule.FullPath())
} }
return cmdObj.Run() return cmdObj.Run()
} }

View file

@ -1,13 +1,10 @@
package git_commands package git_commands
import ( import (
"strings"
"testing" "testing"
"github.com/go-errors/errors" "github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/samber/lo"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -83,27 +80,6 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
runner.CheckForMissingCalls() runner.CheckForMissingCalls()
} }
// A command that runs inside a submodule mustn't inherit the GIT_DIR and
// GIT_WORK_TREE that say where the superproject is; git would answer it from
// there instead, and the answer would look perfectly plausible.
func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) {
t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git")
t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo")
runner := oscommands.NewFakeRunner(t).
ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool {
return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool {
return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") ||
strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=")
})
}, "bbbbbbb the subject\n", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
_, err := instance.GetCommitSummary("mysub", "bbbbbbb")
assert.NoError(t, err)
runner.CheckForMissingCalls()
}
func TestSubmoduleConflictSideLog(t *testing.T) { func TestSubmoduleConflictSideLog(t *testing.T) {
runner := oscommands.NewFakeRunner(t). runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil) ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)

View file

@ -385,22 +385,27 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
// WorktreeFileDiff returns the diff of a file // WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted // for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput() s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
return s return s
} }
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths // WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
// in the working tree. node is the item they belong to; all it decides is // in the working tree. When pathOverrides is non-empty, those paths are used instead of
// whether git has to compare against /dev/null, which is the case for a file // the node's path (used to diff only filtered/visible files within a directory).
// that isn't in the index yet. func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg() colorArg := self.diffRendererConfigManager.GetColorArg()
if plain { if plain {
colorArg = "never" colorArg = "never"
} }
prevPath := node.GetPreviousPath()
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
paths := pathOverrides
if len(paths) == 0 {
paths = []string{node.GetPath()}
}
cmdArgs := NewGitCmd("diff"). cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
Arg("--submodule"). Arg("--submodule").
@ -410,6 +415,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
Arg("--"). Arg("--").
ArgIf(noIndex, "/dev/null"). ArgIf(noIndex, "/dev/null").
Arg(paths...). Arg(paths...).
ArgIf(prevPath != "", prevPath).
Dir(self.repoPaths.worktreePath). Dir(self.repoPaths.worktreePath).
ToArgv() ToArgv()

View file

@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
func (self *WorktreeCommands) Detach(worktreePath string) error { func (self *WorktreeCommands) Detach(worktreePath string) error {
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv() cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run() return self.cmd.New(cmdArgs).Run()
} }
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) { func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {

View file

@ -22,6 +22,9 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader {
} }
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
currentRepoPath := self.repoPaths.RepoPath()
worktreePath := self.repoPaths.WorktreePath()
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv() cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv()
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil { if err != nil {
@ -51,13 +54,17 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if strings.HasPrefix(splitLine, "worktree ") { if strings.HasPrefix(splitLine, "worktree ") {
path := strings.SplitN(splitLine, " ", 2)[1] path := strings.SplitN(splitLine, " ", 2)[1]
isMain := path == currentRepoPath
isCurrent := path == worktreePath
isPathMissing := self.pathExists(path)
current = &models.Worktree{ current = &models.Worktree{
IsPathMissing: self.pathExists(path), IsMain: isMain,
IsCurrent: isCurrent,
IsPathMissing: isPathMissing,
Path: path, Path: path,
// we defer populating GitDir until a loop below so that // we defer populating GitDir until a loop below so that
// we can parallelize the calls to git rev-parse, and // we can parallelize the calls to git rev-parse
// IsMain/IsCurrent because they are derived from GitDir
GitDir: "", GitDir: "",
} }
} else if strings.HasPrefix(splitLine, "HEAD ") { } else if strings.HasPrefix(splitLine, "HEAD ") {
@ -77,7 +84,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if worktree.IsPathMissing { if worktree.IsPathMissing {
return return
} }
gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir") gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir")
if err != nil { if err != nil {
self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err) self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err)
return return
@ -88,23 +95,6 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
} }
wg.Wait() wg.Wait()
// Identify the current and the main worktree by their git dir rather than by
// their path: `git worktree list` reports the main worktree as the common
// git dir with a trailing "/.git" removed, which is the working tree only
// when the git dir sits inside it. In a submodule, a bare repo or a repo
// using core.worktree it doesn't, and comparing paths then matches nothing.
// A worktree whose directory is gone has no git dir to compare, so there we
// have nothing better than its path.
for _, worktree := range worktrees {
if worktree.GitDir != "" {
worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath()
worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath()
} else {
worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath()
worktree.IsMain = worktree.Path == self.repoPaths.RepoPath()
}
}
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string { names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
return worktree.Path return worktree.Path
})) }))

View file

@ -23,10 +23,8 @@ func TestGetWorktrees(t *testing.T) {
{ {
testName: "Single worktree (main)", testName: "Single worktree (main)",
repoPaths: &RepoPaths{ repoPaths: &RepoPaths{
repoPath: "/path/to/repo", repoPath: "/path/to/repo",
worktreePath: "/path/to/repo", worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
}, },
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@ -57,10 +55,8 @@ branch refs/heads/mybranch
{ {
testName: "Multiple worktrees (main + linked)", testName: "Multiple worktrees (main + linked)",
repoPaths: &RepoPaths{ repoPaths: &RepoPaths{
repoPath: "/path/to/repo", repoPath: "/path/to/repo",
worktreePath: "/path/to/repo", worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
}, },
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@ -110,10 +106,8 @@ branch refs/heads/mybranch-worktree
{ {
testName: "Worktree missing path", testName: "Worktree missing path",
repoPaths: &RepoPaths{ repoPaths: &RepoPaths{
repoPath: "/path/to/repo", repoPath: "/path/to/repo",
worktreePath: "/path/to/repo", worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
}, },
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@ -142,10 +136,8 @@ branch refs/heads/missingbranch
{ {
testName: "In linked worktree", testName: "In linked worktree",
repoPaths: &RepoPaths{ repoPaths: &RepoPaths{
repoPath: "/path/to/repo", repoPath: "/path/to/repo",
worktreePath: "/path/to/repo-worktree", worktreePath: "/path/to/repo-worktree",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree",
}, },
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@ -192,51 +184,11 @@ branch refs/heads/mybranch-worktree
}, },
expectedErr: "", expectedErr: "",
}, },
{
testName: "In a submodule",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo/mysubmodule",
worktreePath: "/path/to/repo/mysubmodule",
repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
// A submodule's git dir doesn't live inside its working tree, and
// `git worktree list` reports the git dir rather than the working
// tree it belongs to.
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
`worktree /path/to/repo/.git/modules/mysubmodule
HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d
branch refs/heads/mybranch
`,
nil)
gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir")
runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil)
_ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755)
},
expectedWorktrees: []*models.Worktree{
{
IsMain: true,
IsCurrent: true,
Path: "/path/to/repo/.git/modules/mysubmodule",
IsPathMissing: false,
GitDir: "/path/to/repo/.git/modules/mysubmodule",
Branch: "mybranch",
Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d",
Name: "mysubmodule",
},
},
expectedErr: "",
},
{ {
testName: "Detached HEAD worktree", testName: "Detached HEAD worktree",
repoPaths: &RepoPaths{ repoPaths: &RepoPaths{
repoPath: "/path/to/repo", repoPath: "/path/to/repo",
worktreePath: "/path/to/repo", worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
}, },
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},

View file

@ -18,14 +18,10 @@ type File struct {
Deleted bool Deleted bool
HasMergeConflicts bool HasMergeConflicts bool
HasInlineMergeConflicts bool HasInlineMergeConflicts bool
// How long the conflict markers in this file are, taken from its DisplayString string
// conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
// only look this up for files that have inline merge conflicts. LinesDeleted int
ConflictMarkerSize int LinesAdded int
DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// If true, this must be a worktree folder // If true, this must be a worktree folder
IsWorktree bool IsWorktree bool

View file

@ -1,12 +1,12 @@
package oscommands package oscommands
import ( import (
"bytes"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"github.com/go-errors/errors" "github.com/go-errors/errors"
"github.com/samber/lo" "github.com/samber/lo"
@ -228,47 +228,37 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
// keeping this here in case I adapt this code for some other purpose in the future // keeping this here in case I adapt this code for some other purpose in the future
// cmds[len(cmds)-1].Stdout = os.Stdout // cmds[len(cmds)-1].Stdout = os.Stdout
stderrs := make([]bytes.Buffer, len(cmds))
for i := range cmds {
cmds[i].Stderr = &stderrs[i]
}
// Start every command before waiting for any of them: waiting for a command
// closes our end of the pipe that feeds the next one, and a command that
// hasn't been started by then would inherit a closed stdin.
started := 0
var startErr error
for _, cmd := range cmds {
if err := cmd.Start(); err != nil {
startErr = err
break
}
started++
}
finalErrors := []string{} finalErrors := []string{}
if startErr != nil { wg := sync.WaitGroup{}
c.Log.Error(startErr) wg.Add(len(cmds))
finalErrors = append(finalErrors, startErr.Error())
// Without the rest of the pipeline to drain them, the commands we did for _, cmd := range cmds {
// start could block forever writing to a full pipe. go utils.Safe(func() {
for _, cmd := range cmds[:started] { stderr, err := cmd.StderrPipe()
_ = cmd.Process.Kill() if err != nil {
} c.Log.Error(err)
}
if err := cmd.Start(); err != nil {
c.Log.Error(err)
}
if b, err := io.ReadAll(stderr); err == nil {
if len(b) > 0 {
finalErrors = append(finalErrors, string(b))
}
}
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
wg.Done()
})
} }
for i, cmd := range cmds[:started] { wg.Wait()
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
if stderrs[i].Len() > 0 {
finalErrors = append(finalErrors, stderrs[i].String())
}
}
if len(finalErrors) > 0 { if len(finalErrors) > 0 {
return errors.New(strings.Join(finalErrors, "\n")) return errors.New(strings.Join(finalErrors, "\n"))

View file

@ -204,8 +204,7 @@ func (p *winPty) Close() error {
// slave closes on child exit, but ConPTY keeps the pipe alive until we call // slave closes on child exit, but ConPTY keeps the pipe alive until we call
// ClosePseudoConsole explicitly. Without doing that on child exit, the // ClosePseudoConsole explicitly. Without doing that on child exit, the
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and // scanner in pkg/tasks.NewCmdTask would block forever on the next read and
// the render would never reach its end of input, so the new content would // the post-content view never gets cleared (FlushStaleCells never fires).
// never be swapped in.
func startWaiter(proc *os.Process, p *winPty) func() error { func startWaiter(proc *os.Process, p *winPty) func() error {
done := make(chan struct{}) done := make(chan struct{})
var waitErr error var waitErr error

View file

@ -44,8 +44,7 @@ func (self *Hunk) lineCount() int {
// Returns all lines in the hunk, including the header line // Returns all lines in the hunk, including the header line
func (self *Hunk) allLines() []*PatchLine { func (self *Hunk) allLines() []*PatchLine {
lines := make([]*PatchLine, 1, 1+len(self.bodyLines)) lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
lines = append(lines, self.bodyLines...) lines = append(lines, self.bodyLines...)
return lines return lines
} }

View file

@ -50,13 +50,13 @@ type editPreset struct {
suspend func() bool 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 // 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 { func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012. // 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` 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` 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` nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`

View file

@ -191,51 +191,6 @@ func validateCustomCommandKey(key Keybinding) error {
return nil 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 { func validateCustomCommands(customCommands []CustomCommand) error {
for _, customCommand := range customCommands { for _, customCommand := range customCommands {
if err := validateCustomCommandKey(customCommand.Key); err != nil { if err := validateCustomCommandKey(customCommand.Key); err != nil {
@ -261,15 +216,6 @@ func validateCustomCommands(customCommands []CustomCommand) error {
return err return err
} }
} else { } 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 { for _, prompt := range customCommand.Prompts {
if err := validateCustomCommandPrompt(prompt); err != nil { if err := validateCustomCommandPrompt(prompt); err != nil {
return err return err

View file

@ -225,43 +225,6 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false}, {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", name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) { setup: func(config *UserConfig, _ string) {

44
pkg/env/env.go vendored
View file

@ -2,59 +2,27 @@ package env
import ( import (
"os" "os"
"strings"
) )
// This package encapsulates accessing/mutating the ENV of the program. // This package encapsulates accessing/mutating the ENV of the program.
// The variables with which git can be told where a repo is, rather than having
// it find out from the working directory.
const (
GitDirEnvVar = "GIT_DIR"
GitWorkTreeEnvVar = "GIT_WORK_TREE"
)
func GetGitDirEnv() string { func GetGitDirEnv() string {
return os.Getenv(GitDirEnvVar) return os.Getenv("GIT_DIR")
} }
func SetGitDirEnv(value string) { func SetGitDirEnv(value string) {
os.Setenv(GitDirEnvVar, value) os.Setenv("GIT_DIR", value)
} }
func GetWorkTreeEnv() string { func GetWorkTreeEnv() string {
return os.Getenv(GitWorkTreeEnvVar) return os.Getenv("GIT_WORK_TREE")
} }
func SetWorkTreeEnv(value string) { func SetWorkTreeEnv(value string) {
os.Setenv(GitWorkTreeEnvVar, value) os.Setenv("GIT_WORK_TREE", value)
} }
func UnsetGitLocationEnvVars() { func UnsetGitLocationEnvVars() {
_ = os.Unsetenv(GitDirEnvVar) _ = os.Unsetenv("GIT_DIR")
_ = os.Unsetenv(GitWorkTreeEnvVar) _ = os.Unsetenv("GIT_WORK_TREE")
}
// GetGitLocationEnvVars returns the location variables that are set, as
// "NAME=value" entries.
func GetGitLocationEnvVars() []string {
envVars := []string{}
for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} {
if value := os.Getenv(name); value != "" {
envVars = append(envVars, name+"="+value)
}
}
return envVars
}
// SetGitLocationEnvVars sets the location variables from "NAME=value" entries,
// clearing both first so that only what is given remains. Passing nothing is
// how you say the repo is to be found from the working directory.
func SetGitLocationEnvVars(envVars []string) {
UnsetGitLocationEnvVars()
for _, envVar := range envVars {
if name, value, ok := strings.Cut(envVar, "="); ok {
os.Setenv(name, value)
}
}
} }

View file

@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
g := newTestGui(t) g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0) view, _ := g.SetView("list", 0, 0, 20, 10, 0)
doubleClicks := []bool{} doubleClicks := []bool{}
g.SetViewClickBinding(&ViewMouseBinding{ assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list", ViewName: "list",
Key: MouseLeft, Key: MouseLeft,
Handler: func(opts ViewMouseBindingOpts) error { Handler: func(opts ViewMouseBindingOpts) error {
doubleClicks = append(doubleClicks, opts.IsDoubleClick) doubleClicks = append(doubleClicks, opts.IsDoubleClick)
return nil return nil
}, },
}) }))
for _, event := range []GocuiEvent{ for _, event := range []GocuiEvent{
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)), gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),

View file

@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool {
v.TextArea.GoToEndOfLine() v.TextArea.GoToEndOfLine()
case key.Equals(NewKeyStrMod("y", ModCtrl)): case key.Equals(NewKeyStrMod("y", ModCtrl)):
v.TextArea.Yank() v.TextArea.Yank()
case key.IsPrintable(): case key.Str() != "" && key.Mod() == 0:
v.TextArea.TypeCharacter(key.Str()) v.TextArea.TypeCharacter(key.Str())
default: default:
return false return false

View file

@ -38,11 +38,6 @@ var (
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further // ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled") 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 ( const (
@ -222,11 +217,6 @@ type Gui struct {
// worker goroutines, so it's atomic. // worker goroutines, so it's atomic.
uiThreadID atomic.Int64 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 // blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed // the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are // once the count drops back to zero, while mouse clicks and hover are
@ -311,12 +301,6 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
// runs during startup, before we reach MainLoop. // runs during startup, before we reach MainLoop.
g.uiThreadID.Store(goid.Get()) 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 return g, nil
} }
@ -675,15 +659,19 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
} }
// SetTabClickBinding sets a binding for a tab click event // SetTabClickBinding sets a binding for a tab click event
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) { func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error {
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{ g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
viewName: viewName, viewName: viewName,
handler: handler, handler: handler,
}) })
return nil
} }
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) { func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
g.viewMouseBindings = append(g.viewMouseBindings, binding) g.viewMouseBindings = append(g.viewMouseBindings, binding)
return nil
} }
// captureMouse routes subsequent mouse events to view until the mouse button is // captureMouse routes subsequent mouse events to view until the mouse button is
@ -905,50 +893,36 @@ func (g *Gui) EndBlockingEvents() error {
} }
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the // OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
// caller until f has run. Use it to read UI-thread-owned state (the model, // caller until f has run, returning f's error. Use it to read UI-thread-owned
// contexts) from a worker without racing the UI thread. // 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: // 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 // 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 // deadlocks. Callers arrange this by construction (see the refresh helper's
// RefreshFromWorker); a debug-only assertion there guards against getting it // RefreshFromWorker); a debug-only assertion there guards against getting it
// wrong. // wrong.
func (g *Gui) OnUIThreadAndWait(f func()) error { func (g *Gui) OnUIThreadAndWait(f func() error) error {
return g.onUIThreadAndWait(f, false) return g.onUIThreadAndWait(f, false)
} }
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine, // Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
// so it doesn't count towards the program being busy (see UpdateBackground). // so it doesn't count towards the program being busy (see UpdateBackground).
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error { func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
return g.onUIThreadAndWait(f, true) return g.onUIThreadAndWait(f, true)
} }
func (g *Gui) onUIThreadAndWait(f func(), background bool) error { func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
enqueue := g.Update enqueue := g.Update
if background { if background {
enqueue = g.UpdateBackground enqueue = g.UpdateBackground
} }
ran := make(chan struct{}) result := make(chan error, 1)
enqueue(func(*Gui) error { enqueue(func(*Gui) error {
f() result <- f()
close(ran)
return nil 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 // Calls a function in a goroutine. Handles panics gracefully and tracks
@ -1272,7 +1246,7 @@ func calcScrollbarRune(
func calcRealScrollbarStartEnd(v *View) (bool, int, int) { func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
height := v.InnerHeight() height := v.InnerHeight()
fullHeight := v.scrollbarContentHeight() - v.scrollMargin() fullHeight := v.ViewLinesHeight() - v.scrollMargin()
if v.CanScrollPastBottom { if v.CanScrollPastBottom {
fullHeight += height fullHeight += height
@ -1454,7 +1428,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
currentBgColor = v.BgColor currentBgColor = v.BgColor
} }
if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() { if i >= currentTabStart && i <= currentTabEnd {
currentFgColor = v.SelFgColor currentFgColor = v.SelFgColor
if v != g.currentView { if v != g.currentView {
currentFgColor &= ^AttrBold currentFgColor &= ^AttrBold
@ -1493,7 +1467,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' // drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.buf.lines) == 0 { if len(v.lines) == 0 {
return nil return nil
} }
@ -1615,20 +1589,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views) 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. // draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error { func (g *Gui) draw(v *View) error {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
@ -1649,11 +1609,11 @@ func (g *Gui) draw(v *View) error {
Screen.HideCursor() Screen.HideCursor()
} }
v.draw(g.IsFocused()) v.draw()
if v.Frame { if v.Frame {
var fgColor, bgColor, frameColor Attribute var fgColor, bgColor, frameColor Attribute
if g.Highlight && g.hasFocus(v) && g.IsFocused() { if g.Highlight && v == g.currentView {
fgColor = g.SelFgColor fgColor = g.SelFgColor
bgColor = g.SelBgColor bgColor = g.SelBgColor
frameColor = g.SelFrameColor frameColor = g.SelFrameColor
@ -1757,13 +1717,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if newY < 0 { if newY < 0 {
newY = 0 newY = 0
newCy = -v.oy newCy = -v.oy
} else if newY >= len(v.buf.lines) { } else if newY >= len(v.lines) {
newY = len(v.buf.lines) - 1 newY = len(v.lines) - 1
newCy = newY - v.oy newCy = newY - v.oy
} }
visibleLineWidth := 0 visibleLineWidth := 0
for _, c := range v.buf.lines[newY].cells { for _, c := range v.lines[newY].cells {
visibleLineWidth += c.width visibleLineWidth += c.width
} }
if visibleLineWidth < newX { if visibleLineWidth < newX {
@ -1773,8 +1733,10 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
} }
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil { if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
if link := v.hyperlinkAt(newX, newY); link != "" { if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
return g.openHyperlink(link, v.name) if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
return g.openHyperlink(link, v.name)
}
} }
} }
@ -1993,7 +1955,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
matchingParentViewKb = nil matchingParentViewKb = nil
break break
} }
if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) { if v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = kb matchingParentViewKb = kb
} }
if globalKb == nil && kb.viewName == "" { if globalKb == nil && kb.viewName == "" {
@ -2028,21 +1990,7 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
return nil 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 { 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 { if g.focusHandler != nil {
return g.focusHandler(ev.Focused) return g.focusHandler(ev.Focused)
} }
@ -2105,15 +2053,13 @@ func (g *Gui) isSuspended() bool {
return g.suspended return g.suspended
} }
// matchView returns if the keybinding matches the given view (and the view's context) // matchView returns if the keybinding matches the current view (and the view's context)
func (g *Gui) matchView(v *View, kb *keybinding) bool { func (g *Gui) matchView(v *View, kb *keybinding) bool {
// if the user is typing in a field, ignore char keys
if v == nil { if v == nil {
return false return false
} }
// If the user is typing in a field, printable keys are theirs to type, so no if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
// 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 return false
} }
if kb.viewName != v.name { if kb.viewName != v.name {

View file

@ -61,12 +61,6 @@ func (k Key) IsSet() bool {
return k.keyName != 0 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 { func (k Key) Equals(otherKey Key) bool {
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
} }

View file

@ -1,16 +0,0 @@
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())
}

View file

@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
}, },
}, },
} { } {
g.SetViewClickBinding(binding) assert.NoError(t, g.SetViewClickBinding(binding))
} }
g.captureMouse(view) g.captureMouse(view)
@ -69,7 +69,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
receivedBy := "" receivedBy := ""
for _, viewName := range []string{"left", "right"} { for _, viewName := range []string{"left", "right"} {
g.SetViewClickBinding(&ViewMouseBinding{ assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName, ViewName: viewName,
Key: MouseLeft, Key: MouseLeft,
Modifier: ModMotion, Modifier: ModMotion,
@ -77,7 +77,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
receivedBy = viewName receivedBy = viewName
return nil return nil
}, },
}) }))
} }
assert.NoError(t, g.onKey(&GocuiEvent{ assert.NoError(t, g.onKey(&GocuiEvent{
@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
view.Tabs = []string{"first", "second"} view.Tabs = []string{"first", "second"}
clickedTabs := []int{} clickedTabs := []int{}
g.SetTabClickBinding("tabs", func(tabIndex int) error { assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
clickedTabs = append(clickedTabs, tabIndex) clickedTabs = append(clickedTabs, tabIndex)
return nil return nil
}) }))
assert.NoError(t, g.onKey(&GocuiEvent{ assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, Type: eventMouse,
@ -172,7 +172,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
_, _ = g.SetView("right", 21, 0, 41, 10, 0) _, _ = g.SetView("right", 21, 0, 41, 10, 0)
receivedBy := "" receivedBy := ""
for _, viewName := range []string{"left", "right"} { for _, viewName := range []string{"left", "right"} {
g.SetViewClickBinding(&ViewMouseBinding{ assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName, ViewName: viewName,
Key: MouseLeft, Key: MouseLeft,
Modifier: ModMotion, Modifier: ModMotion,
@ -180,7 +180,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
receivedBy = viewName receivedBy = viewName
return nil return nil
}, },
}) }))
} }
g.captureMouse(left) g.captureMouse(left)

View file

@ -1,142 +0,0 @@
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)
}

View file

@ -1,58 +0,0 @@
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()
}
}

View file

@ -300,15 +300,15 @@ func (g *Gui) pollEvent() GocuiEvent {
if g.playRecording { if g.playRecording {
select { select {
case ev := <-g.replayedEvents.Keys: case ev := <-g.replayedEvents.Keys:
tev = ev.toTcellEvent() tev = (ev).toTcellEvent()
task = ev.task task = ev.task
case ev := <-g.replayedEvents.Resizes: case ev := <-g.replayedEvents.Resizes:
tev = ev.toTcellEvent() tev = (ev).toTcellEvent()
case ev := <-g.replayedEvents.MouseEvents: case ev := <-g.replayedEvents.MouseEvents:
tev = ev.toTcellEvent() tev = (ev).toTcellEvent()
task = ev.task task = ev.task
case ev := <-g.replayedEvents.FocusEvents: case ev := <-g.replayedEvents.FocusEvents:
tev = ev.toTcellEvent() tev = (ev).toTcellEvent()
task = ev.task task = ev.task
} }
} else { } else {

View file

@ -1,43 +0,0 @@
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

View file

@ -11,7 +11,6 @@ import (
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color" "github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
"github.com/samber/lo"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -102,13 +101,15 @@ func TestWriteString(t *testing.T) {
for _, test := range tests { for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal) v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines { for _, l := range test.existingLines {
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)}) v.lines = append(v.lines, lineType{cells: stringToCells(l)})
} }
for _, s := range test.stringsToWrite { for _, s := range test.stringsToWrite {
v.writeString(s) v.writeString(s)
} }
resultingLines := lo.Map(v.buf.lines, var resultingLines [][]string
func(l lineType, _ int) []string { return cellsToStrings(l.cells) }) for _, l := range v.lines {
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
assert.Equal(t, test.expectedLines, resultingLines) assert.Equal(t, test.expectedLines, resultingLines)
} }
} }
@ -143,115 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt") v.writeString("htt")
// No hyperlinks are generated for incomplete URLs // No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink) assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even // Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet) // though we didn't see a newline yet)
v.writeString("ps://example.com") v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink) assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
v.Clear() v.Clear()
// Valid but incomplete URL // Valid but incomplete URL
v.writeString("https://exa") v.writeString("https://exa")
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink) assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link // Writing more characters to the same fixes the link
v.writeString("mple.com") v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink) assert.Equal(t, "https://example.com", v.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) { func TestContainsColoredText(t *testing.T) {
@ -328,7 +233,7 @@ func TestContainsColoredText(t *testing.T) {
for j, cells := range test.lines { for j, cells := range test.lines {
lines[j] = lineType{cells: cells} lines[j] = lineType{cells: cells}
} }
v := &View{buf: &viewBuffer{lines: lines}} v := &View{lines: lines}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i) assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
} }
} }
@ -343,8 +248,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
// "a", then "skip to row 3" (i.e. one blank row), then "b". // "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n") v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.buf.lines)) got := make([][]string, 0, len(v.lines))
for _, l := range v.buf.lines { for _, l := range v.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
@ -364,8 +269,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row. // ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n") v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.buf.lines)) got := make([][]string, 0, len(v.lines))
for _, l := range v.buf.lines { for _, l := range v.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
assert.Equal(t, [][]string{ assert.Equal(t, [][]string{
@ -377,31 +282,6 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
}, got) }, 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) { func TestWriteCursorForwardEscape(t *testing.T) {
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX, // ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward // "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
@ -412,8 +292,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
// "a" + ECH 5 + CUF 5 + "b" — visually "a b". // "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n") v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.buf.lines)) got := make([][]string, 0, len(v.lines))
for _, l := range v.buf.lines { for _, l := range v.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
@ -432,8 +312,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
v.writeString("abcdefghij\n") v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n") v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.buf.lines)) got := make([][]string, 0, len(v.lines))
for _, l := range v.buf.lines { for _, l := range v.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
assert.Equal(t, [][]string{ assert.Equal(t, [][]string{
@ -464,7 +344,11 @@ func cellsToString(cells []cell) string {
} }
func cellsToStrings(cells []cell) []string { func cellsToStrings(cells []cell) []string {
return lo.Map(cells, func(c cell, _ int) string { return c.chr }) s := []string{}
for _, c := range cells {
s = append(s, c.chr)
}
return s
} }
func TestLineWrap(t *testing.T) { func TestLineWrap(t *testing.T) {
@ -650,7 +534,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
// renders with bg=red. The trailing area past "foo" must NOT extend // renders with bg=red. The trailing area past "foo" must NOT extend
// the red bg because '\n' marks the line as cleanly terminated. // the red bg because '\n' marks the line as cleanly terminated.
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n") v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
v.draw(true) v.draw()
// First row: cells 1..3 are "foo" (render with red bg via reverse), // First row: cells 1..3 are "foo" (render with red bg via reverse),
// cells 4..10 are trailing and should be plain default. // cells 4..10 are trailing and should be plain default.
@ -676,7 +560,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
// Reverse + red fg, "foo", no termination. The trailing cells past // Reverse + red fg, "foo", no termination. The trailing cells past
// "foo" should be plain default, NOT a continuation of the red bg. // "foo" should be plain default, NOT a continuation of the red bg.
v.writeString("\x1b[7m\x1b[31mfoo") v.writeString("\x1b[7m\x1b[31mfoo")
v.draw(true) v.draw()
// Cells 4..10 are trailing and should be default with no reverse. // Cells 4..10 are trailing and should be default with no reverse.
for x := 4; x <= 10; x++ { for x := 4; x <= 10; x++ {
@ -699,7 +583,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should // \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
// fill the remaining 8 cells with red. // fill the remaining 8 cells with red.
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n") v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
v.draw(true) v.draw()
// All ten cells at (1..10, 1) should have red bg. // All ten cells at (1..10, 1) should have red bg.
for x := 1; x <= 10; x++ { for x := 1; x <= 10; x++ {
@ -727,7 +611,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area // segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
// must pick up the red fill from \x1b[K. // 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.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
v.draw(true) v.draw()
// All three wrapped rows should have the red fill background across // All three wrapped rows should have the red fill background across
// the full InnerWidth, including the trailing cells past each row's // the full InnerWidth, including the trailing cells past each row's
@ -761,7 +645,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
// last cell red) and segment 2 is "ccc" (green, last cell green). // last cell red) and segment 2 is "ccc" (green, last cell green).
// \x1b[K records the green bg on the source line. // \x1b[K records the green bg on the source line.
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n") v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
v.draw(true) v.draw()
// Row 1's content ends with a red cell at x=7, so trailing columns // 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. // 8..10 should pick up red rather than the \x1b[K's green.

View file

@ -119,12 +119,13 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
var appStatusHelper *helpers.AppStatusHelper var appStatusHelper *helpers.AppStatusHelper
var branchesHelper *helpers.BranchesHelper var branchesHelper *helpers.BranchesHelper
var fetchGeneration int var fetchGeneration int
if err := self.gui.g.OnUIThreadAndWaitBackground(func() { if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
git = self.gui.git git = self.gui.git
appStatusHelper = self.gui.helpers.AppStatus appStatusHelper = self.gui.helpers.AppStatus
branchesHelper = self.gui.helpers.BranchesHelper branchesHelper = self.gui.helpers.BranchesHelper
fetchGeneration = self.gui.c.State().GetRepoGeneration() fetchGeneration = self.gui.c.State().GetRepoGeneration()
self.gui.State.LastBackgroundFetchTime = time.Now() self.gui.State.LastBackgroundFetchTime = time.Now()
return nil
}); err != nil { }); err != nil {
return err return err
} }
@ -183,9 +184,10 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
// reading them from this background goroutine would race the reassignment. // reading them from this background goroutine would race the reassignment.
var git *commands.GitCommand var git *commands.GitCommand
var refreshHelper *helpers.RefreshHelper var refreshHelper *helpers.RefreshHelper
if err := self.gui.g.OnUIThreadAndWaitBackground(func() { if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
git = self.gui.git git = self.gui.git
refreshHelper = self.gui.helpers.Refresh refreshHelper = self.gui.helpers.Refresh
return nil
}); err != nil { }); err != nil {
return return
} }

View file

@ -3,7 +3,6 @@ package gui
import ( import (
"sync" "sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
@ -180,8 +179,11 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.helpers.Window.SetWindowContext(c) self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c) self.gui.helpers.Window.MoveToTopOfWindow(c)
inputViewName := c.GetInputViewName() oldView := self.gui.c.GocuiGui().CurrentView()
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil { if oldView != nil && oldView.Name() != viewName {
oldView.HighlightInactive = true
}
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
panic(err) panic(err)
} }
@ -196,37 +198,9 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == "" self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.updateSelectionHighlights()
c.HandleFocus(opts) 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 { func (self *ContextMgr) Current() types.Context {
self.RLock() self.RLock()
defer self.RUnlock() defer self.RUnlock()

View file

@ -28,7 +28,7 @@ type BaseContext struct {
hasControlledBounds bool hasControlledBounds bool
needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
needsRerenderOnHeightChange bool needsRerenderOnHeightChange bool
hasSelectableContent bool highlightOnFocus bool
*ParentContextMgr *ParentContextMgr
} }
@ -49,7 +49,7 @@ type NewBaseContextOpts struct {
Focusable bool Focusable bool
Transient bool Transient bool
HasUncontrolledBounds bool // negating for the sake of making false the default HasUncontrolledBounds bool // negating for the sake of making false the default
HasSelectableContent bool HighlightOnFocus bool
NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
NeedsRerenderOnHeightChange bool NeedsRerenderOnHeightChange bool
@ -70,7 +70,7 @@ func NewBaseContext(opts NewBaseContextOpts) *BaseContext {
focusable: opts.Focusable, focusable: opts.Focusable,
transient: opts.Transient, transient: opts.Transient,
hasControlledBounds: hasControlledBounds, hasControlledBounds: hasControlledBounds,
hasSelectableContent: opts.HasSelectableContent, highlightOnFocus: opts.HighlightOnFocus,
needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange, needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange,
needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange, needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange,
ParentContextMgr: &ParentContextMgr{}, ParentContextMgr: &ParentContextMgr{},
@ -102,10 +102,6 @@ func (self *BaseContext) GetViewName() string {
return self.view.Name() return self.view.Name()
} }
func (self *BaseContext) GetInputViewName() string {
return self.GetViewName()
}
func (self *BaseContext) GetView() *gocui.View { func (self *BaseContext) GetView() *gocui.View {
return self.view return self.view
} }
@ -118,16 +114,12 @@ func (self *BaseContext) GetKind() types.ContextKind {
return self.kind return self.kind
} }
func (self *BaseContext) HasSelectableContent() bool {
return self.hasSelectableContent
}
func (self *BaseContext) GetKey() types.ContextKey { func (self *BaseContext) GetKey() types.ContextKey {
return self.key return self.key
} }
func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := make([]*types.Binding, 0, len(self.keybindingsFns)) bindings := []*types.Binding{}
for i := range self.keybindingsFns { for i := range self.keybindingsFns {
// the first binding in the bindings array takes precedence but we want the // the first binding in the bindings array takes precedence but we want the
// last keybindingsFn to take precedence to we add them in reverse // last keybindingsFn to take precedence to we add them in reverse
@ -224,7 +216,7 @@ func (self *BaseContext) AddOnQuitFn(fn func()) {
} }
func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
bindings := make([]*gocui.ViewMouseBinding, 0, len(self.mouseKeybindingsFns)) bindings := []*gocui.ViewMouseBinding{}
for i := range self.mouseKeybindingsFns { for i := range self.mouseKeybindingsFns {
// the first binding in the bindings array takes precedence but we want the // the first binding in the bindings array takes precedence but we want the
// last keybindingsFn to take precedence to we add them in reverse // last keybindingsFn to take precedence to we add them in reverse

View file

@ -1,22 +0,0 @@
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)
}

View file

@ -1,5 +1,7 @@
package context package context
import "github.com/jesseduffield/lazygit/pkg/i18n"
type FilteredListViewModel[T HasID] struct { type FilteredListViewModel[T HasID] struct {
*FilteredList[T] *FilteredList[T]
*ListViewModel[T] *ListViewModel[T]
@ -33,3 +35,8 @@ func (self *FilteredListViewModel[T]) ClearFilter() {
self.SetSelection(unfilteredIndex) 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
}

View file

@ -28,19 +28,10 @@ type ListContextTrait struct {
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search // true if we're inside the OnSearchSelect call; in that case we don't want to update the search
// result index. // result index.
inOnSearchSelect bool 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) IsListContext() {}
func (self *ListContextTrait) HasSelectableContent() bool {
return self.list.Len() > 0
}
func (self *ListContextTrait) FocusLine(scrollIntoView bool) { func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.Context.FocusLine(scrollIntoView) self.Context.FocusLine(scrollIntoView)
@ -90,13 +81,7 @@ func (self *ListContextTrait) refreshViewport() {
} }
func (self *ListContextTrait) setFooter() { func (self *ListContextTrait) setFooter() {
footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()) self.GetViewTrait().SetFooter(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 { func formatListFooter(selectedLineIdx int, length int) string {
@ -104,7 +89,9 @@ func formatListFooter(selectedLineIdx int, length int) string {
} }
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
self.FocusLine(!opts.KeepScrollPosition) self.FocusLine(opts.ScrollSelectionIntoView)
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
self.Context.HandleFocus(opts) self.Context.HandleFocus(opts)
} }

View file

@ -324,13 +324,7 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit {
} }
func shouldShowGraph(c *ContextCommon) bool { func shouldShowGraph(c *ContextCommon) bool {
// Whether we can draw a graph is a property of the commit list we have if c.Modes().Filtering.Active() {
// loaded, not of the filtering mode: turning filtering on or off only
// reaches the screen when the reloaded list does, and until then the graph
// has to keep matching the list that is still on display. Drawing one for a
// filtered list is also ruinously slow, because none of the commits in it
// are connected to each other, so no pipe ever terminates.
if c.Model().CommitsWereFilteredAtLastRefresh {
return false return false
} }

View file

@ -21,12 +21,12 @@ func NewMainContext(
ctx := &MainContext{ ctx := &MainContext{
SimpleContext: NewSimpleContext( SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{ NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT, Kind: types.MAIN_CONTEXT,
View: view, View: view,
WindowName: windowName, WindowName: windowName,
Key: key, Key: key,
Focusable: true, Focusable: true,
HasSelectableContent: false, HighlightOnFocus: false,
})), })),
SearchTrait: NewSearchTrait(c), SearchTrait: NewSearchTrait(c),
} }

View file

@ -8,6 +8,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -44,13 +45,6 @@ func NewMenuContext(
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment }, getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
getNonModelItems: viewModel.GetNonModelItems, 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, c: c,
}, },
} }
@ -64,8 +58,6 @@ type MenuViewModel struct {
columnAlignment []utils.Alignment columnAlignment []utils.Alignment
allowFilteringKeybindings bool allowFilteringKeybindings bool
keybindingsTakePrecedence bool keybindingsTakePrecedence bool
filterAsYouType bool
filterStarted bool
onCancel func() error onCancel func() error
*FilteredListViewModel[*types.MenuItem] *FilteredListViewModel[*types.MenuItem]
} }
@ -136,37 +128,10 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
self.allowFilteringKeybindings = allow self.allowFilteringKeybindings = allow
} }
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
return self.allowFilteringKeybindings
}
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) { func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
self.keybindingsTakePrecedence = value 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 // TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
menuItems := self.FilteredListViewModel.GetItems() menuItems := self.FilteredListViewModel.GetItems()
@ -244,16 +209,6 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
basicBindings := self.ListContextTrait.GetKeybindings(opts) 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 { menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
return len(item.Keys) > 0 return len(item.Keys) > 0
}) })
@ -312,13 +267,10 @@ func (self *MenuContext) RangeSelectEnabled() bool {
return false return false
} }
// A menu that filters as you type points the keyboard at its filter input, so func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string {
// that whatever the user types ends up there. Keys that the input doesn't take if self.allowFilteringKeybindings {
// still reach the menu, because the input view is embedded in the menu view. return tr.FilterPrefixMenu
func (self *MenuContext) GetInputViewName() string {
if self.filterAsYouType {
return self.c.Views().MenuFilter.Name()
} }
return self.GetViewName() return self.FilteredListViewModel.FilterPrefix(tr)
} }

View file

@ -35,12 +35,12 @@ func NewMergeConflictsContext(
viewModel: viewModel, viewModel: viewModel,
Context: NewSimpleContext( Context: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{ NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT, Kind: types.MAIN_CONTEXT,
View: c.Views().MergeConflicts, View: c.Views().MergeConflicts,
WindowName: "main", WindowName: "main",
Key: MERGE_CONFLICTS_CONTEXT_KEY, Key: MERGE_CONFLICTS_CONTEXT_KEY,
Focusable: true, Focusable: true,
HasSelectableContent: true, HighlightOnFocus: true,
}), }),
), ),
c: c, c: c,

View file

@ -47,7 +47,7 @@ func NewPatchExplorerContext(
Key: key, Key: key,
Kind: types.MAIN_CONTEXT, Kind: types.MAIN_CONTEXT,
Focusable: true, Focusable: true,
HasSelectableContent: true, HighlightOnFocus: true,
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
})), })),
SearchTrait: NewSearchTrait(c), SearchTrait: NewSearchTrait(c),

View file

@ -33,16 +33,21 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string
} }
func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
if self.highlightOnFocus {
self.GetViewTrait().SetHighlight(true)
}
for _, fn := range self.onFocusFns { for _, fn := range self.onFocusFns {
fn(opts) fn(opts)
} }
if self.onRenderToMainFn != nil && !opts.SkipMainViewUpdate { if self.onRenderToMainFn != nil {
self.onRenderToMainFn() self.onRenderToMainFn()
} }
} }
func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) { func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) {
self.GetViewTrait().SetHighlight(false)
self.view.SetOriginX(0) self.view.SetOriginX(0)
for _, fn := range self.onFocusLostFns { for _, fn := range self.onFocusLostFns {
fn(opts) fn(opts)

View file

@ -43,6 +43,11 @@ func (self *ViewTrait) SetContent(content string) {
self.view.SetContent(content) self.view.SetContent(content)
} }
func (self *ViewTrait) SetHighlight(highlight bool) {
self.view.Highlight = highlight
self.view.HighlightInactive = false
}
func (self *ViewTrait) SetFooter(value string) { func (self *ViewTrait) SetFooter(value string) {
self.view.Footer = value self.view.Footer = value
} }

View file

@ -16,8 +16,8 @@ var _ types.IListContext = (*WorktreesContext)(nil)
func NewWorktreesContext(c *ContextCommon) *WorktreesContext { func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
viewModel := NewFilteredListViewModel( viewModel := NewFilteredListViewModel(
func() []*models.Worktree { return c.Model().Worktrees }, func() []*models.Worktree { return c.Model().Worktrees },
func(worktree *models.Worktree) []string { func(Worktree *models.Worktree) []string {
return []string{worktree.Name, worktree.Branch} return []string{Worktree.Name}
}, },
) )

View file

@ -138,9 +138,6 @@ func (gui *Gui) resetHelpersAndControllers() {
common := controllers.NewControllerCommon(helperCommon, gui) common := controllers.NewControllerCommon(helperCommon, gui)
listControllerFactory := controllers.NewListControllerFactory(common)
menuListController := listControllerFactory.Create(gui.State.Contexts.Menu)
syncController := controllers.NewSyncController( syncController := controllers.NewSyncController(
common, common,
) )
@ -159,7 +156,7 @@ func (gui *Gui) resetHelpersAndControllers() {
remoteBranchesController := controllers.NewRemoteBranchesController(common) remoteBranchesController := controllers.NewRemoteBranchesController(common)
menuController := controllers.NewMenuController(common, menuListController) menuController := controllers.NewMenuController(common)
localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull) localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
tagsController := controllers.NewTagsController(common) tagsController := controllers.NewTagsController(common)
filesController := controllers.NewFilesController( filesController := controllers.NewFilesController(
@ -362,7 +359,6 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Menu, controllers.AttachControllers(gui.State.Contexts.Menu,
menuController, menuController,
menuListController,
) )
controllers.AttachControllers(gui.State.Contexts.CommitMessage, controllers.AttachControllers(gui.State.Contexts.CommitMessage,
@ -416,11 +412,8 @@ func (gui *Gui) resetHelpersAndControllers() {
) )
// this must come last so that we've got our click handlers defined against the context // 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() { 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)) controllers.AttachControllers(context, listControllerFactory.Create(context))
} }
} }

View file

@ -157,18 +157,6 @@ 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{ items := []*types.MenuItem{
{ {
Label: self.c.Tr.CommitHash, Label: self.c.Tr.CommitHash,
@ -219,9 +207,22 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
}, },
Keys: menuKey('a'), 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{ return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard, Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
Items: items, Items: items,

View file

@ -339,10 +339,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
HandleConfirm: func() error { HandleConfirm: func() error {
commits := self.c.Model().Commits commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
var filePaths []string var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
@ -616,9 +613,24 @@ 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 { func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
return diffPathsForNode( if !node.IsFile() && self.context().IsFiltering() {
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, 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()}
} }
// NOTE: these functions are identical to those in files_controller.go (except for types) and // NOTE: these functions are identical to those in files_controller.go (except for types) and

View file

@ -1,132 +0,0 @@
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+"/")
}

View file

@ -1,113 +0,0 @@
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))
})
}
}

View file

@ -130,11 +130,10 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
OpensMenu: true, OpensMenu: true,
}, },
{ {
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
Handler: self.toggleStagedAll, Handler: self.toggleStagedAll,
GetDisabledReason: self.require(self.anyFilesDisplayed), Description: self.c.Tr.ToggleStagedAll,
Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip,
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
}, },
{ {
Keys: opts.GetKeys(opts.Config.Universal.GoInto), Keys: opts.GetKeys(opts.Config.Universal.GoInto),
@ -329,7 +328,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
// (it was resolved in an editor), in which case the caller should fall back to // (it was resolved in an editor), in which case the caller should fall back to
// showing the file's diff. // showing the file's diff.
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool { func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File) hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
if err != nil { if err != nil {
return true return true
} }
@ -369,8 +368,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges() mainShowsStaged := !split && node.GetHasStagedChanges()
paths := self.pathsForDiff(node) pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
title := self.c.Tr.UnstagedChanges title := self.c.Tr.UnstagedChanges
if mainShowsStaged { if mainShowsStaged {
title = self.c.Tr.StagedChanges title = self.c.Tr.StagedChanges
@ -385,7 +384,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
} }
if split { if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths) cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
title := self.c.Tr.StagedChanges title := self.c.Tr.StagedChanges
if mainShowsStaged { if mainShowsStaged {
@ -643,9 +642,19 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
return nil return nil
} }
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string { // pathOverridesForDiff returns file paths to override the node's path in diff
return diffPathsForNode( // commands when a text filter is active and the node is a directory. This
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering()) // 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
} }
// unstageFilteredFiles unstages only the visible (filtered) files from the // unstageFilteredFiles unstages only the visible (filtered) files from the
@ -907,17 +916,6 @@ 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 { func (self *FilesController) toggleStagedAll() error {
if err := self.toggleStagedAllWithLock(); err != nil { if err := self.toggleStagedAllWithLock(); err != nil {
return err return err
@ -1266,7 +1264,7 @@ func (self *FilesController) switchToMerge() error {
return nil return nil
} }
return self.c.Helpers().MergeConflicts.SwitchToMerge(file) return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path)
} }
func (self *FilesController) createStashMenu() error { func (self *FilesController) createStashMenu() error {
@ -1510,20 +1508,13 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
self.c.Prompt(types.PromptOpts{ self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.StashChanges, Title: self.c.Tr.StashChanges,
HandleConfirm: func(stashComment string) error { HandleConfirm: func(stashComment string) error {
return self.c.WithWaitingStatusBlockingInput( self.c.LogAction(action)
types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus},
func(gocui.Task) error {
self.c.LogAction(action)
if err := stashFunc(stashComment); err != nil { if err := stashFunc(stashComment); err != nil {
return err return err
} }
self.c.RefreshFromWorker(types.RefreshOptions{ self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
BatchUIUpdates: true, return nil
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
return nil
})
}, },
AllowEmptyInput: true, AllowEmptyInput: true,
}) })

View file

@ -33,17 +33,7 @@ func (self *FilterController) Context() types.Context {
return self.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 { func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
return nil
}
return []*types.Binding{ return []*types.Binding{
{ {
Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
@ -54,6 +44,5 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type
} }
func (self *FilterController) OpenFilterPrompt() error { func (self *FilterController) OpenFilterPrompt() error {
self.c.Helpers().Search.OpenFilterPrompt(self.context) return self.c.Helpers().Search.OpenFilterPrompt(self.context)
return nil
} }

View file

@ -3,6 +3,7 @@ package controllers
import ( import (
"fmt" "fmt"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
) )
@ -41,7 +42,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{ menuItems = append(menuItems, &types.MenuItem{
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName), Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName),
OnPress: func() error { OnPress: func() error {
return self.c.Helpers().Mode.SetFilteringPath(fileName) return self.setFilteringPath(fileName)
}, },
Tooltip: tooltip, Tooltip: tooltip,
}) })
@ -51,7 +52,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{ menuItems = append(menuItems, &types.MenuItem{
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author), Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author),
OnPress: func() error { OnPress: func() error {
return self.c.Helpers().Mode.SetFilteringAuthor(author) return self.setFilteringAuthor(author)
}, },
Tooltip: tooltip, Tooltip: tooltip,
}) })
@ -64,7 +65,7 @@ func (self *FilteringMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(), FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(),
Title: self.c.Tr.EnterFileName, Title: self.c.Tr.EnterFileName,
HandleConfirm: func(response string) error { HandleConfirm: func(response string) error {
return self.c.Helpers().Mode.SetFilteringPath(response) return self.setFilteringPath(response)
}, },
}) })
@ -80,7 +81,7 @@ func (self *FilteringMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
Title: self.c.Tr.EnterAuthor, Title: self.c.Tr.EnterAuthor,
HandleConfirm: func(response string) error { HandleConfirm: func(response string) error {
return self.c.Helpers().Mode.SetFilteringAuthor(response) return self.setFilteringAuthor(response)
}, },
}) })
@ -98,3 +99,34 @@ func (self *FilteringMenuAction) Call() error {
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems}) return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems})
} }
func (self *FilteringMenuAction) setFilteringPath(path string) error {
self.c.Modes().Filtering.Reset()
self.c.Modes().Filtering.SetPath(path)
return self.setFiltering()
}
func (self *FilteringMenuAction) setFilteringAuthor(author string) error {
self.c.Modes().Filtering.Reset()
self.c.Modes().Filtering.SetAuthor(author)
return self.setFiltering()
}
func (self *FilteringMenuAction) setFiltering() error {
self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash())
repoState := self.c.State().GetRepoState()
if repoState.GetScreenMode() == types.SCREEN_NORMAL {
repoState.SetScreenMode(types.SCREEN_HALF)
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error {
self.c.Contexts().LocalCommits.SetSelection(0)
self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{})
return nil
}})
return nil
}

View file

@ -85,32 +85,30 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks // WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks
// keyboard input for the whole duration of the operation: keys the user presses // keyboard input for the whole duration of the operation: keys the user presses
// while it runs are buffered and replayed against the post-operation state (see // while it runs are buffered and replayed against the post-operation state (see
// gocui.BeginBlockingEvents). Use it for operations whose following keypress // gocui.BeginBlockingEvents). Use it for operations that manipulate an
// depends on the state they produce, e.g. ones that manipulate an in-progress // in-progress rebase or otherwise rewrite commits, where a racing keypress
// rebase or otherwise rewrite commits, where a racing keypress would target the // would target the wrong commit or todo.
// wrong commit or todo.
// //
// Must be called on the UI thread: the block is begun synchronously here, before // Must be called on the UI thread: the block is begun synchronously here, before
// the operation is dispatched to a worker, so no keypress can slip through in // the operation is dispatched to a worker, so no keypress can slip through in
// between. // between.
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) { func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) {
self.c.GocuiGui().BeginBlockingEvents() self.c.GocuiGui().BeginBlockingEvents()
if opts.HideWorkingTreeState { // Hide the rebasing-mode indicator (and its reset button) while we drive the
self.modeHelper.SetSuppressWorkingTreeStateMode(true) // rebase ourselves; it reflects the transient on-disk state and would
} // otherwise flash on for the duration of the operation.
self.modeHelper.SetSuppressRebasingMode(true)
self.c.OnWorker(func(task gocui.Task) error { self.c.OnWorker(func(task gocui.Task) error {
// End the block and restore the mode indicator once the operation and its // End the block and restore the mode indicator once the operation and its
// refresh have applied their UI updates: OnUIThread queues this after the // refresh have applied their UI updates: OnUIThread queues this after the
// refresh's model bounces and Then (which RefreshFromWorker has already // refresh's model bounces and Then (which RefreshFromWorker has already
// enqueued by the time f returns), so the replayed keys act on the // enqueued by the time f returns), so the replayed keys act on the
// refreshed state and any resulting working tree state shows correctly. // refreshed state and any resulting rebase state shows correctly.
defer self.c.OnUIThread(func() error { defer self.c.OnUIThread(func() error {
if opts.HideWorkingTreeState { self.modeHelper.SetSuppressRebasingMode(false)
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
}
return self.c.GocuiGui().EndBlockingEvents() return self.c.GocuiGui().EndBlockingEvents()
}) })
return self.WithWaitingStatusImpl(opts.Message, f, task) return self.WithWaitingStatusImpl(message, f, task)
}) })
} }

View file

@ -85,10 +85,7 @@ func (self *CherryPickHelper) Paste() error {
HandleConfirm: func() error { HandleConfirm: func() error {
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
cherryPickedCommits := self.getData().CherryPickedCommits cherryPickedCommits := self.getData().CherryPickedCommits
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error {
Message: self.c.Tr.CherryPickingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CherryPick) self.c.LogAction(self.c.Tr.Actions.CherryPick)
if mustStash { if mustStash {

View file

@ -9,7 +9,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
) )
type ConfirmationHelper struct { type ConfirmationHelper struct {
@ -324,70 +323,19 @@ 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) { 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 // we want the unfiltered length here so that if we're filtering we don't
// resize the window // resize the window
itemCount := menuContext.UnfilteredLen() itemCount := self.c.Contexts().Menu.UnfilteredLen()
offset := 3 offset := 3
panelWidth := self.getPopupPanelWidth(90) panelWidth := self.getPopupPanelWidth(90)
contentWidth := panelWidth - 2 // minus 2 for the frame contentWidth := panelWidth - 2 // minus 2 for the frame
promptLinesCount := self.layoutMenuPrompt(contentWidth) promptLinesCount := self.layoutMenuPrompt(contentWidth)
// The row is reserved for the whole time the menu is open, even though it only x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext)
// becomes visible once the user starts typing, so that revealing it doesn't menuBottom := y1 - offset
// 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) _, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
tooltipTop := menuBottom + 1 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 := "" tooltip := ""
selectedItem := self.c.Contexts().Menu.GetSelected() selectedItem := self.c.Contexts().Menu.GetSelected()
if selectedItem != nil { if selectedItem != nil {

View file

@ -1,31 +0,0 @@
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))
})
}
}

View file

@ -141,6 +141,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
} }
self.c.Contexts().LocalCommits.SetSelection(index) self.c.Contexts().LocalCommits.SetSelection(index)
self.c.Contexts().LocalCommits.FocusLine(true)
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil return nil
}, },

View file

@ -188,8 +188,9 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
} }
result := false result := false
_ = self.c.GocuiGui().OnUIThreadAndWait(func() { _ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
result = check() result = check()
return nil
}) })
return result return result
} }

View file

@ -1,7 +1,6 @@
package helpers package helpers
import ( import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
) )
@ -18,14 +17,14 @@ func NewMergeConflictsHelper(
} }
} }
func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) { func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) {
self.context().GetMutex().Lock() self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock() defer self.context().GetMutex().Unlock()
return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize) return self.setMergeStateWithoutLock(path)
} }
func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) { func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) {
content, err := self.c.Git().File.Cat(path) content, err := self.c.Git().File.Cat(path)
if err != nil { if err != nil {
return false, err return false, err
@ -35,7 +34,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSi
self.context().SetUserScrolling(false) self.context().SetUserScrolling(false)
} }
self.context().GetState().SetContent(content, path, markerSize) self.context().GetState().SetContent(content, path)
return !self.context().GetState().NoConflicts(), nil return !self.context().GetState().NoConflicts(), nil
} }
@ -73,8 +72,7 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
self.context().GetMutex().Lock() self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock() defer self.context().GetMutex().Unlock()
state := self.context().GetState() hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath())
hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize())
if err != nil { if err != nil {
return false, err return false, err
} }
@ -86,9 +84,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
return false, nil return false, nil
} }
func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error { func (self *MergeConflictsHelper) SwitchToMerge(path string) error {
if self.context().GetState().GetPath() != file.Path { if self.context().GetState().GetPath() != path {
hasConflicts, err := self.SetMergeState(file) hasConflicts, err := self.SetMergeState(path)
if err != nil { if err != nil {
return err return err
} }

View file

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo" "github.com/samber/lo"
@ -13,12 +12,12 @@ import (
type ModeHelper struct { type ModeHelper struct {
c *HelperCommon c *HelperCommon
diffHelper *DiffHelper diffHelper *DiffHelper
patchBuildingHelper *PatchBuildingHelper patchBuildingHelper *PatchBuildingHelper
cherryPickHelper *CherryPickHelper cherryPickHelper *CherryPickHelper
mergeAndRebaseHelper *MergeAndRebaseHelper mergeAndRebaseHelper *MergeAndRebaseHelper
bisectHelper *BisectHelper bisectHelper *BisectHelper
suppressWorkingTreeStateMode bool suppressRebasingMode bool
} }
func NewModeHelper( func NewModeHelper(
@ -131,7 +130,7 @@ func (self *ModeHelper) Statuses() []ModeStatus {
}, },
{ {
IsActive: func() bool { IsActive: func() bool {
return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any() return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any()
}, },
InfoLabel: func() string { InfoLabel: func() string {
workingTreeState := self.c.Git().Status.WorkingTreeState() workingTreeState := self.c.Git().Status.WorkingTreeState()
@ -183,39 +182,16 @@ func (self *ModeHelper) ExitFilterMode() error {
return self.ClearFiltering() return self.ClearFiltering()
} }
func (self *ModeHelper) SetFilteringPath(path string) error {
return self.setFiltering(func() {
self.c.Modes().Filtering.SetPath(path)
})
}
func (self *ModeHelper) SetFilteringAuthor(author string) error {
return self.setFiltering(func() {
self.c.Modes().Filtering.SetAuthor(author)
})
}
func (self *ModeHelper) setFiltering(setFilter func()) error {
return self.changeFiltering(
func() {
// Whatever we were filtering by before is replaced, not added to
self.c.Modes().Filtering.Reset()
setFilter()
self.c.Modes().Filtering.SetSelectedCommitHash(
self.c.Contexts().LocalCommits.GetSelectedCommitHash())
},
func() {
self.c.Contexts().LocalCommits.SetSelection(0)
},
)
}
func (self *ModeHelper) ClearFiltering() error { func (self *ModeHelper) ClearFiltering() error {
selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash()
self.c.Modes().Filtering.Reset()
if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF {
self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL)
}
return self.changeFiltering( self.c.Refresh(types.RefreshOptions{
self.c.Modes().Filtering.Reset, Scope: ScopesToRefreshWhenFilteringModeChanges(),
func() { Then: func() error {
// Find the commit that was last selected in filtering mode, and select it again after refreshing // Find the commit that was last selected in filtering mode, and select it again after refreshing
if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) {
// If we couldn't find it (either because no commit was selected // If we couldn't find it (either because no commit was selected
@ -224,57 +200,12 @@ func (self *ModeHelper) ClearFiltering() error {
// before we entered filtering // before we entered filtering
self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash()) self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash())
} }
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
return nil
}, },
)
}
// changeFiltering applies a change to the filtering mode: setFilter mutates the
// mode, then the views whose contents depend on the filter are reloaded, and
// selectCommit puts the selection where it belongs in the reloaded commit list.
//
// Reloading the commit list can take seconds in a big repo, so it happens on a
// worker with a waiting status. Everything the user can see of the change waits
// for it: the screen mode, the focused panel and the reloaded lists all land in
// the same frame, from the refresh's Then, rather than framing an unfiltered
// list as if it were the filtered one. Until then the pre-change state stays on
// screen, and it stays consistent, because the only thing that has changed
// behind it is the filter that the reload is in the middle of applying. The one
// thing that can't wait is the mode indicator in the information panel: the
// filter has to be set before the reload can use it, so the indicator leads the
// lists by however long the reload takes.
//
// Input is blocked for the duration: the keys the user presses arrive after the
// change, which is where they meant them to go, and it keeps a second filter
// change from racing this one — they would both refresh with whichever filter
// happened to be set when their git commands ran.
func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) error {
setFilter()
filtering := self.c.Modes().Filtering.Active()
message := lo.Ternary(filtering, self.c.Tr.ApplyingFilterStatus, self.c.Tr.RemovingFilterStatus)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{Message: message}, func(gocui.Task) error {
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: ScopesToRefreshWhenFilteringModeChanges(),
BatchUIUpdates: true,
Then: func() error {
repoState := self.c.State().GetRepoState()
if filtering {
if repoState.GetScreenMode() == types.SCREEN_NORMAL {
repoState.SetScreenMode(types.SCREEN_HALF)
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
} else if repoState.GetScreenMode() == types.SCREEN_HALF {
repoState.SetScreenMode(types.SCREEN_NORMAL)
}
selectCommit()
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
return nil
},
})
return nil
}) })
return nil
} }
// Stashes really only need to be refreshed when filtering by path, not by author, but it's too much // Stashes really only need to be refreshed when filtering by path, not by author, but it's too much
@ -288,6 +219,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView {
} }
} }
func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) { func (self *ModeHelper) SetSuppressRebasingMode(value bool) {
self.suppressWorkingTreeStateMode = value self.suppressRebasingMode = value
} }

View file

@ -111,17 +111,6 @@ type refreshEnv struct {
// persist its refreshed stat cache. // persist its refreshed stat cache.
backgroundRoutine bool 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 // the repo generation captured when the refresh started
generation int generation int
@ -231,17 +220,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// against the repo it started in, and the generation guard drops its // against the repo it started in, and the generation guard drops its
// writes. // writes.
env := refreshEnv{ env := refreshEnv{
background: options.Background || options.DontBlockRepoSwitch, background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background, backgroundRoutine: options.Background,
keepScrollPosition: options.Background || options.DontBlockRepoSwitch,
skipMainViewUpdate: options.SkipMainViewUpdate,
} }
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration() env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git() env.git = self.c.Git()
}) { })
return
}
if options.BatchUIUpdates { if options.BatchUIUpdates {
env.batch = &refreshBounceBatch{} env.batch = &refreshBounceBatch{}
} }
@ -277,10 +262,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// - merge conflicts are part of what the files refresh produces // - merge conflicts are part of what the files refresh produces
// - pull requests are fetched for the tracking branches against the // - pull requests are fetched for the tracking branches against the
// remotes, so refresh both alongside to fetch against fresh data // 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) { if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES) scopeSet.Add(types.COMMITS, types.BRANCHES)
} }
@ -293,9 +274,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.PULL_REQUESTS) { if scopeSet.Includes(types.PULL_REQUESTS) {
scopeSet.Add(types.BRANCHES, types.REMOTES) 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 // Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race // below, rather than after. This is important to guard against the race
@ -322,23 +300,6 @@ 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{} branchesAndRemotesWg := sync.WaitGroup{}
// The pull-request fetch (below) needs the just-loaded branches and // The pull-request fetch (below) needs the just-loaded branches and
// remotes. Their model writes are bounced onto the UI thread, so the // remotes. Their model writes are bounced onto the UI thread, so the
@ -348,49 +309,32 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// branchesAndRemotesWg gives the fetch the happens-before to read them. // branchesAndRemotesWg gives the fetch the happens-before to read them.
var loadedBranches []*models.Branch var loadedBranches []*models.Branch
var loadedRemotes []*models.Remote var loadedRemotes []*models.Remote
if scopeSet.Includes(types.COMMITS) { includeWorktreesWithBranches := false
// Capture the refresh's inputs (model, contexts, modes) on the UI if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
// thread, before the git work is dispatched to a worker, so the worker // whenever we change commits, we should update branches because the upstream/downstream
// computes from an immutable snapshot instead of reading state the UI // counts can change. Whenever we change branches we should also change commits
// thread concurrently mutates. Every scope below does the same. // 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.
var capturedCommits capturedCommitState var capturedCommits capturedCommitState
if !self.captureOnUIThread(calledFromWorker, env.background, func() { var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommits = self.captureCommitsState() capturedCommits = self.captureCommitsState()
}) { capturedReflog = self.captureReflogState()
return capturedBranches = self.captureBranchState()
} })
refresh("commits and commit files", func() { refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) 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" { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
branchesAndRemotesWg.Add(1) branchesAndRemotesWg.Add(1)
refresh("reflog and branches", func() { refresh("reflog and branches", func() {
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env) loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env)
branchesAndRemotesWg.Done() branchesAndRemotesWg.Done()
}) })
} else { } else {
@ -399,44 +343,47 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// Not a recency sort, so branches doesn't depend on the reflog // Not a recency sort, so branches doesn't depend on the reflog
// being fresh; it runs concurrently with the reflog refresh // being fresh; it runs concurrently with the reflog refresh
// below and uses the reflog we captured up front, as it always has. // below and uses the reflog we captured up front, as it always has.
loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env) loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env)
branchesAndRemotesWg.Done() branchesAndRemotesWg.Done()
}) })
refresh("reflog", func() { refresh("reflog", func() {
_, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) _, _ = 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) { if scopeSet.Includes(types.SUB_COMMITS) {
var capturedSubCommits capturedSubCommitState var capturedSubCommits capturedSubCommitState
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedSubCommits = self.captureSubCommitState() capturedSubCommits = self.captureSubCommitState()
}) { })
return
}
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) 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 // 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) { if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState var capturedCommitFiles capturedCommitFilesState
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState() capturedCommitFiles = self.captureCommitFilesState()
}) { })
return
}
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
} }
fileWg := sync.WaitGroup{} fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) { if scopeSet.Includes(types.FILES) {
var capturedFiles capturedFilesState var capturedFiles capturedFilesState
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedFiles = self.captureFilesState() capturedFiles = self.captureFilesState()
}) { })
return
}
fileWg.Add(1) fileWg.Add(1)
refresh("files", func() { refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env) _ = self.refreshFilesAndSubmodules(capturedFiles, env)
@ -446,11 +393,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.STASH) { if scopeSet.Includes(types.STASH) {
var stashFilterPath string var stashFilterPath string
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath() stashFilterPath = self.c.Modes().Filtering.GetPath()
}) { })
return
}
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
} }
@ -463,11 +408,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// needs it to keep the remote-branches selection valid, and reading // needs it to keep the remote-branches selection valid, and reading
// the Remotes context off the UI thread races its render. // the Remotes context off the UI thread races its render.
var prevSelectedRemote *models.Remote var prevSelectedRemote *models.Remote
if !self.captureOnUIThread(calledFromWorker, env.background, func() { self.captureOnUIThread(calledFromWorker, env.background, func() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
}) { })
return
}
branchesAndRemotesWg.Add(1) branchesAndRemotesWg.Add(1)
refresh("remotes", func() { refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
@ -500,6 +443,10 @@ 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) { if scopeSet.Includes(types.STAGING) {
refresh("staging", func() { refresh("staging", func() {
fileWg.Wait() fileWg.Wait()
@ -726,19 +673,17 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState {
} }
} }
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
switch self.c.State().GetRepoState().GetStartupStage() { switch self.c.State().GetRepoState().GetStartupStage() {
case types.INITIAL: case types.INITIAL:
// Return the immediate (non-recency) load's branches; the recency-sorted // 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 // 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. // set of branches, which is all the caller (the PR fetch) needs.
branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
self.onWorker(env.background, func(_ gocui.Task) error { self.onWorker(env.background, func(_ gocui.Task) error {
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false)
// The load above already waited for the worktrees, so this one has self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env)
// nothing left to wait for.
self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env)
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
return nil return nil
}) })
@ -747,7 +692,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo
case types.COMPLETE: case types.COMPLETE:
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit)
return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env) return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env)
} }
return nil return nil
@ -865,16 +810,13 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
var selectionRange *localCommitSelectionRange var selectionRange *localCommitSelectionRange
var newConflictedCommitIdx *int
if commitSelection == types.KeepCommitSelectionByHash { if commitSelection == types.KeepCommitSelectionByHash {
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits)
} }
self.c.Model().BisectInfo = bisectInfo self.c.Model().BisectInfo = bisectInfo
self.c.Model().Commits = commits self.c.Model().Commits = commits
self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != ""
self.RefreshAuthors(commits) self.RefreshAuthors(commits)
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
if checkedOutRef != nil { if checkedOutRef != nil {
@ -883,23 +825,33 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.c.Model().CheckedOutBranch = "" self.c.Model().CheckedOutBranch = ""
} }
scrollSelectionIntoView := false
switch commitSelection { switch commitSelection {
case types.SelectHeadCommit: case types.SelectHeadCommit:
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
scrollSelectionIntoView = true
} }
case types.KeepCommitSelectionByHash: case types.KeepCommitSelectionByHash:
if newConflictedCommitIdx != nil { if selectionRange != nil {
self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx) selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
} else if selectionRange != nil {
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange)
if found { if found {
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
scrollSelectionIntoView = didMove
} }
} }
case types.KeepCommitSelectionIndex: case types.KeepCommitSelectionIndex:
// The caller set the selection index deliberately; leave it untouched. // 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) self.refreshView(self.c.Contexts().LocalCommits, env)
@ -911,6 +863,8 @@ type localCommitSelectionRange struct {
selectedIsTODO bool selectedIsTODO bool
rangeStartHash string rangeStartHash string
rangeStartIsTODO bool rangeStartIsTODO bool
selectedIdx int
rangeStartIdx int
mode traits.RangeSelectMode mode traits.RangeSelectMode
} }
@ -929,6 +883,8 @@ func captureLocalCommitSelectionRange(
selectedIsTODO: commits[selectedIdx].IsTODO(), selectedIsTODO: commits[selectedIdx].IsTODO(),
rangeStartHash: commits[rangeStartIdx].Hash(), rangeStartHash: commits[rangeStartIdx].Hash(),
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
mode: mode, mode: mode,
} }
} }
@ -936,16 +892,17 @@ func captureLocalCommitSelectionRange(
func findLocalCommitSelectionRange( func findLocalCommitSelectionRange(
commits []*models.Commit, commits []*models.Commit,
selectionRange *localCommitSelectionRange, selectionRange *localCommitSelectionRange,
) (int, int, bool) { ) (int, int, bool, bool) {
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
if !foundSelected || !foundRangeStart { if !foundSelected || !foundRangeStart {
return 0, 0, false return 0, 0, false, false
} }
return selectedIdx, rangeStartIdx, true didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
return selectedIdx, rangeStartIdx, didMove, true
} }
// findCommitByHashPreferringTODOStatus finds the commit with the given hash. // findCommitByHashPreferringTODOStatus finds the commit with the given hash.
@ -976,24 +933,6 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" 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 // capturedSubCommitState holds the sub-commits refresh's model/context/mode
// inputs, gathered on the UI thread (see captureSubCommitState) before the git // inputs, gathered on the UI thread (see captureSubCommitState) before the git
// work is dispatched to a worker. // work is dispatched to a worker.
@ -1134,7 +1073,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
// self.refreshStatus is called at the end of this because that's when we can // 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 // be sure there is a State.Model.Branches array to pick the current branch from
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
loadSeq := self.branchLoadSeq.Add(1) loadSeq := self.branchLoadSeq.Add(1)
branches, err := env.git.Loaders.BranchLoader.Load( branches, err := env.git.Loaders.BranchLoader.Load(
@ -1168,9 +1107,10 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitFor
self.c.Log.Error(err) self.c.Log.Error(err)
} }
// Render only once the refreshed worktrees are in the model; the branches var worktrees []*models.Worktree
// view shows them against the branches (see performRefresh). if refreshWorktrees {
waitForWorktrees() worktrees = self.loadWorktrees(env)
}
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
// Drop this write if a branch load that started later has already applied // Drop this write if a branch load that started later has already applied
@ -1193,6 +1133,11 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitFor
// the branches we just wrote, on the UI thread. // the branches we just wrote, on the UI thread.
self.rebuildPullRequestsMap() 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, // 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 // keeps it on the UI thread and keeps the list and selection updating in
// the same frame. // the same frame.
@ -1208,8 +1153,10 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitFor
} }
} }
case types.SelectCheckedOutBranch: case types.SelectCheckedOutBranch:
// The checked-out branch is always at the top of the list. // 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.
self.c.Contexts().Branches.SetSelectedLineIdx(0) 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 // Need to re-render the commits view because the visualization of local
@ -1300,20 +1247,21 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// waiting for a callback that only it can run), and capturing inline also // 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, // guarantees the snapshot reflects the state at the moment Refresh was called,
// before the calling handler regains control and can mutate it. // 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 { if !calledFromWorker {
fn() fn()
return true return
} }
if background { wrapped := func() error {
return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil fn()
return nil
}
if background {
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped)
} else {
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
} }
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
} }
// capturedFilesState holds the files refresh's context/model inputs, gathered // capturedFilesState holds the files refresh's context/model inputs, gathered
@ -1356,8 +1304,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// process working directory, which may already point at another // process working directory, which may already point at another
// repo if the user switched while this refresh was in flight. // repo if the user switched while this refresh was in flight.
hasConflicts, err := mergeconflicts.FileHasConflictMarkers( hasConflicts, err := mergeconflicts.FileHasConflictMarkers(
filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path), filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path))
file.ConflictMarkerSize)
if err != nil { if err != nil {
self.c.Log.Error(err) self.c.Log.Error(err)
} else if !hasConflicts { } else if !hasConflicts {
@ -1380,9 +1327,12 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
Background: env.backgroundRoutine, Background: env.backgroundRoutine,
}) })
conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) { conflictFileCount := 0
return file.Path, file.HasMergeConflicts for _, file := range files {
}) if file.HasMergeConflicts {
conflictFileCount++
}
}
repoState := self.c.State().GetRepoState() repoState := self.c.State().GetRepoState()
workingTreeState := env.git.Status.WorkingTreeState() workingTreeState := env.git.Status.WorkingTreeState()
@ -1392,7 +1342,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
repoState.SetMergeOrRebaseStartedInLazygit(false) repoState.SetMergeOrRebaseStartedInLazygit(false)
} }
if workingTreeState.Any() && len(conflictedPaths) == 0 { if workingTreeState.Any() && conflictFileCount == 0 {
if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() { if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() {
// The conflicts of an operation we started have just been resolved // 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 // (e.g. in the user's editor). Offer to continue it. We only do this
@ -1430,61 +1380,24 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
// only taking over the filter if it hasn't already been set by the user. // only taking over the filter if it hasn't already been set by the user.
if len(conflictedPaths) > 0 && prevConflictFileCount == 0 { if conflictFileCount > 0 && prevConflictFileCount == 0 {
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
} }
} else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll) fileTreeViewModel.SetStatusFilter(filetree.DisplayAll)
self.c.Contexts().Files.GetView().Subtitle = "" self.c.Contexts().Files.GetView().Subtitle = ""
} }
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.RememberConflictedPaths(conflictedPaths)
}
self.c.Model().Submodules = submoduleConfigs self.c.Model().Submodules = submoduleConfigs
self.c.Model().Files = files self.c.Model().Files = files
markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath())
fileTreeViewModel.SetTree() fileTreeViewModel.SetTree()
}) })
return nil 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 // 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 // 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 // we need to be more careful with how we use this, and to ensure we're emptying
@ -1534,9 +1447,11 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
self.c.Model().ReflogCommits = reflogCommits self.c.Model().ReflogCommits = reflogCommits
self.c.Model().FilteredReflogCommits = filteredReflogCommits self.c.Model().FilteredReflogCommits = filteredReflogCommits
// Setting the selection here, in the same bounce that writes the list, // Setting the selection here, in the same bounce that writes the list,
// keeps it on the UI thread and atomic with the list update. // 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.
if selectTopEntry { if selectTopEntry {
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
} }
}) })
@ -1586,27 +1501,16 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {
return worktrees return worktrees
} }
func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) { func (self *RefreshHelper) refreshWorktrees(env refreshEnv) {
worktrees := self.loadWorktrees(env) worktrees := self.loadWorktrees(env)
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Worktrees = worktrees 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)
}
}) })
// The branches view shows worktrees against branches, so it needs to be // need to refresh branches because the branches view shows worktrees against
// rendered again as well. When the branches are being refreshed too, they // branches
// render after waiting for the write above, so leave it to them. self.refreshView(self.c.Contexts().Branches, env)
if !branchesAreRefreshing {
self.refreshView(self.c.Contexts().Branches, env)
}
self.refreshView(self.c.Contexts().Worktrees, env) self.refreshView(self.c.Contexts().Worktrees, env)
} }
@ -1674,10 +1578,7 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
// the filtered list model is up to date for rendering. // the filtered list model is up to date for rendering.
self.searchHelper.ReApplyFilter(context) self.searchHelper.ReApplyFilter(context)
self.c.PostRefreshUpdateWithOptions(context, types.OnFocusOpts{ self.c.PostRefreshUpdate(context)
KeepScrollPosition: env.keepScrollPosition,
SkipMainViewUpdate: env.skipMainViewUpdate,
})
self.c.AfterLayout(func() error { self.c.AfterLayout(func() error {
// Re-applying the search must be done after re-rendering the view though, // Re-applying the search must be done after re-rendering the view though,
@ -1853,11 +1754,7 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
// the branches and remotes as they are on the UI thread, after their // the branches and remotes as they are on the UI thread, after their
// own refreshes' bounces have applied. // own refreshes' bounces have applied.
self.rebuildPullRequestsMap() self.rebuildPullRequestsMap()
// This lands whenever the network call happens to return, and only self.c.PostRefreshUpdate(self.c.Contexts().Branches)
// 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})
}) })
} }

View file

@ -1,7 +1,6 @@
package helpers package helpers
import ( import (
"path/filepath"
"testing" "testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
@ -29,6 +28,8 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) {
expected: &localCommitSelectionRange{ expected: &localCommitSelectionRange{
selectedHash: "b", selectedHash: "b",
rangeStartHash: "a", rangeStartHash: "a",
selectedIdx: 1,
rangeStartIdx: 0,
mode: traits.RangeSelectModeSticky, mode: traits.RangeSelectModeSticky,
}, },
}, },
@ -73,12 +74,15 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
type expectation struct { type expectation struct {
selectedIdx int selectedIdx int
rangeStartIdx int rangeStartIdx int
moved bool
found bool found bool
} }
selectionRange := localCommitSelectionRange{ selectionRange := localCommitSelectionRange{
selectedHash: "b", selectedHash: "b",
rangeStartHash: "c", rangeStartHash: "c",
selectedIdx: 1,
rangeStartIdx: 2,
mode: traits.RangeSelectModeSticky, mode: traits.RangeSelectModeSticky,
} }
@ -93,6 +97,7 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 2, selectedIdx: 2,
rangeStartIdx: 3, rangeStartIdx: 3,
moved: true,
found: true, found: true,
}, },
}, },
@ -121,6 +126,7 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 2, selectedIdx: 2,
rangeStartIdx: 3, rangeStartIdx: 3,
moved: true,
found: true, found: true,
}, },
}, },
@ -133,6 +139,7 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 0, selectedIdx: 0,
rangeStartIdx: 1, rangeStartIdx: 1,
moved: true,
found: true, found: true,
}, },
}, },
@ -140,10 +147,11 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
actual := expectation{ actual := expectation{
selectedIdx: selectedIdx, selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx, rangeStartIdx: rangeStartIdx,
moved: moved,
found: found, found: found,
} }
@ -152,62 +160,6 @@ 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) { func TestGetGithubBaseRemote(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@ -300,46 +252,6 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) {
}, callsByHost) }, 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 { func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
return lo.Map(names, func(name string, _ int) githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo {
return makeGithubRemoteInfo(name, name) return makeGithubRemoteInfo(name, name)
@ -376,7 +288,3 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit {
func makeTodoCommitWithHash(hash string, 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}) 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})
}

View file

@ -54,10 +54,7 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
if err != nil { if err != nil {
return err return err
} }
self.c.State().GetRepoPathStack().Push(types.RepoLocation{ self.c.State().GetRepoPathStack().Push(wd)
Path: wd,
GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
})
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
} }
@ -153,11 +150,7 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
} }
}) })
return self.c.Menu(types.CreateMenuOptions{ return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
Title: self.c.Tr.RecentRepos,
Items: menuItems,
FilterAsYouType: true,
})
} }
// SwitchToParentRepo switches back to the repo the current submodule was // SwitchToParentRepo switches back to the repo the current submodule was
@ -171,7 +164,7 @@ func (self *ReposHelper) SwitchToParentRepo() error {
if self.switchRefusedBecauseBusy() { if self.switchRefusedBecauseBusy() {
return nil return nil
} }
return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
} }
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
@ -196,41 +189,23 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool {
return false return false
} }
// switchTo switches lazygit to the repository (or worktree) at the given path, // switchTo switches lazygit to the repository (or worktree) at the given path.
// which git is expected to find from that path alone. That's true of every repo // It runs synchronously on the UI thread: the switch swaps gui.State (in
// we switch to without having been there before. // resetState) and reassigns gui.git and the process cwd, all of which the UI
// thread also reads, so doing it here rather than on a worker avoids racing
// those reads. The heavy data loading is still dispatched asynchronously by the
// refresh that onNewRepo kicks off.
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error { func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey) env.UnsetGitLocationEnvVars()
}
// switchToLocation switches lazygit to the repository (or worktree) at the
// given location. It runs synchronously on the UI thread: the switch swaps
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
// which the UI thread also reads, so doing it here rather than on a worker
// avoids racing those reads. The heavy data loading is still dispatched
// asynchronously by the refresh that onNewRepo kicks off.
//
// Everything from here on has to find the repo the way git does, from the
// directory we're about to change to, so the location's environment goes into
// the process env before we do. Usually that just clears whatever the repo
// we're leaving needed, but going back to a repo whose git dir isn't in its
// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the
// reason we remember the environment at all: nothing in the path leads to its
// git dir. On failure we put back what the repo we're staying in needs.
func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error {
originalPath, err := os.Getwd() originalPath, err := os.Getwd()
if err != nil { if err != nil {
return nil return nil
} }
originalGitLocationEnvVars := env.GetGitLocationEnvVars()
env.SetGitLocationEnvVars(location.GitLocationEnvVars) msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
self.c.LogCommand(msg, false) self.c.LogCommand(msg, false)
if err := os.Chdir(location.Path); err != nil { if err := os.Chdir(path); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return errors.New(errMsg) return errors.New(errMsg)
} }
@ -238,7 +213,6 @@ func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg st
} }
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if err := os.Chdir(originalPath); err != nil { if err := os.Chdir(originalPath); err != nil {
return err return err
} }

View file

@ -29,14 +29,14 @@ func NewSearchHelper(
} }
} }
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) { func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
state := self.searchState() state := self.searchState()
state.PrevSearchIndex = -1 state.PrevSearchIndex = -1
state.Context = context state.Context = context
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
promptView := self.promptView() promptView := self.promptView()
promptView.ClearTextArea() promptView.ClearTextArea()
self.OnPromptContentChanged("") self.OnPromptContentChanged("")
@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) {
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
self.c.ResetKeybindings() return self.c.ResetKeybindings()
} }
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) { func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
state := self.searchState() state := self.searchState()
state.PrevSearchIndex = -1 state.PrevSearchIndex = -1
@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) {
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
self.c.ResetKeybindings() return self.c.ResetKeybindings()
} }
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) { func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext)
state.Context = context state.Context = context
searchString := context.GetFilter() searchString := context.GetFilter()
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
promptView := self.promptView() promptView := self.promptView()
keybindingConfig := self.c.UserConfig().Keybinding keybindingConfig := self.c.UserConfig().Keybinding
@ -103,11 +103,10 @@ func (self *SearchHelper) promptContent() string {
return self.c.Contexts().Search.GetView().TextArea.GetContent() return self.c.Contexts().Search.GetView().TextArea.GetContent()
} }
func (self *SearchHelper) Confirm() { func (self *SearchHelper) Confirm() error {
state := self.searchState() state := self.searchState()
if self.promptContent() == "" { if self.promptContent() == "" {
self.CancelPrompt() return self.CancelPrompt()
return
} }
switch state.SearchType() { switch state.SearchType() {
@ -119,7 +118,7 @@ func (self *SearchHelper) Confirm() {
self.c.Context().Pop() self.c.Context().Pop()
} }
self.c.ResetKeybindings() return self.c.ResetKeybindings()
} }
func (self *SearchHelper) ConfirmFilter() { func (self *SearchHelper) ConfirmFilter() {
@ -176,12 +175,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition
return context.ModelSearchResults(normalizedSearchStr, caseSensitive) return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
} }
func (self *SearchHelper) CancelPrompt() { func (self *SearchHelper) CancelPrompt() error {
self.Cancel() self.Cancel()
self.c.Context().Pop() self.c.Context().Pop()
self.c.ResetKeybindings() return self.c.ResetKeybindings()
} }
func (self *SearchHelper) ScrollHistory(scrollIncrement int) { func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
@ -225,7 +224,10 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
state := self.searchState() state := self.searchState()
switch context := state.Context.(type) { switch context := state.Context.(type) {
case types.IFilterableContext: case types.IFilterableContext:
self.ApplyFilter(context, searchString) context.SetSelection(0)
context.GetView().SetOriginY(0)
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
case types.ISearchableContext: case types.ISearchableContext:
// do nothing // do nothing
default: default:
@ -233,21 +235,12 @@ 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) { func (self *SearchHelper) ReApplyFilter(context types.Context) {
filterableContext, ok := context.(types.IFilterableContext) filterableContext, ok := context.(types.IFilterableContext)
if ok { if ok {
state := self.searchState() state := self.searchState()
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() { if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
filterableContext.SetSelection(0) 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.GetView().SetOriginY(0)
} }
filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch())

View file

@ -66,6 +66,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix
self.c.PostRefreshUpdate(self.c.Contexts().SubCommits) self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
subCommitsContext.FocusLine(true)
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
return nil return nil

View file

@ -87,8 +87,8 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
repoState := self.c.State().GetRepoState() repoState := self.c.State().GetRepoState()
var searchPrefix string var searchPrefix string
if _, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok { if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
searchPrefix = self.c.Tr.FilterPrefix searchPrefix = filterableContext.FilterPrefix(self.c.Tr)
} else { } else {
searchPrefix = self.c.Tr.SearchPrefix searchPrefix = self.c.Tr.SearchPrefix
} }

View file

@ -136,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error {
self.context.SetNeedRerenderVisibleLines() self.context.SetNeedRerenderVisibleLines()
} }
self.context.HandleFocus(types.OnFocusOpts{}) self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
} else { } else {
// If the selection did not change (because, for example, we are at the top of the list and // 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 // press up), we still want to ensure that the selection is visible. This is useful after
@ -205,10 +205,9 @@ func (self *ListController) handlePageChange(delta int) error {
// must tell it explicitly to rerender. // must tell it explicitly to rerender.
self.context.SetNeedRerenderVisibleLines() self.context.SetNeedRerenderVisibleLines()
// This function scrolls the view itself, keeping the selection at the edge of // Since we are maintaining the scroll position ourselves above, there's no point in passing
// the viewport rather than in its middle, so the scroll position is ours to // ScrollSelectionIntoView=true here.
// maintain, not the focus mechanism's. self.context.HandleFocus(types.OnFocusOpts{})
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
return nil return nil
} }
@ -281,10 +280,7 @@ func (self *ListController) selectRangeThroughViewIndex(viewIndex int) {
newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex) newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex)
list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx()) list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx())
// The pointer can be outside the viewport, in which case so is the end of self.context.HandleFocus(types.OnFocusOpts{})
// 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 { func (self *ListController) handleDragAutoscroll(viewIndex int) bool {

View file

@ -191,8 +191,7 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding
self.commitDrag.hasMoved = true self.commitDrag.hasMoved = true
if self.updateCommitDragInsertion(opts.Y) { if self.updateCommitDragInsertion(opts.Y) {
self.c.PostRefreshUpdateWithOptions(self.context(), self.c.PostRefreshUpdate(self.context())
types.OnFocusOpts{KeepScrollPosition: true})
} }
originY := self.context().GetView().OriginY() originY := self.context().GetView().OriginY()
self.dragAutoscroller.Update(opts.Y - originY) self.dragAutoscroller.Update(opts.Y - originY)
@ -345,9 +344,7 @@ func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex i
func (self *LocalCommitsController) stopMovingCommitsIndicator() { func (self *LocalCommitsController) stopMovingCommitsIndicator() {
self.stopMovingCommitsIndicatorTicker() self.stopMovingCommitsIndicatorTicker()
self.context().ClearDropInsertionIndex() self.context().ClearDropInsertionIndex()
self.c.PostRefreshUpdateWithOptions( self.c.PostRefreshUpdate(self.context())
self.context(), types.OnFocusOpts{SkipMainViewUpdate: true},
)
} }
func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() { func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() {
@ -746,10 +743,7 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
HandleConfirm: func() error { HandleConfirm: func() error {
commits := self.c.Model().Commits commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx) self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
Message: self.c.Tr.SquashingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
}) })
@ -773,10 +767,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error { OnPress: func() error {
commits := self.c.Model().Commits commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx) self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
Message: self.c.Tr.FixingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommit) self.c.LogAction(self.c.Tr.Actions.FixupCommit)
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
}) })
@ -789,10 +780,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error { OnPress: func() error {
commits := self.c.Model().Commits commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx) self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
Message: self.c.Tr.FixingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
}) })
@ -903,10 +891,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str
self.c.Tr.RewordingStatus, nil, nil) self.c.Tr.RewordingStatus, nil, nil)
} }
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error {
Message: self.c.Tr.RewordingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
if err != nil { if err != nil {
return err return err
@ -992,10 +977,7 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
if !isMerge { if !isMerge {
self.selectRebaseResultCommit(startIdx) self.selectRebaseResultCommit(startIdx)
} }
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error {
Message: self.c.Tr.DroppingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DropCommit) self.c.LogAction(self.c.Tr.Actions.DropCommit)
if isMerge { if isMerge {
return self.dropMergeCommit(commits, startIdx) return self.dropMergeCommit(commits, startIdx)
@ -1020,10 +1002,7 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits commits := self.c.Model().Commits
if !commits[endIdx].IsMerge() { if !commits[endIdx].IsMerge() {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{BatchUIUpdates: true}) err, types.RefreshOptions{BatchUIUpdates: true})
@ -1045,10 +1024,7 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error {
func (self *LocalCommitsController) startInteractiveRebaseWithEdit( func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
commitsToEdit []*models.Commit, commitsToEdit []*models.Commit,
) error { ) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit) self.c.LogAction(self.c.Tr.Actions.EditCommit)
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
@ -1173,30 +1149,22 @@ func (self *LocalCommitsController) move(
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
return err 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 // Block input until the refresh has landed: a quick second press must
// read the moved todo from the refreshed model, not grab whatever the // read the moved todo from the refreshed model, not grab whatever the
// advanced selection index points at in the stale one. // advanced selection index points at in the stale one.
self.c.RefreshBlockingInput(types.RefreshOptions{ self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.REBASE_COMMITS}, Scope: []types.RefreshableView{types.REBASE_COMMITS},
SkipMainViewUpdate: true, CommitSelection: types.KeepCommitSelectionIndex,
Then: func() error { Then: onComplete,
self.context().MoveSelection(offset)
self.context().FocusLine(true)
if onComplete != nil {
return onComplete()
}
return nil
},
}) })
return nil return nil
} }
commits := self.c.Model().Commits commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
Message: self.c.Tr.MovingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if offset > 0 { if offset > 0 {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
} else { } else {
@ -1212,7 +1180,7 @@ func (self *LocalCommitsController) move(
Then: func() error { Then: func() error {
if err == nil { if err == nil {
self.context().MoveSelection(offset) self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{}) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
} }
if onComplete != nil { if onComplete != nil {
return onComplete() return onComplete()
@ -1241,10 +1209,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
selectedIdx := self.context().GetView().SelectedLineIdx() selectedIdx := self.context().GetView().SelectedLineIdx()
handleCommit = func() error { handleCommit = func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AmendCommit) self.c.LogAction(self.c.Tr.Actions.AmendCommit)
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
@ -1306,10 +1271,7 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en
} }
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
return err return err
@ -1325,10 +1287,7 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e
Title: self.c.Tr.SetAuthorPromptTitle, Title: self.c.Tr.SetAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error { HandleConfirm: func(value string) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
return err return err
@ -1348,10 +1307,7 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start,
Title: self.c.Tr.AddCoAuthorPromptTitle, Title: self.c.Tr.AddCoAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error { HandleConfirm: func(value string) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
return err return err
@ -1385,10 +1341,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
HandleConfirm: func() error { HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RevertCommit) self.c.LogAction(self.c.Tr.Actions.RevertCommit)
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error {
Message: self.c.Tr.RevertingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if mustStash { if mustStash {
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
return err return err
@ -1439,10 +1392,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
selectedIdx := self.context().GetSelectedLineIdx() selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits commits := self.c.Model().Commits
branches := self.c.Model().Branches branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
Message: self.c.Tr.CreatingFixupCommitStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
return err return err
} }
@ -1550,10 +1500,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
selectedIdx := self.context().GetSelectedLineIdx() selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits commits := self.c.Model().Commits
branches := self.c.Model().Branches branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
Message: self.c.Tr.CreatingFixupCommitStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
return err return err
} }
@ -1614,10 +1561,7 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba
// up by that many rows to stay on the same commit. Compute the target as an // up by that many rows to stay on the same commit. Compute the target as an
// absolute index now, on the current list. // absolute index now, on the current list.
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
Message: self.c.Tr.SquashingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
@ -1684,8 +1628,7 @@ func (self *LocalCommitsController) openSearch() error {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
} }
self.c.Helpers().Search.OpenSearchPrompt(self.context()) return self.c.Helpers().Search.OpenSearchPrompt(self.context())
return nil
} }
func (self *LocalCommitsController) handleOpenLogMenu() error { func (self *LocalCommitsController) handleOpenLogMenu() error {

View file

@ -109,8 +109,7 @@ func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() { manager.ReadToEnd(func() {
self.c.OnUIThread(func() error { self.c.OnUIThread(func() error {
self.c.Helpers().Search.OpenSearchPrompt(self.context) return self.c.Helpers().Search.OpenSearchPrompt(self.context)
return nil
}) })
}) })
} }

View file

@ -1,26 +1,20 @@
package controllers package controllers
import ( 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/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
) )
type MenuController struct { type MenuController struct {
baseController baseController
*ListControllerTrait[*types.MenuItem] *ListControllerTrait[*types.MenuItem]
c *ControllerCommon c *ControllerCommon
// for delegating navigation to, see physicalKeyBindings
listController *ListController
} }
var _ types.IController = &MenuController{} var _ types.IController = &MenuController{}
func NewMenuController( func NewMenuController(
c *ControllerCommon, c *ControllerCommon,
listController *ListController,
) *MenuController { ) *MenuController {
return &MenuController{ return &MenuController{
baseController: baseController{}, baseController: baseController{},
@ -30,13 +24,12 @@ func NewMenuController(
c.Contexts().Menu.GetSelected, c.Contexts().Menu.GetSelected,
c.Contexts().Menu.GetSelectedItems, 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 // NOTE: if you add a new keybinding here, you'll also need to add it to
// `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it // `reservedKeys` in `pkg/gui/context/menu_context.go`
func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{ bindings := []*types.Binding{
{ {
@ -59,51 +52,6 @@ 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 return bindings
} }
@ -125,11 +73,6 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
} }
func (self *MenuController) close() error { func (self *MenuController) close() error {
if self.context().FilterStarted() {
self.stopFiltering()
return nil
}
if self.context().IsFiltering() { if self.context().IsFiltering() {
self.c.Helpers().Search.Cancel() self.c.Helpers().Search.Cancel()
return nil return nil
@ -138,17 +81,6 @@ func (self *MenuController) close() error {
return self.context().OnMenuPress(nil) 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 { func (self *MenuController) context() *context.MenuContext {
return self.c.Contexts().Menu return self.c.Contexts().Menu
} }

View file

@ -64,7 +64,6 @@ func (self *OptionsMenuAction) Call() error {
ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft},
AllowFilteringKeybindings: true, AllowFilteringKeybindings: true,
KeepConflictingKeybindings: true, KeepConflictingKeybindings: true,
FilterAsYouType: true,
}) })
} }

View file

@ -230,8 +230,9 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread // Escape pops the patch-building context, so run it on the UI thread
// before the refresh below. // before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() { _ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
self.c.Helpers().PatchBuilding.Escape() self.c.Helpers().PatchBuilding.Escape()
return nil
}) })
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{}) err, types.RefreshOptions{})

Some files were not shown because too many files have changed in this diff Show more