mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Compare commits
205 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ede21e9c | ||
|
|
8b049be31d | ||
|
|
a8dc4aaf1b | ||
|
|
c07f4d381b | ||
|
|
f9dd66dbf0 | ||
|
|
21b649f12e | ||
|
|
3ba69d4965 | ||
|
|
abe273dd3e | ||
|
|
9182ac9ebc | ||
|
|
9aa2c0b85a | ||
|
|
9d128e8503 | ||
|
|
3c95d635cb | ||
|
|
910d1e49cf | ||
|
|
91970a1202 | ||
|
|
730b590ce6 | ||
|
|
9673a627ca | ||
|
|
114d3a5a25 | ||
|
|
811b3fbdd1 | ||
|
|
04a7ae3ec8 | ||
|
|
0505e778b3 | ||
|
|
4c90bc334c | ||
|
|
b54318e80c | ||
|
|
acb6e48a98 | ||
|
|
dd2a1a634f | ||
|
|
e148959865 | ||
|
|
ef3a4c71cc | ||
|
|
e0b2a5081d | ||
|
|
d2dc38ee87 | ||
|
|
ec3f681ebf | ||
|
|
d1707d5dd2 | ||
|
|
e8009599e0 | ||
|
|
dd01d67879 | ||
|
|
4b391acec0 | ||
|
|
eb760ee928 | ||
|
|
1b2bd85fc5 | ||
|
|
e7de68331a | ||
|
|
e0927d4faf | ||
|
|
3f6be3b3ee | ||
|
|
90f5348371 | ||
|
|
4f78a5576a | ||
|
|
8aa57264fa | ||
|
|
4116a15dae | ||
|
|
bb7e74968b | ||
|
|
fb761892aa | ||
|
|
68355862c1 | ||
|
|
2048c7f0a6 | ||
|
|
fe9b990c4c | ||
|
|
1fb5f87d05 | ||
|
|
c80035d7ee | ||
|
|
f1b0347111 | ||
|
|
a9fe055af4 | ||
|
|
45d68bccc2 | ||
|
|
f9ec7adb61 | ||
|
|
9e98b3d2f3 | ||
|
|
28c5f5748c | ||
|
|
b2a684bec1 | ||
|
|
c840013ca3 | ||
|
|
8924bca76f | ||
|
|
07fa0ef732 | ||
|
|
a8b762d03b | ||
|
|
eddbbdad23 | ||
|
|
3914755c98 | ||
|
|
d37f901ac1 | ||
|
|
fa531dc518 | ||
|
|
fd1b229f93 | ||
|
|
b7ffa93dc6 | ||
|
|
530d773052 | ||
|
|
c3027caf6b | ||
|
|
655f913f44 | ||
|
|
c300c319f9 | ||
|
|
35753fd042 | ||
|
|
c8d610cb58 | ||
|
|
c09b682f63 | ||
|
|
6a932f3057 | ||
|
|
ea91639546 | ||
|
|
d7401559f0 | ||
|
|
bee03d3b98 | ||
|
|
40cb4bb24d | ||
|
|
2c9187acb9 | ||
|
|
6913f2afce | ||
|
|
c199ac69f5 | ||
|
|
43b47d16dd | ||
|
|
c10bc3b697 | ||
|
|
adf59703ac | ||
|
|
431993b606 | ||
|
|
37c53ac1bf | ||
|
|
486d8536f2 | ||
|
|
92e50a5d9a | ||
|
|
f0ccb937d3 | ||
|
|
1db9f9cdb8 | ||
|
|
a26899f5ab | ||
|
|
4820241caf | ||
|
|
6032225472 | ||
|
|
ebfa8c71b2 | ||
|
|
3cbf40d4ef | ||
|
|
bf6c34798c | ||
|
|
9e23111172 | ||
|
|
87b30d9581 | ||
|
|
cc5d5057a7 | ||
|
|
2a6cb8d78e | ||
|
|
114d18dc04 | ||
|
|
fbadbbf99a | ||
|
|
85cff19a2a | ||
|
|
9293f03c83 | ||
|
|
86c9e6a20a | ||
|
|
e3fe321080 | ||
|
|
d65ee95942 | ||
|
|
94018de3e8 | ||
|
|
01600042cd | ||
|
|
1868167a7a | ||
|
|
1c17ee1a92 | ||
|
|
6417da5319 | ||
|
|
aa38daff98 | ||
|
|
39513d244d | ||
|
|
4e2a1cd5b0 | ||
|
|
b2b9519bcc | ||
|
|
a1f1a4ee8c | ||
|
|
a5a2bd0699 | ||
|
|
ae1007612b | ||
|
|
312a5f2cc1 | ||
|
|
80ad2db71a | ||
|
|
a1f5db6bce | ||
|
|
62caf427ac | ||
|
|
cfe7961d54 | ||
|
|
1f1fd99357 | ||
|
|
0140e19eb2 | ||
|
|
d298a7ce4b | ||
|
|
1248ac4457 | ||
|
|
cbc3da507b | ||
|
|
0c04ee5c61 | ||
|
|
c477a2959b | ||
|
|
1a3cf798f9 | ||
|
|
af04698d9a | ||
|
|
5c4c7139d7 | ||
|
|
aebf495dce | ||
|
|
29d05e23f9 | ||
|
|
a85d6e0349 | ||
|
|
e9faf0325d | ||
|
|
a881fb7ee0 | ||
|
|
4e4cd93993 | ||
|
|
ac5e0b4ae9 | ||
|
|
d25315b55a | ||
|
|
25c6aeadf0 | ||
|
|
b1e9ac3969 | ||
|
|
fbe2379fa5 | ||
|
|
f77c1d37f1 | ||
|
|
27fcf4b729 | ||
|
|
93b8f343bb | ||
|
|
c18a0c6680 | ||
|
|
c8bc1928f2 | ||
|
|
4b22b844e7 | ||
|
|
ec577f1afa | ||
|
|
70427c8ff5 | ||
|
|
2f06724b80 | ||
|
|
f9b790a1f9 | ||
|
|
ddceff6962 | ||
|
|
f416a4ba6a | ||
|
|
2399cac0db | ||
|
|
d6cf948dca | ||
|
|
4b257646ad | ||
|
|
d3a2c87293 | ||
|
|
f162dc5aec | ||
|
|
d0078bf05c | ||
|
|
5481436d8c | ||
|
|
c3450f9406 | ||
|
|
bc9fafff02 | ||
|
|
5dec89abfe | ||
|
|
4c39b0b903 | ||
|
|
ed22322ec8 | ||
|
|
6c567d1eb6 | ||
|
|
544f3b834b | ||
|
|
da7ca77c9d | ||
|
|
bf5829af3f | ||
|
|
f141fcc570 | ||
|
|
06b421ad0c | ||
|
|
9b1078a2ca | ||
|
|
d19af37ee7 | ||
|
|
34d41b5d51 | ||
|
|
616d75a1fa | ||
|
|
3d80e466ce | ||
|
|
0ce248d1bf | ||
|
|
e10a2f6a27 | ||
|
|
ca6c0500e6 | ||
|
|
e17ed2484c | ||
|
|
180039e78c | ||
|
|
d2d5bdc2bc | ||
|
|
e1b8ef048a | ||
|
|
7cbd93f945 | ||
|
|
d1014aecf3 | ||
|
|
e1391298aa | ||
|
|
813de837ef | ||
|
|
71ff5fd827 | ||
|
|
a9bb960e5c | ||
|
|
900c3e3c45 | ||
|
|
d2a1a4f2a2 | ||
|
|
bd76666d97 | ||
|
|
1b901c7187 | ||
|
|
7e6d5ff7c1 | ||
|
|
b30c734513 | ||
|
|
e54cb4bf42 | ||
|
|
f4968f6839 | ||
|
|
f8b7bab1ab | ||
|
|
8996bd68b9 | ||
|
|
34da956f5d | ||
|
|
4ec91a0bf5 |
2
.github/workflows/check-required-label.yml
vendored
2
.github/workflows/check-required-label.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
check-required-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
|
||||
- uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5
|
||||
with:
|
||||
mode: exactly
|
||||
count: 1
|
||||
|
|
|
|||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Test code
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
path: ~/git-${{matrix.git-version}}
|
||||
key: ${{runner.os}}-git-${{matrix.git-version}}
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Print git version
|
||||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Build linux binary
|
||||
|
|
@ -157,7 +157,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Check Vendor Directory
|
||||
|
|
@ -183,7 +183,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Check formatting
|
||||
|
|
@ -195,7 +195,7 @@ jobs:
|
|||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
|
||||
with:
|
||||
# If you change this, make sure to also update scripts/golangci-lint-shim.sh
|
||||
version: v2.4.0
|
||||
version: v2.12.2
|
||||
upload-coverage:
|
||||
# List all jobs that produce coverage files
|
||||
needs: [unit-tests, integration-tests]
|
||||
|
|
@ -206,7 +206,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
git push origin "refs/tags/$NEW_TAG"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
|
||||
|
|
|
|||
2
.github/workflows/sponsors.yml
vendored
2
.github/workflows/sponsors.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Sponsors 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
|
||||
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
||||
file: "README.md"
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ linters:
|
|||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- vendor/
|
||||
|
|
|
|||
49
AGENTS.md
49
AGENTS.md
|
|
@ -82,11 +82,26 @@ while still being meaningful and self-contained.
|
|||
excuse bundling it in. Before committing, review your diff and split out any
|
||||
hunk that is behavior-preserving (an extraction, a rename, a move) into a
|
||||
preceding commit, by staging hunks or resetting and recommitting in order.
|
||||
- **A preparatory refactor is a new commit only when it prepares something
|
||||
new.** Before adding one, find the commit that introduced the code you are
|
||||
about to restructure. If that commit is on this branch, the refactor is a
|
||||
`fixup!` for it rather than a commit of its own: a branch must never contain
|
||||
a commit whose code a later commit on the same branch tidies up. A prep
|
||||
refactor earns a commit of its own only when the shape it corrects came from
|
||||
before the branch. This holds across a branch stack too — if the commit that
|
||||
introduced the code is in an earlier branch of the stack, the fixup belongs
|
||||
there, and the branches above it get replayed. The one exception is when
|
||||
fixing it there turns out to be unreasonably difficult; ask me what to do
|
||||
rather than deciding to leave the repair at the tip.
|
||||
- **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
|
||||
Match the plain English imperative style of the existing history.
|
||||
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
|
||||
characters, or even a little more if needed to convey a good single-line
|
||||
summary; the body should be wrapped at 72 exactly, no more, no less.
|
||||
- **End every commit message with the `Co-authored-by:` trailer** naming the
|
||||
model that wrote it, exactly as your harness instructions spell it. Nothing
|
||||
in `just check` catches a missing one, so it has to be part of writing the
|
||||
message rather than something to notice afterwards.
|
||||
|
||||
## Iterate with `fixup!` commits
|
||||
|
||||
|
|
@ -105,6 +120,20 @@ separate, reviewable commit that the user decides when to fold in. A bare
|
|||
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
|
||||
treat "I'm only touching the tip commit" as an exception.
|
||||
|
||||
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
|
||||
you naturally would because "the branch isn't pushed yet". The user always wants
|
||||
to review what you changed, so make this transparent; no exceptions.
|
||||
|
||||
**When the tip is the wrong place for a fixup, insert it mid-branch.**
|
||||
Committing a fixup at the tip of the branch only works while the code it
|
||||
touches still looks the same there; once later commits have rewritten that
|
||||
code — or the target has since been split — the fixup won't apply, and
|
||||
rewriting the later commits to accommodate it defeats the point. Check out the
|
||||
target, make the change, `git commit --fixup=<target>`, then
|
||||
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
|
||||
branch. The fixup stays a separate, reviewable commit; only its position
|
||||
changes.
|
||||
|
||||
If the changes don't map cleanly onto existing commits — say they cut
|
||||
across several of them, or restructure something at a different layer
|
||||
than any existing commit naturally owns — stop and ask the user how to
|
||||
|
|
@ -390,12 +419,32 @@ Avoid phrasings like:
|
|||
- "cleaner than the previous approach"
|
||||
- "we used to ... but ..."
|
||||
- "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
|
||||
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
|
||||
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
|
||||
|
||||
When you're investigating a defect and laying out fix options for the user,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Fields only for `extDiff`:
|
|||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ git:
|
|||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
|
||||
|
||||
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
|
||||
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
|
||||
|
||||
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
|
||||
|
||||
## Menu filtering
|
||||
|
||||
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
|
||||
|
||||
## Filtering files by status
|
||||
|
||||
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` w `` | New worktree | |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
|
||||
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
|
||||
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
|
|
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <enter> `` | Bekijk branches | |
|
||||
| `` n `` | Voeg een nieuwe remote toe | |
|
||||
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
|
||||
| `` e `` | Edit | Wijzig remote |
|
||||
| `` f `` | Fetch | Fetch remote |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
|
|
|
|||
|
|
@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
|
||||
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
|
||||
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
|
||||
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
|
||||
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
|
||||
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
|
||||
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
|
||||
| `` q, <ctrl+c> `` | 結束 | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+z> `` | 掛起應用程式 | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
|
||||
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
|
||||
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
|
||||
|
|
@ -44,21 +44,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <, <home> `` | 捲動到頂部 | |
|
||||
| `` >, <end> `` | 捲動到底部 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <shift+down> `` | Range select down | |
|
||||
| `` <shift+up> `` | Range select up | |
|
||||
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
|
||||
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
| `` H `` | 向左捲動 | |
|
||||
| `` L `` | 向右捲動 | |
|
||||
| `` ] `` | 下一個索引標籤 | |
|
||||
| `` [ `` | 上一個索引標籤 | |
|
||||
|
||||
## Input prompt
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 主面板 (補丁生成)
|
||||
|
||||
| Key | Action | Info |
|
||||
|
|
@ -66,12 +59,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
|
||||
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
|
||||
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
|
||||
| `` <esc> `` | 退出自訂補丁建立器 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -81,8 +74,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
|
|
@ -90,15 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 挑選程式碼片段 | |
|
||||
| `` b `` | Pick both hunks | |
|
||||
| `` b `` | 選取兩個區塊 | |
|
||||
| `` <up>, k `` | 選擇上一段 | |
|
||||
| `` <down>, j `` | 選擇下一段 | |
|
||||
| `` <left>, h `` | 選擇上一個衝突 | |
|
||||
| `` <right>, l `` | 選擇下一個衝突 | |
|
||||
| `` z `` | 復原 | Undo last merge conflict resolution. |
|
||||
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
|
|
@ -108,19 +101,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 功能表
|
||||
|
|
@ -135,19 +128,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -156,12 +149,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
|
||||
| `` <enter> `` | Enter | 進入子模組 |
|
||||
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
|
||||
| `` u `` | Update | 更新子模組 |
|
||||
| `` <enter> `` | 進入 | 進入子模組 |
|
||||
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
|
||||
| `` u `` | 更新 | 更新子模組 |
|
||||
| `` n `` | 新增子模組 | |
|
||||
| `` e `` | 更新子模組 URL | |
|
||||
| `` i `` | Initialize | 初始化子模組 |
|
||||
| `` i `` | 初始化 | 初始化子模組 |
|
||||
| `` b `` | 查看批量子模組選項 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -169,27 +162,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` n `` | New worktree | |
|
||||
| `` <space> `` | Switch | Switch to the selected worktree. |
|
||||
| `` n `` | 新建工作樹 | |
|
||||
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
|
||||
| `` o `` | 在編輯器中開啟 | |
|
||||
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
|
||||
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 提交
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` b `` | 查看二分選項 | |
|
||||
| `` s `` | 壓縮 (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
|
||||
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
|
||||
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
|
||||
| `` r `` | 改寫提交 | 改寫選中的提交訊息 |
|
||||
| `` R `` | 使用編輯器改寫提交 | |
|
||||
| `` d `` | 刪除提交 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
|
||||
| `` e `` | 編輯(開始互動變基) | 編輯提交 |
|
||||
| `` i `` | 開始互動變基 | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
|
||||
| `` p `` | 挑選 | 挑選提交 (於變基過程中) |
|
||||
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
|
||||
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
|
||||
|
|
@ -198,22 +191,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` V `` | 貼上提交 (揀選) | |
|
||||
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
|
||||
| `` A `` | 修改 | 使用已預存的更改修正提交 |
|
||||
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
|
||||
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
|
||||
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -231,30 +224,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 檢出 | 檢出檔案 |
|
||||
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
|
||||
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 收藏 (Stash)
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
|
||||
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
|
||||
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
|
||||
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
|
||||
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
|
||||
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
|
||||
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` r `` | 重新命名收藏 | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -262,19 +255,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -286,18 +279,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` i `` | 顯示 git-flow 選項 | |
|
||||
| `` <space> `` | 檢出 | 檢出選定的項目。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` o `` | 建立拉取請求 | |
|
||||
| `` O `` | 建立拉取請求選項 | |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
|
||||
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
|
||||
| `` - `` | Checkout previous branch | |
|
||||
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
|
||||
| `` - `` | 簽出上一個分支 | |
|
||||
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
|
||||
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||
| `` T `` | 建立標籤 | |
|
||||
| `` s `` | 排序規則 | |
|
||||
|
|
@ -305,7 +298,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` R `` | 重新命名分支 | |
|
||||
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -313,15 +306,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
|
||||
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | 刪除 | View delete options for local/remote tag. |
|
||||
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
|
||||
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
|
||||
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
|
||||
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -330,40 +323,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
|
||||
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
|
||||
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` A `` | 修改上次提交 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` i `` | 忽略或排除檔案 | |
|
||||
| `` r `` | 重新整理檔案 | |
|
||||
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
|
||||
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
|
||||
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
|
||||
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
|
||||
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
|
||||
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
|
||||
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
|
||||
| `` g `` | 檢視遠端重設選項 | |
|
||||
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` f `` | 擷取 | 同步遠端異動 |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 次要
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
|
@ -373,9 +366,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` u `` | 檢查更新 | |
|
||||
| `` <enter> `` | 切換到最近使用的版本庫 | |
|
||||
| `` a `` | Show/cycle all branch logs | |
|
||||
| `` A `` | Show/cycle all branch logs (reverse) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` a `` | 顯示/迴圈所有分支日誌 | |
|
||||
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
|
||||
## 確認面板
|
||||
|
||||
|
|
@ -385,16 +378,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <esc> `` | 關閉/取消 | |
|
||||
| `` <ctrl+o> `` | 複製到剪貼簿 | |
|
||||
|
||||
## 輸入提示
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 遠端
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | View branches | |
|
||||
| `` <enter> `` | 檢視分支 | |
|
||||
| `` n `` | 新增遠端 | |
|
||||
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
|
||||
| `` e `` | 編輯 | 編輯遠端 |
|
||||
| `` f `` | 擷取 | 擷取遠端 |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 遠端分支
|
||||
|
|
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
|
||||
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` w `` | New worktree | |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` d `` | 刪除 | Delete the remote branch from the remote. |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
|
||||
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
|
||||
| `` s `` | 排序規則 | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Fields only for `extDiff`:
|
|||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ git:
|
|||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
|
||||
|
||||
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
|
||||
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
|
||||
|
||||
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
|
||||
|
||||
## Menu filtering
|
||||
|
||||
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
|
||||
|
||||
## Filtering files by status
|
||||
|
||||
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` w `` | New worktree | |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
|
||||
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
|
||||
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
|
|
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <enter> `` | Bekijk branches | |
|
||||
| `` n `` | Voeg een nieuwe remote toe | |
|
||||
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
|
||||
| `` e `` | Edit | Wijzig remote |
|
||||
| `` f `` | Fetch | Fetch remote |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
|
|
|
|||
|
|
@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
|
||||
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
|
||||
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
|
||||
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
|
||||
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
|
||||
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
|
||||
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
|
||||
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
|
||||
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
|
||||
| `` q, <ctrl+c> `` | 結束 | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+z> `` | 掛起應用程式 | |
|
||||
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
|
||||
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
|
||||
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
|
||||
|
|
@ -44,21 +44,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <, <home> `` | 捲動到頂部 | |
|
||||
| `` >, <end> `` | 捲動到底部 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <shift+down> `` | Range select down | |
|
||||
| `` <shift+up> `` | Range select up | |
|
||||
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
|
||||
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
| `` H `` | 向左捲動 | |
|
||||
| `` L `` | 向右捲動 | |
|
||||
| `` ] `` | 下一個索引標籤 | |
|
||||
| `` [ `` | 上一個索引標籤 | |
|
||||
|
||||
## Input prompt
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 主面板 (補丁生成)
|
||||
|
||||
| Key | Action | Info |
|
||||
|
|
@ -66,12 +59,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
|
||||
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
|
||||
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
|
||||
| `` <esc> `` | 退出自訂補丁建立器 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -81,8 +74,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|-----|--------|-------------|
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
|
|
@ -90,15 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 挑選程式碼片段 | |
|
||||
| `` b `` | Pick both hunks | |
|
||||
| `` b `` | 選取兩個區塊 | |
|
||||
| `` <up>, k `` | 選擇上一段 | |
|
||||
| `` <down>, j `` | 選擇下一段 | |
|
||||
| `` <left>, h `` | 選擇上一個衝突 | |
|
||||
| `` <right>, l `` | 選擇下一個衝突 | |
|
||||
| `` z `` | 復原 | Undo last merge conflict resolution. |
|
||||
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
|
|
@ -108,19 +101,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 功能表
|
||||
|
|
@ -135,19 +128,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -156,12 +149,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
|
||||
| `` <enter> `` | Enter | 進入子模組 |
|
||||
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
|
||||
| `` u `` | Update | 更新子模組 |
|
||||
| `` <enter> `` | 進入 | 進入子模組 |
|
||||
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
|
||||
| `` u `` | 更新 | 更新子模組 |
|
||||
| `` n `` | 新增子模組 | |
|
||||
| `` e `` | 更新子模組 URL | |
|
||||
| `` i `` | Initialize | 初始化子模組 |
|
||||
| `` i `` | 初始化 | 初始化子模組 |
|
||||
| `` b `` | 查看批量子模組選項 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -169,27 +162,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` n `` | New worktree | |
|
||||
| `` <space> `` | Switch | Switch to the selected worktree. |
|
||||
| `` n `` | 新建工作樹 | |
|
||||
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
|
||||
| `` o `` | 在編輯器中開啟 | |
|
||||
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
|
||||
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 提交
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` b `` | 查看二分選項 | |
|
||||
| `` s `` | 壓縮 (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
|
||||
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
|
||||
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
|
||||
| `` r `` | 改寫提交 | 改寫選中的提交訊息 |
|
||||
| `` R `` | 使用編輯器改寫提交 | |
|
||||
| `` d `` | 刪除提交 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
|
||||
| `` e `` | 編輯(開始互動變基) | 編輯提交 |
|
||||
| `` i `` | 開始互動變基 | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
|
||||
| `` p `` | 挑選 | 挑選提交 (於變基過程中) |
|
||||
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
|
||||
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
|
||||
|
|
@ -198,22 +191,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` V `` | 貼上提交 (揀選) | |
|
||||
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
|
||||
| `` A `` | 修改 | 使用已預存的更改修正提交 |
|
||||
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
|
||||
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
|
||||
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -231,30 +224,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 檢出 | 檢出檔案 |
|
||||
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
|
||||
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
|
||||
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 收藏 (Stash)
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
|
||||
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
|
||||
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
|
||||
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
|
||||
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
|
||||
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
|
||||
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` r `` | 重新命名收藏 | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -262,19 +255,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
|
||||
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
|
||||
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
|
||||
| `` o `` | 在瀏覽器中開啟提交 | |
|
||||
| `` n `` | 從提交建立新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
|
||||
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` * `` | Select commits of current branch | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` * `` | 選擇目前分支的提交 | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -286,18 +279,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` i `` | 顯示 git-flow 選項 | |
|
||||
| `` <space> `` | 檢出 | 檢出選定的項目。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
|
||||
| `` w `` | New worktree | |
|
||||
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` o `` | 建立拉取請求 | |
|
||||
| `` O `` | 建立拉取請求選項 | |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` G `` | 在瀏覽器中開啟拉取請求 | |
|
||||
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
|
||||
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
|
||||
| `` - `` | Checkout previous branch | |
|
||||
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
|
||||
| `` - `` | 簽出上一個分支 | |
|
||||
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
|
||||
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||
| `` T `` | 建立標籤 | |
|
||||
| `` s `` | 排序規則 | |
|
||||
|
|
@ -305,7 +298,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` R `` | 重新命名分支 | |
|
||||
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -313,15 +306,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
|
||||
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | 刪除 | View delete options for local/remote tag. |
|
||||
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
|
||||
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
|
||||
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
|
||||
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
|
@ -330,40 +323,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
|
||||
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
|
||||
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
|
||||
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
|
||||
| `` y `` | 複製到剪貼簿 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` A `` | 修改上次提交 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` e `` | 編輯 | 使用外部編輯器開啟 |
|
||||
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
|
||||
| `` i `` | 忽略或排除檔案 | |
|
||||
| `` r `` | 重新整理檔案 | |
|
||||
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
|
||||
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
|
||||
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
|
||||
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
|
||||
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
|
||||
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
|
||||
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
|
||||
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
|
||||
| `` g `` | 檢視遠端重設選項 | |
|
||||
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
|
||||
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
|
||||
| `` f `` | 擷取 | 同步遠端異動 |
|
||||
| `` - `` | Collapse all files | Collapse all directories in the files tree |
|
||||
| `` = `` | Expand all files | Expand all directories in the file tree |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
|
||||
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 次要
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
|
@ -373,9 +366,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` u `` | 檢查更新 | |
|
||||
| `` <enter> `` | 切換到最近使用的版本庫 | |
|
||||
| `` a `` | Show/cycle all branch logs | |
|
||||
| `` A `` | Show/cycle all branch logs (reverse) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` a `` | 顯示/迴圈所有分支日誌 | |
|
||||
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
|
||||
## 確認面板
|
||||
|
||||
|
|
@ -385,16 +378,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` <esc> `` | 關閉/取消 | |
|
||||
| `` <ctrl+o> `` | 複製到剪貼簿 | |
|
||||
|
||||
## 輸入提示
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | 確認 | |
|
||||
| `` <esc> `` | 關閉/取消 | |
|
||||
|
||||
## 遠端
|
||||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <enter> `` | View branches | |
|
||||
| `` <enter> `` | 檢視分支 | |
|
||||
| `` n `` | 新增遠端 | |
|
||||
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
|
||||
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
|
||||
| `` e `` | 編輯 | 編輯遠端 |
|
||||
| `` f `` | 擷取 | 擷取遠端 |
|
||||
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
|
||||
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 遠端分支
|
||||
|
|
@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
|
||||
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
|
||||
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
|
||||
| `` n `` | 新分支 | |
|
||||
| `` w `` | New worktree | |
|
||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` d `` | 刪除 | Delete the remote branch from the remote. |
|
||||
| `` w `` | 新建工作樹 | |
|
||||
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
|
||||
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
|
||||
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
|
||||
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
|
||||
| `` s `` | 排序規則 | |
|
||||
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
|
||||
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
| `` 0 `` | 聚焦主檢視 | |
|
||||
| `` <enter> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
|
|
|||
32
flake.lock
32
flake.lock
|
|
@ -7,7 +7,7 @@
|
|||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
"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"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
|
|
@ -19,11 +19,11 @@
|
|||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1759362264,
|
||||
"narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=",
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "758cf7296bee11f1706a574c77d072b8a7baa881",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -34,11 +34,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1759831965,
|
||||
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
|
||||
"lastModified": 1785828668,
|
||||
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
|
||||
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -50,11 +50,11 @@
|
|||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1754788789,
|
||||
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
|
||||
"lastModified": 1785031560,
|
||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
|
||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -65,11 +65,11 @@
|
|||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1754340878,
|
||||
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
|
||||
"lastModified": 1770107345,
|
||||
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
|
||||
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -108,11 +108,11 @@
|
|||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1758728421,
|
||||
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
|
||||
"lastModified": 1785360170,
|
||||
"narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
|
||||
"rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@
|
|||
};
|
||||
|
||||
treefmt = {
|
||||
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
|
||||
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
|
||||
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler;
|
||||
programs.nixfmt.package = pkgs.nixfmt;
|
||||
programs.gofmt.enable = true;
|
||||
};
|
||||
|
||||
|
|
|
|||
27
go.mod
27
go.mod
|
|
@ -5,6 +5,9 @@ go 1.25.0
|
|||
// This is necessary to ignore test files when executing gofumpt.
|
||||
ignore ./test
|
||||
|
||||
// Likewise for worktrees that are nested in the main tree.
|
||||
ignore ./.worktrees
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/adrg/xdg v0.5.3
|
||||
|
|
@ -13,7 +16,7 @@ require (
|
|||
github.com/cli/go-gh/v2 v2.13.0
|
||||
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gdamore/tcell/v3 v3.4.1
|
||||
github.com/gdamore/tcell/v3 v3.4.2
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/gookit/color v1.6.1
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
|
|
@ -21,8 +24,8 @@ require (
|
|||
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
|
||||
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
|
||||
github.com/kyokomi/emoji/v2 v2.2.13
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/kyokomi/emoji/v2 v2.2.14
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/mgutz/str v1.2.0
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
|
||||
|
|
@ -31,12 +34,12 @@ require (
|
|||
github.com/samber/lo v1.53.0
|
||||
github.com/sanity-io/litter v1.5.8
|
||||
github.com/sasha-s/go-deadlock v0.3.9
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spkg/bom v1.0.1
|
||||
github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/xo/terminfo v1.0.0
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
|
|
@ -50,11 +53,9 @@ require (
|
|||
github.com/cli/safeexec v1.0.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/color v1.9.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hpcloud/tail v1.0.0 // indirect
|
||||
github.com/invopop/jsonschema v0.10.0 // indirect
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
|
||||
|
|
@ -63,16 +64,16 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/onsi/ginkgo v1.10.3 // indirect
|
||||
github.com/onsi/gomega v1.34.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
mvdan.cc/gofumpt v0.11.0 // indirect
|
||||
)
|
||||
|
||||
tool mvdan.cc/gofumpt
|
||||
|
|
|
|||
57
go.sum
57
go.sum
|
|
@ -25,22 +25,20 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
|
||||
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
|
||||
github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU=
|
||||
github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
|
||||
|
|
@ -73,10 +71,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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
|
|
@ -100,12 +98,11 @@ github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeD
|
|||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
|
||||
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
|
|
@ -114,8 +111,8 @@ github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rY
|
|||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
|
||||
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs=
|
||||
|
|
@ -125,28 +122,30 @@ github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304/go.
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -175,14 +174,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
@ -196,5 +195,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
|
||||
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type errorMapping struct {
|
|||
func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
|
||||
errorMessage := err.Error()
|
||||
|
||||
knownErrorMessages := []string{minGitVersionErrorMessage(tr)}
|
||||
knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported}
|
||||
|
||||
if lo.Contains(knownErrorMessages, errorMessage) {
|
||||
return errorMessage, true
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/maps"
|
||||
"github.com/jesseduffield/lazycore/pkg/utils"
|
||||
"github.com/jesseduffield/lazygit/pkg/app"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ func CommandToRun() string {
|
|||
}
|
||||
|
||||
func GetKeybindingsDir() string {
|
||||
return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
|
||||
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings"
|
||||
}
|
||||
|
||||
func generateAtDir(cheatsheetDir string) {
|
||||
|
|
@ -196,7 +196,7 @@ func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
|
|||
|
||||
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
|
||||
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings)
|
||||
|
||||
for _, section := range bindingSections {
|
||||
content.WriteString(formatTitle(section.title))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -67,11 +68,24 @@ func NewGitCommand(
|
|||
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())
|
||||
if err != nil {
|
||||
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
|
||||
// (see NewGitCmdObjBuilder); the config commands run outside that builder.
|
||||
gitConfig.SetDir(repoPaths.WorktreePath())
|
||||
|
|
@ -94,7 +108,7 @@ func NewGitCommandAux(
|
|||
repoPaths *git_commands.RepoPaths,
|
||||
diffRendererConfigManager *config.DiffRendererConfigManager,
|
||||
) *GitCommand {
|
||||
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath())
|
||||
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars())
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ type gitCmdObjBuilder struct {
|
|||
// the old builder) must keep running its commands against the repo it
|
||||
// started in, not whichever one the process has since moved to.
|
||||
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{}
|
||||
|
|
@ -30,7 +37,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
|
|||
// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar.
|
||||
var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0"
|
||||
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder {
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []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)
|
||||
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
|
||||
return &gitCmdObjRunner{
|
||||
|
|
@ -43,15 +50,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
|
|||
return &gitCmdObjBuilder{
|
||||
innerBuilder: updatedBuilder,
|
||||
repoDir: repoDir,
|
||||
envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...),
|
||||
}
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) Quote(str string) string {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
|
|||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
|
||||
|
|
@ -34,8 +35,27 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
|
|||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
nil,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi
|
|||
}
|
||||
|
||||
if workingTreeState.Rebasing {
|
||||
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, addConflictedRebasingCommit)
|
||||
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, commits, addConflictedRebasingCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -251,8 +251,8 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line
|
|||
})
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) ([]*models.Commit, error) {
|
||||
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), false)
|
||||
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, existingCommits []*models.Commit, addConflictingCommit bool) ([]*models.Commit, error) {
|
||||
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), existingCommits, false)
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) {
|
||||
|
|
@ -271,39 +271,56 @@ func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool
|
|||
}
|
||||
}
|
||||
|
||||
return self.getHydratedTodoCommits(hashPool, commits, true)
|
||||
return self.getHydratedTodoCommits(hashPool, commits, nil, true)
|
||||
}
|
||||
|
||||
func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) {
|
||||
func (self *CommitLoader) getHydratedTodoCommits(
|
||||
hashPool *utils.StringPool,
|
||||
todoCommits []*models.Commit,
|
||||
existingCommits []*models.Commit,
|
||||
todoFileHasShortHashes bool,
|
||||
) ([]*models.Commit, error) {
|
||||
if len(todoCommits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
|
||||
return commit.Hash(), commit.Hash() != ""
|
||||
})
|
||||
|
||||
// note that we're not filtering these as we do non-rebasing commits just because
|
||||
// I suspect that will cause some damage
|
||||
cmdObj := self.cmd.New(
|
||||
NewGitCmd("show").
|
||||
Config("log.showSignature=false").
|
||||
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
|
||||
Arg(commitHashes...).
|
||||
ToArgv(),
|
||||
).DontLog()
|
||||
|
||||
// A refresh of only the rebasing todos should reuse the already loaded todos to avoid
|
||||
// unnecessary git show calls.
|
||||
fullCommits := map[string]*models.Commit{}
|
||||
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
|
||||
if line == "" || line[0] != '+' {
|
||||
return false, nil
|
||||
for _, commit := range existingCommits {
|
||||
if commit.IsTODO() && commit.Hash() != "" {
|
||||
// Make a copy of the commit; that's necessary to avoid mutating the original commit
|
||||
// when we later reuse it in the loop at the end of this function.
|
||||
fullCommits[commit.Hash()] = lo.ToPtr(*commit)
|
||||
}
|
||||
commit := self.extractCommitFromLine(hashPool, line[1:], false)
|
||||
fullCommits[commit.Hash()] = commit
|
||||
return false, nil
|
||||
}
|
||||
|
||||
commitHashesToFetch := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
|
||||
return commit.Hash(), commit.Hash() != "" && fullCommits[commit.Hash()] == nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
if len(commitHashesToFetch) > 0 {
|
||||
// note that we're not filtering these as we do non-rebasing commits just because
|
||||
// I suspect that will cause some damage
|
||||
cmdObj := self.cmd.New(
|
||||
NewGitCmd("show").
|
||||
Config("log.showSignature=false").
|
||||
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
|
||||
Arg(commitHashesToFetch...).
|
||||
ToArgv(),
|
||||
).DontLog()
|
||||
|
||||
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
|
||||
if line == "" || line[0] != '+' {
|
||||
return false, nil
|
||||
}
|
||||
commit := self.extractCommitFromLine(hashPool, line[1:], false)
|
||||
fullCommits[commit.Hash()] = commit
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
findFullCommit := lo.Ternary(todoFileHasShortHashes,
|
||||
|
|
|
|||
|
|
@ -538,6 +538,110 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommitLoaderGetHydratedTodoCommitsReusesExistingCommit(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
runner := oscommands.NewFakeRunner(t)
|
||||
loader := &CommitLoader{
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(runner),
|
||||
}
|
||||
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: "0123456789012345678901234567890123456789",
|
||||
Name: "hydrated subject",
|
||||
AuthorName: "Jane Doe",
|
||||
AuthorEmail: "jane@example.com",
|
||||
UnixTimestamp: 1234,
|
||||
Parents: []string{"1123456789012345678901234567890123456789"},
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
})
|
||||
refreshedTodo := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingCommit.Hash(),
|
||||
Name: "subject from the todo file",
|
||||
Status: models.StatusConflicted,
|
||||
Action: todo.Fixup,
|
||||
ActionFlag: "-C",
|
||||
})
|
||||
|
||||
commits, err := loader.getHydratedTodoCommits(
|
||||
hashPool,
|
||||
[]*models.Commit{refreshedTodo},
|
||||
[]*models.Commit{existingCommit},
|
||||
false,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*models.Commit{
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingCommit.Hash(),
|
||||
Name: "hydrated subject",
|
||||
AuthorName: "Jane Doe",
|
||||
AuthorEmail: "jane@example.com",
|
||||
UnixTimestamp: 1234,
|
||||
Parents: []string{"1123456789012345678901234567890123456789"},
|
||||
Status: models.StatusConflicted,
|
||||
Action: todo.Fixup,
|
||||
ActionFlag: "-C",
|
||||
}),
|
||||
}, commits)
|
||||
assert.Equal(t, todo.Pick, existingCommit.Action)
|
||||
assert.Equal(t, models.StatusRebasing, existingCommit.Status)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCommitLoaderGetHydratedTodoCommitsLoadsMissingCommit(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
existingHash := "0123456789012345678901234567890123456789"
|
||||
missingHash := "2123456789012345678901234567890123456789"
|
||||
missingCommitOutput := strings.ReplaceAll(
|
||||
`+2123456789012345678901234567890123456789|1235|John Doe|john@example.com||>|tag: new|new subject`,
|
||||
"|",
|
||||
"\x00",
|
||||
)
|
||||
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(
|
||||
[]string{
|
||||
"-c", "log.showSignature=false", "show", "--no-patch", "--oneline", "--abbrev=20",
|
||||
prettyFormat, missingHash,
|
||||
},
|
||||
missingCommitOutput,
|
||||
nil,
|
||||
)
|
||||
loader := &CommitLoader{
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(runner),
|
||||
}
|
||||
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingHash,
|
||||
Name: "existing subject",
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
})
|
||||
refreshedTodos := []*models.Commit{
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: existingHash,
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Pick,
|
||||
}),
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: missingHash,
|
||||
Status: models.StatusRebasing,
|
||||
Action: todo.Edit,
|
||||
}),
|
||||
}
|
||||
|
||||
commits, err := loader.getHydratedTodoCommits(
|
||||
hashPool,
|
||||
refreshedTodos,
|
||||
[]*models.Commit{existingCommit},
|
||||
false,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commits, 2)
|
||||
assert.Equal(t, "existing subject", commits[0].Name)
|
||||
assert.Equal(t, "new subject", commits[1].Name)
|
||||
assert.Equal(t, todo.Edit, commits[1].Action)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCommitLoader_setCommitStatuses(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ package git_commands
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type FileLoaderConfig interface {
|
||||
|
|
@ -88,27 +88,66 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
|||
files = append(files, file)
|
||||
}
|
||||
|
||||
// Go through the files to see if any of these files are actually worktrees
|
||||
// so that we can render them correctly
|
||||
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
|
||||
for _, file := range files {
|
||||
for _, worktreePath := range worktreePaths {
|
||||
absFilePath, err := filepath.Abs(file.Path)
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
continue
|
||||
}
|
||||
if absFilePath == worktreePath {
|
||||
file.IsWorktree = true
|
||||
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
|
||||
// If we include the slash, it will be rendered as a folder with a null file inside.
|
||||
file.Path = strings.TrimSuffix(file.Path, "/")
|
||||
break
|
||||
}
|
||||
self.setConflictMarkerSizes(files)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// Looks up how long the conflict markers in the conflicted files are. We ask
|
||||
// git for all of them at once, because spawning a process per file would be
|
||||
// painfully slow when hundreds of files are conflicted (especially on Windows).
|
||||
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) {
|
||||
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool {
|
||||
return file.HasInlineMergeConflicts
|
||||
})
|
||||
if len(conflictedFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
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 files
|
||||
return markerSizes, nil
|
||||
}
|
||||
|
||||
type FileDiff struct {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
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",
|
||||
nil,
|
||||
).
|
||||
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
|
||||
"file5.txt\x00conflict-marker-size\x00unspecified\x00",
|
||||
nil,
|
||||
),
|
||||
showNumstatInFilesView: true,
|
||||
expectedFiles: []*models.File{
|
||||
|
|
@ -112,6 +116,58 @@ 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",
|
||||
similarityThreshold: 50,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
)
|
||||
|
||||
// OptionalLocksEnvVar is the name of the environment variable that tells git
|
||||
|
|
@ -18,6 +19,15 @@ import (
|
|||
// that opts back in is the foreground files refresh; see FileLoader.gitStatus.
|
||||
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
|
||||
// including conditional args
|
||||
type GitCommandBuilder struct {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
|||
return queryString, variables
|
||||
}
|
||||
|
||||
// GetAuthToken returns the token to authenticate against the given host with,
|
||||
// or an empty string if there is none.
|
||||
//
|
||||
// The token has to come from gh itself rather than from an in-process lookup
|
||||
// with go-gh: that reads gh's config file once per process and answers from
|
||||
// that snapshot ever after, whereas gh rewrites the file whenever the active
|
||||
// account changes, and keeps the active account's token either there or in the
|
||||
// system keyring. Under a long-running lazygit the snapshot therefore drifts
|
||||
// out of date, leaving us with a token for an account that is no longer active,
|
||||
// or with no token at all.
|
||||
func (self *GitHubCommands) GetAuthToken(host string) string {
|
||||
token, _ := auth.TokenForHost(host)
|
||||
return token
|
||||
ghExe := ghExecutable()
|
||||
if ghExe == "" {
|
||||
// Without gh installed, the environment variables and config file that
|
||||
// gh would have consulted are still worth a look.
|
||||
token, _ := auth.TokenFromEnvOrConfig(host)
|
||||
return token
|
||||
}
|
||||
|
||||
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
|
||||
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
// Not being logged in to this host is a normal state rather than
|
||||
// something to report; the runner logs gh's stderr for the rest.
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
// ghExecutable returns the path of the gh binary, or an empty string if it
|
||||
// isn't installed.
|
||||
func ghExecutable() string {
|
||||
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// A gh found in the current directory rather than on PATH comes back as
|
||||
// exec.ErrDot, which we treat as not having found one at all.
|
||||
ghExe, err := exec.LookPath("gh")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
ioFs "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RepoPaths struct {
|
||||
|
|
@ -19,10 +18,12 @@ type RepoPaths struct {
|
|||
repoGitDirPath string
|
||||
repoName string
|
||||
isBareRepo bool
|
||||
gitLocationEnvVars []string
|
||||
}
|
||||
|
||||
// Path to the current worktree. If we're in the main worktree, this will
|
||||
// be the same as RepoPath()
|
||||
// be the same as RepoPath(). It is empty for a bare repo, which has no
|
||||
// worktree at all.
|
||||
func (self *RepoPaths) WorktreePath() string {
|
||||
return self.worktreePath
|
||||
}
|
||||
|
|
@ -53,10 +54,33 @@ func (self *RepoPaths) RepoName() string {
|
|||
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 {
|
||||
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
|
||||
func MockRepoPaths(currentPath string) *RepoPaths {
|
||||
return &RepoPaths{
|
||||
|
|
@ -84,26 +108,76 @@ func GetRepoPathsForDir(
|
|||
dir string,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) (*RepoPaths, error) {
|
||||
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree")
|
||||
repoPaths, err := repoPathsForDir(dir, cmd)
|
||||
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 {
|
||||
return nil, err
|
||||
// --show-toplevel is the only one of these that needs a work tree, and
|
||||
// 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")
|
||||
worktreePath := gitDirResults[0]
|
||||
worktreeGitDirPath := gitDirResults[1]
|
||||
repoGitDirPath := gitDirResults[2]
|
||||
isBareRepo := gitDirResults[3] == "true"
|
||||
|
||||
// If we're in a submodule, --show-superproject-working-tree will return
|
||||
// a value, meaning gitDirResults will be length 5. In that case
|
||||
// return the worktree path as the repoPath. Otherwise we're in a
|
||||
// normal repo or a worktree so return the parent of the git common
|
||||
// dir (repoGitDirPath)
|
||||
isSubmodule := len(gitDirResults) == 5
|
||||
// A worktree that has the repo's common git dir to itself is the repo's main
|
||||
// worktree, so it is the repoPath. That holds for a submodule as well: its
|
||||
// git dir lives under the superproject's .git/modules, but it is still the
|
||||
// submodule's own common dir.
|
||||
isMainWorktree := worktreeGitDirPath == repoGitDirPath
|
||||
|
||||
// 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
|
||||
if isSubmodule {
|
||||
if isMainWorktree || isSubmodule {
|
||||
repoPath = worktreePath
|
||||
} else {
|
||||
repoPath = filepath.Dir(repoGitDirPath)
|
||||
|
|
@ -116,62 +190,113 @@ func GetRepoPathsForDir(
|
|||
repoPath: repoPath,
|
||||
repoGitDirPath: repoGitDirPath,
|
||||
repoName: repoName,
|
||||
isBareRepo: isBareRepo,
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
|
||||
}, 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(
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
dir string,
|
||||
gitRevArgs ...string,
|
||||
) (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...)
|
||||
if dir != "" {
|
||||
gitRevParse.Dir(dir)
|
||||
}
|
||||
|
||||
gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog()
|
||||
return cmd.New(gitRevParse.ToArgv()).DontLog()
|
||||
}
|
||||
|
||||
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
|
||||
res, err := gitCmd.RunWithOutput()
|
||||
if err != nil {
|
||||
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
`C:\path\to\repo\.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
|
|
@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
"/path/to/repo/.git",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git",
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
},
|
||||
|
|
@ -76,53 +72,147 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
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",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
// setup for main worktree
|
||||
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"))
|
||||
|
||||
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
|
||||
// --show-toplevel
|
||||
`C:\path\to\repo`,
|
||||
// --git-dir
|
||||
`C:\path\to\bare_repo\bare.git`,
|
||||
`C:\path\to\project\bare.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\bare_repo\bare.git`,
|
||||
// --is-bare-repository
|
||||
`true`,
|
||||
// --show-superproject-working-tree
|
||||
`C:\path\to\project\bare.git`,
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
"/path/to/repo",
|
||||
// --git-dir
|
||||
"/path/to/bare_repo/bare.git",
|
||||
"/path/to/project/bare.git",
|
||||
// --git-common-dir
|
||||
"/path/to/bare_repo/bare.git",
|
||||
// --is-bare-repository
|
||||
"true",
|
||||
// --show-superproject-working-tree
|
||||
"/path/to/project/bare.git",
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
},
|
||||
Path: "/path/to/repo",
|
||||
Path: "/path/to/project",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
worktreePath: `C:\path\to\repo`,
|
||||
worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`,
|
||||
repoPath: `C:\path\to\bare_repo`,
|
||||
repoGitDirPath: `C:\path\to\bare_repo\bare.git`,
|
||||
repoName: `bare_repo`,
|
||||
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: "/path/to/repo",
|
||||
worktreeGitDirPath: "/path/to/bare_repo/bare.git",
|
||||
repoPath: "/path/to/bare_repo",
|
||||
repoGitDirPath: "/path/to/bare_repo/bare.git",
|
||||
repoName: "bare_repo",
|
||||
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{
|
||||
// --show-toplevel
|
||||
`C:\path\to\worktree`,
|
||||
// --git-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --show-superproject-working-tree
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
"/path/to/worktree",
|
||||
// --git-dir
|
||||
"/path/to/repo/.git",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git",
|
||||
// --show-superproject-working-tree
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
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",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
worktreePath: `C:\path\to\worktree`,
|
||||
worktreeGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoPath: `C:\path\to\worktree`,
|
||||
repoGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoName: `worktree`,
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`},
|
||||
}, &RepoPaths{
|
||||
worktreePath: "/path/to/worktree",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
repoPath: "/path/to/worktree",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
repoName: "worktree",
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
|
||||
}),
|
||||
Err: nil,
|
||||
},
|
||||
{
|
||||
Name: "submodule",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
|
|
@ -133,8 +223,6 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
`C:\path\to\repo\.git\modules\submodule1`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git\modules\submodule1`,
|
||||
// --is-bare-repository
|
||||
`false`,
|
||||
// --show-superproject-working-tree
|
||||
`C:\path\to\repo`,
|
||||
}, []string{
|
||||
|
|
@ -144,15 +232,22 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
"/path/to/repo/.git/modules/submodule1",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git/modules/submodule1",
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
"/path/to/repo",
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
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",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
|
|
@ -176,7 +271,12 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
Name: "git rev-parse returns an error",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--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"))
|
||||
},
|
||||
|
|
@ -184,7 +284,7 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
Expected: nil,
|
||||
Err: func(getRevParseArgs argFn) error {
|
||||
args := strings.Join(getRevParseArgs(), " ")
|
||||
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)
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
|
|||
Config("log.showsignature=false").
|
||||
ToArgv()
|
||||
|
||||
summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
||||
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.
|
||||
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
|
||||
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
// 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).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
||||
|
|
@ -195,20 +195,15 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
|||
Arg("--include-untracked").
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
|
||||
parentDir := ""
|
||||
if submodule.ParentModule != nil {
|
||||
parentDir = submodule.ParentModule.FullPath()
|
||||
}
|
||||
cmdArgs := NewGitCmd("submodule").
|
||||
Arg("update", "--init", "--force", "--", submodule.Path).
|
||||
DirIf(parentDir != "", parentDir).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return self.runInParentModule(submodule, self.cmd.New(cmdArgs))
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) UpdateAll() error {
|
||||
|
|
@ -225,9 +220,16 @@ func (self *SubmoduleCommands) UpdateAll() error {
|
|||
// temporarily chdir-ing the process there, which would leak the parent
|
||||
// module's directory into whatever other commands run concurrently (e.g. a
|
||||
// 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 {
|
||||
if submodule.ParentModule != nil {
|
||||
cmdObj.SetWd(submodule.ParentModule.FullPath())
|
||||
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
|
||||
}
|
||||
return cmdObj.Run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
|
|||
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) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
|
||||
|
|
|
|||
|
|
@ -385,27 +385,22 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
|
|||
// WorktreeFileDiff returns the diff of a file
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
|
||||
return s
|
||||
}
|
||||
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
|
||||
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
|
||||
// the node's path (used to diff only filtered/visible files within a directory).
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
|
||||
// in the working tree. node is the item they belong to; all it decides is
|
||||
// whether git has to compare against /dev/null, which is the case for a file
|
||||
// that isn't in the index yet.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
prevPath := node.GetPreviousPath()
|
||||
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
|
||||
|
||||
paths := pathOverrides
|
||||
if len(paths) == 0 {
|
||||
paths = []string{node.GetPath()}
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
|
|
@ -415,7 +410,6 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
|
|||
Arg("--").
|
||||
ArgIf(noIndex, "/dev/null").
|
||||
Arg(paths...).
|
||||
ArgIf(prevPath != "", prevPath).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv()
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
|
|||
func (self *WorktreeCommands) Detach(worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader {
|
|||
}
|
||||
|
||||
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
||||
currentRepoPath := self.repoPaths.RepoPath()
|
||||
worktreePath := self.repoPaths.WorktreePath()
|
||||
|
||||
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv()
|
||||
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
|
|
@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
|
||||
if strings.HasPrefix(splitLine, "worktree ") {
|
||||
path := strings.SplitN(splitLine, " ", 2)[1]
|
||||
isMain := path == currentRepoPath
|
||||
isCurrent := path == worktreePath
|
||||
isPathMissing := self.pathExists(path)
|
||||
|
||||
current = &models.Worktree{
|
||||
IsMain: isMain,
|
||||
IsCurrent: isCurrent,
|
||||
IsPathMissing: isPathMissing,
|
||||
IsPathMissing: self.pathExists(path),
|
||||
Path: path,
|
||||
// we defer populating GitDir until a loop below so that
|
||||
// we can parallelize the calls to git rev-parse
|
||||
// we can parallelize the calls to git rev-parse, and
|
||||
// IsMain/IsCurrent because they are derived from GitDir
|
||||
GitDir: "",
|
||||
}
|
||||
} else if strings.HasPrefix(splitLine, "HEAD ") {
|
||||
|
|
@ -84,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
if worktree.IsPathMissing {
|
||||
return
|
||||
}
|
||||
gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir")
|
||||
gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir")
|
||||
if err != nil {
|
||||
self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err)
|
||||
return
|
||||
|
|
@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
}
|
||||
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 {
|
||||
return worktree.Path
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) {
|
|||
{
|
||||
testName: "Single worktree (main)",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/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) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -55,8 +57,10 @@ branch refs/heads/mybranch
|
|||
{
|
||||
testName: "Multiple worktrees (main + linked)",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/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) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree
|
|||
{
|
||||
testName: "Worktree missing path",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/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) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -136,8 +142,10 @@ branch refs/heads/missingbranch
|
|||
{
|
||||
testName: "In linked worktree",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo-worktree",
|
||||
repoPath: "/path/to/repo",
|
||||
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) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -184,11 +192,51 @@ branch refs/heads/mybranch-worktree
|
|||
},
|
||||
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",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/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) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ type File struct {
|
|||
Deleted bool
|
||||
HasMergeConflicts bool
|
||||
HasInlineMergeConflicts bool
|
||||
DisplayString string
|
||||
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
|
||||
LinesDeleted int
|
||||
LinesAdded int
|
||||
// How long the conflict markers in this file are, taken from its
|
||||
// conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We
|
||||
// only look this up for files that have inline merge conflicts.
|
||||
ConflictMarkerSize int
|
||||
DisplayString string
|
||||
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
|
||||
LinesDeleted int
|
||||
LinesAdded int
|
||||
|
||||
// If true, this must be a worktree folder
|
||||
IsWorktree bool
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
package oscommands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/samber/lo"
|
||||
|
|
@ -228,37 +228,47 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
|||
// keeping this here in case I adapt this code for some other purpose in the future
|
||||
// cmds[len(cmds)-1].Stdout = os.Stdout
|
||||
|
||||
finalErrors := []string{}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(cmds))
|
||||
|
||||
for _, cmd := range cmds {
|
||||
go utils.Safe(func() {
|
||||
stderr, err := cmd.StderrPipe()
|
||||
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()
|
||||
})
|
||||
stderrs := make([]bytes.Buffer, len(cmds))
|
||||
for i := range cmds {
|
||||
cmds[i].Stderr = &stderrs[i]
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// 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{}
|
||||
|
||||
if startErr != nil {
|
||||
c.Log.Error(startErr)
|
||||
finalErrors = append(finalErrors, startErr.Error())
|
||||
|
||||
// Without the rest of the pipeline to drain them, the commands we did
|
||||
// start could block forever writing to a full pipe.
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
for i, cmd := range cmds[:started] {
|
||||
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 {
|
||||
return errors.New(strings.Join(finalErrors, "\n"))
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@ func (p *winPty) Close() error {
|
|||
// slave closes on child exit, but ConPTY keeps the pipe alive until we call
|
||||
// ClosePseudoConsole explicitly. Without doing that on child exit, the
|
||||
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and
|
||||
// the post-content view never gets cleared (FlushStaleCells never fires).
|
||||
// the render would never reach its end of input, so the new content would
|
||||
// never be swapped in.
|
||||
func startWaiter(proc *os.Process, p *winPty) func() error {
|
||||
done := make(chan struct{})
|
||||
var waitErr error
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ func (self *Hunk) lineCount() int {
|
|||
|
||||
// Returns all lines in the hunk, including the header line
|
||||
func (self *Hunk) allLines() []*PatchLine {
|
||||
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
|
||||
lines := make([]*PatchLine, 1, 1+len(self.bodyLines))
|
||||
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
|
||||
lines = append(lines, self.bodyLines...)
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ type editPreset struct {
|
|||
suspend func() bool
|
||||
}
|
||||
|
||||
func returnBool(a bool) func() bool { return (func() bool { return a }) }
|
||||
func returnBool(a bool) func() bool { return func() bool { return a } }
|
||||
|
||||
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
|
||||
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
|
||||
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
|
||||
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
|
||||
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") {
|
||||
if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
|
||||
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
|
||||
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
|
||||
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`
|
||||
|
|
|
|||
|
|
@ -191,6 +191,51 @@ func validateCustomCommandKey(key Keybinding) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidCustomCommandContexts lists the names a custom command's 'context' may
|
||||
// use. It mirrors context.AllContextKeys in the gui package, which this package
|
||||
// can't import; a test over there keeps the two in sync.
|
||||
var ValidCustomCommandContexts = []string{
|
||||
"global",
|
||||
"status",
|
||||
"files",
|
||||
"localBranches",
|
||||
"remotes",
|
||||
"worktrees",
|
||||
"remoteBranches",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflogCommits",
|
||||
"subCommits",
|
||||
"commitFiles",
|
||||
"stash",
|
||||
"normal",
|
||||
"normalSecondary",
|
||||
"staging",
|
||||
"stagingSecondary",
|
||||
"patchBuilding",
|
||||
"patchBuildingSecondary",
|
||||
"mergeConflicts",
|
||||
"menu",
|
||||
"confirmation",
|
||||
"prompt",
|
||||
"search",
|
||||
"commitMessage",
|
||||
"submodules",
|
||||
"suggestions",
|
||||
"cmdLog",
|
||||
}
|
||||
|
||||
func validateCustomCommandContext(context string) error {
|
||||
for _, name := range strings.Split(context, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if !slices.Contains(ValidCustomCommandContexts, name) {
|
||||
return fmt.Errorf("Unknown context '%s' for custom command. Allowed values: %s",
|
||||
name, strings.Join(ValidCustomCommandContexts, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomCommands(customCommands []CustomCommand) error {
|
||||
for _, customCommand := range customCommands {
|
||||
if err := validateCustomCommandKey(customCommand.Key); err != nil {
|
||||
|
|
@ -216,6 +261,15 @@ func validateCustomCommands(customCommands []CustomCommand) error {
|
|||
return err
|
||||
}
|
||||
} else {
|
||||
// A command in a menu may leave the context out, in which case it is
|
||||
// offered whatever is focused; a top-level one may not, but that is
|
||||
// only noticed when the keybindings are built.
|
||||
if customCommand.Context != "" {
|
||||
if err := validateCustomCommandContext(customCommand.Context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, prompt := range customCommand.Prompts {
|
||||
if err := validateCustomCommandPrompt(prompt); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -225,6 +225,43 @@ func TestUserConfigValidate_enums(t *testing.T) {
|
|||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Context: value,
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "global", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "commits, subCommits", valid: true},
|
||||
{value: "commits,subCommits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
{value: "commits, invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context in a sub menu",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Key: Keybinding{"X"},
|
||||
CommandMenu: []CustomCommand{
|
||||
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command sub menu",
|
||||
setup: func(config *UserConfig, _ string) {
|
||||
|
|
|
|||
44
pkg/env/env.go
vendored
44
pkg/env/env.go
vendored
|
|
@ -2,27 +2,59 @@ package env
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return os.Getenv("GIT_DIR")
|
||||
return os.Getenv(GitDirEnvVar)
|
||||
}
|
||||
|
||||
func SetGitDirEnv(value string) {
|
||||
os.Setenv("GIT_DIR", value)
|
||||
os.Setenv(GitDirEnvVar, value)
|
||||
}
|
||||
|
||||
func GetWorkTreeEnv() string {
|
||||
return os.Getenv("GIT_WORK_TREE")
|
||||
return os.Getenv(GitWorkTreeEnvVar)
|
||||
}
|
||||
|
||||
func SetWorkTreeEnv(value string) {
|
||||
os.Setenv("GIT_WORK_TREE", value)
|
||||
os.Setenv(GitWorkTreeEnvVar, value)
|
||||
}
|
||||
|
||||
func UnsetGitLocationEnvVars() {
|
||||
_ = os.Unsetenv("GIT_DIR")
|
||||
_ = os.Unsetenv("GIT_WORK_TREE")
|
||||
_ = os.Unsetenv(GitDirEnvVar)
|
||||
_ = os.Unsetenv(GitWorkTreeEnvVar)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
|
|||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
doubleClicks := []bool{}
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
for _, event := range []GocuiEvent{
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool {
|
|||
v.TextArea.GoToEndOfLine()
|
||||
case key.Equals(NewKeyStrMod("y", ModCtrl)):
|
||||
v.TextArea.Yank()
|
||||
case key.Str() != "" && key.Mod() == 0:
|
||||
case key.IsPrintable():
|
||||
v.TextArea.TypeCharacter(key.Str())
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
114
pkg/gocui/gui.go
114
pkg/gocui/gui.go
|
|
@ -38,6 +38,11 @@ var (
|
|||
|
||||
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
|
||||
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
|
||||
|
||||
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
|
||||
// returned. Nothing dequeues user events after that, so the callback it was
|
||||
// asked to run on the main goroutine never will be.
|
||||
ErrLoopExited = standardErrors.New("main loop exited")
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -217,6 +222,11 @@ type Gui struct {
|
|||
// worker goroutines, so it's atomic.
|
||||
uiThreadID atomic.Int64
|
||||
|
||||
// focused says whether the terminal we're running in has focus, as far as
|
||||
// its focus reports tell us (see IsFocused). Written by the event loop,
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
|
|
@ -301,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
|||
// runs during startup, before we reach MainLoop.
|
||||
g.uiThreadID.Store(goid.Get())
|
||||
|
||||
// Assume we start out focused: a terminal that supports focus reports sends
|
||||
// one for the state it is already in when we turn reporting on in MainLoop,
|
||||
// and passing that on as a change would have the app react to a change that
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
|
|
@ -659,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
|
|||
}
|
||||
|
||||
// SetTabClickBinding sets a binding for a tab click event
|
||||
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error {
|
||||
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) {
|
||||
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
|
||||
viewName: viewName,
|
||||
handler: handler,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
|
||||
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) {
|
||||
g.viewMouseBindings = append(g.viewMouseBindings, binding)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureMouse routes subsequent mouse events to view until the mouse button is
|
||||
|
|
@ -893,36 +905,50 @@ func (g *Gui) EndBlockingEvents() error {
|
|||
}
|
||||
|
||||
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
|
||||
// caller until f has run, returning f's error. Use it to read UI-thread-owned
|
||||
// state (the model, contexts) from a worker without racing the UI thread.
|
||||
// caller until f has run. Use it to read UI-thread-owned state (the model,
|
||||
// contexts) from a worker without racing the UI thread.
|
||||
//
|
||||
// The error it returns is the wait's own, never f's: it reports that f was not
|
||||
// run at all, which happens when the main loop has exited (ErrLoopExited). f
|
||||
// doesn't report an error because what callers want on the UI thread — reading
|
||||
// and mutating state — doesn't fail.
|
||||
//
|
||||
// It must be called from a worker goroutine, never from the UI thread itself:
|
||||
// the UI thread would block waiting for a callback only it can run, which
|
||||
// deadlocks. Callers arrange this by construction (see the refresh helper's
|
||||
// RefreshFromWorker); a debug-only assertion there guards against getting it
|
||||
// wrong.
|
||||
func (g *Gui) OnUIThreadAndWait(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWait(f func()) error {
|
||||
return g.onUIThreadAndWait(f, false)
|
||||
}
|
||||
|
||||
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
|
||||
// so it doesn't count towards the program being busy (see UpdateBackground).
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
|
||||
return g.onUIThreadAndWait(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
|
||||
func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
|
||||
enqueue := g.Update
|
||||
if background {
|
||||
enqueue = g.UpdateBackground
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
ran := make(chan struct{})
|
||||
enqueue(func(*Gui) error {
|
||||
result <- f()
|
||||
f()
|
||||
close(ran)
|
||||
return nil
|
||||
})
|
||||
return <-result
|
||||
|
||||
select {
|
||||
case <-ran:
|
||||
return nil
|
||||
case <-g.loopExited:
|
||||
// The queue we just enqueued onto is no longer being served, so waiting
|
||||
// on `ran` here would mean waiting for the rest of the process's life.
|
||||
return ErrLoopExited
|
||||
}
|
||||
}
|
||||
|
||||
// Calls a function in a goroutine. Handles panics gracefully and tracks
|
||||
|
|
@ -1246,7 +1272,7 @@ func calcScrollbarRune(
|
|||
|
||||
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
|
||||
height := v.InnerHeight()
|
||||
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
|
||||
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
|
||||
|
||||
if v.CanScrollPastBottom {
|
||||
fullHeight += height
|
||||
|
|
@ -1428,7 +1454,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
|
|||
currentBgColor = v.BgColor
|
||||
}
|
||||
|
||||
if i >= currentTabStart && i <= currentTabEnd {
|
||||
if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() {
|
||||
currentFgColor = v.SelFgColor
|
||||
if v != g.currentView {
|
||||
currentFgColor &= ^AttrBold
|
||||
|
|
@ -1467,7 +1493,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
|
|||
|
||||
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1589,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
|
|||
return g.flushContentOnly(views)
|
||||
}
|
||||
|
||||
// hasFocus reports whether a view is drawn as focused. Views that are embedded
|
||||
// in one another (see View.ParentView) form a single unit, so they are all drawn
|
||||
// as focused while any one of them is the current view.
|
||||
func (g *Gui) hasFocus(v *View) bool {
|
||||
return g.currentView != nil && outermostView(v) == outermostView(g.currentView)
|
||||
}
|
||||
|
||||
func outermostView(v *View) *View {
|
||||
for v.ParentView != nil {
|
||||
v = v.ParentView
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// draw manages the cursor and calls the draw function of a view.
|
||||
func (g *Gui) draw(v *View) error {
|
||||
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
|
||||
|
|
@ -1609,11 +1649,11 @@ func (g *Gui) draw(v *View) error {
|
|||
Screen.HideCursor()
|
||||
}
|
||||
|
||||
v.draw()
|
||||
v.draw(g.IsFocused())
|
||||
|
||||
if v.Frame {
|
||||
var fgColor, bgColor, frameColor Attribute
|
||||
if g.Highlight && v == g.currentView {
|
||||
if g.Highlight && g.hasFocus(v) && g.IsFocused() {
|
||||
fgColor = g.SelFgColor
|
||||
bgColor = g.SelBgColor
|
||||
frameColor = g.SelFrameColor
|
||||
|
|
@ -1717,13 +1757,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
if newY < 0 {
|
||||
newY = 0
|
||||
newCy = -v.oy
|
||||
} else if newY >= len(v.lines) {
|
||||
newY = len(v.lines) - 1
|
||||
} else if newY >= len(v.buf.lines) {
|
||||
newY = len(v.buf.lines) - 1
|
||||
newCy = newY - v.oy
|
||||
}
|
||||
|
||||
visibleLineWidth := 0
|
||||
for _, c := range v.lines[newY].cells {
|
||||
for _, c := range v.buf.lines[newY].cells {
|
||||
visibleLineWidth += c.width
|
||||
}
|
||||
if visibleLineWidth < newX {
|
||||
|
|
@ -1733,10 +1773,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
}
|
||||
|
||||
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
|
||||
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
|
||||
if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
if link := v.hyperlinkAt(newX, newY); link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1955,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
|
|||
matchingParentViewKb = nil
|
||||
break
|
||||
}
|
||||
if v != nil && g.matchView(v.ParentView, kb) {
|
||||
if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) {
|
||||
matchingParentViewKb = kb
|
||||
}
|
||||
if globalKb == nil && kb.viewName == "" {
|
||||
|
|
@ -1990,7 +2028,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// IsFocused reports whether the terminal we're running in has focus. Terminals
|
||||
// that don't report focus at all leave this true for good.
|
||||
func (g *Gui) IsFocused() bool {
|
||||
return g.focused.Load()
|
||||
}
|
||||
|
||||
func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// Terminals report their focus state when we turn focus reporting on, and
|
||||
// some report it again when their window is activated, so only pass on the
|
||||
// reports that actually change it.
|
||||
if ev.Focused == g.focused.Load() {
|
||||
return nil
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
|
|
@ -2053,13 +2105,15 @@ func (g *Gui) isSuspended() bool {
|
|||
return g.suspended
|
||||
}
|
||||
|
||||
// matchView returns if the keybinding matches the current view (and the view's context)
|
||||
// matchView returns if the keybinding matches the given view (and the view's context)
|
||||
func (g *Gui) matchView(v *View, kb *keybinding) bool {
|
||||
// if the user is typing in a field, ignore char keys
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
|
||||
// If the user is typing in a field, printable keys are theirs to type, so no
|
||||
// keybinding gets a look at them: not the field's own, and not those of the
|
||||
// view it is embedded in either.
|
||||
if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() {
|
||||
return false
|
||||
}
|
||||
if kb.viewName != v.name {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ func (k Key) IsSet() bool {
|
|||
return k.keyName != 0
|
||||
}
|
||||
|
||||
// IsPrintable reports whether the key stands for a character that can be typed
|
||||
// into a text field.
|
||||
func (k Key) IsPrintable() bool {
|
||||
return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone
|
||||
}
|
||||
|
||||
func (k Key) Equals(otherKey Key) bool {
|
||||
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
|
||||
}
|
||||
|
|
|
|||
16
pkg/gocui/key_test.go
Normal file
16
pkg/gocui/key_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeyIsPrintable(t *testing.T) {
|
||||
assert.True(t, NewKeyRune('x').IsPrintable())
|
||||
assert.True(t, NewKeyRune('界').IsPrintable())
|
||||
assert.True(t, NewKeyRune(' ').IsPrintable())
|
||||
assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable())
|
||||
assert.False(t, NewKeyName(KeyEnter).IsPrintable())
|
||||
assert.False(t, Key{}.IsPrintable())
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
|
|||
},
|
||||
},
|
||||
} {
|
||||
assert.NoError(t, g.SetViewClickBinding(binding))
|
||||
g.SetViewClickBinding(binding)
|
||||
}
|
||||
|
||||
g.captureMouse(view)
|
||||
|
|
@ -69,7 +69,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
|
|||
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
|
|
@ -77,7 +77,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
|
|||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
|
|
@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
|
|||
view.Tabs = []string{"first", "second"}
|
||||
|
||||
clickedTabs := []int{}
|
||||
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
|
||||
g.SetTabClickBinding("tabs", func(tabIndex int) error {
|
||||
clickedTabs = append(clickedTabs, tabIndex)
|
||||
return nil
|
||||
}))
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
|
|
@ -172,7 +172,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
|
|||
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
|
|
@ -180,7 +180,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
|
|||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
g.captureMouse(left)
|
||||
|
|
|
|||
142
pkg/gocui/parent_view_test.go
Normal file
142
pkg/gocui/parent_view_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A view and its parent view, with the child holding the focus.
|
||||
func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) {
|
||||
t.Helper()
|
||||
|
||||
parent, _ := g.SetView("parent", 0, 0, 20, 10, 0)
|
||||
child, _ := g.SetView("child", 0, 10, 20, 12, 0)
|
||||
child.ParentView = parent
|
||||
_, err := g.SetCurrentView(child.Name())
|
||||
assert.NoError(t, err)
|
||||
|
||||
return parent, child
|
||||
}
|
||||
|
||||
func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
|
||||
pressed := []string{}
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
pressed = append(pressed, "parent")
|
||||
return nil
|
||||
})
|
||||
g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error {
|
||||
pressed = append(pressed, "child")
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)}))
|
||||
|
||||
assert.Equal(t, []string{"parent", "child"}, pressed)
|
||||
}
|
||||
|
||||
func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, _ := setupParentAndChildView(t, g)
|
||||
|
||||
pressed := []string{}
|
||||
for _, name := range []string{"first", "second"} {
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
pressed = append(pressed, name)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
|
||||
assert.Equal(t, []string{"first"}, pressed)
|
||||
}
|
||||
|
||||
func TestEmbeddedViewsAreFocusedTogether(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0)
|
||||
sibling.ParentView = parent
|
||||
unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0)
|
||||
|
||||
assert.True(t, g.hasFocus(child))
|
||||
assert.True(t, g.hasFocus(parent))
|
||||
assert.True(t, g.hasFocus(sibling))
|
||||
assert.False(t, g.hasFocus(unrelated))
|
||||
|
||||
_, err := g.SetCurrentView(unrelated.Name())
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, g.hasFocus(unrelated))
|
||||
assert.False(t, g.hasFocus(parent))
|
||||
assert.False(t, g.hasFocus(child))
|
||||
}
|
||||
|
||||
func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
keybindOnEdit bool
|
||||
declineKeybinding bool
|
||||
expectedPresses int
|
||||
expectedEdits int
|
||||
}{
|
||||
{name: "the field gets the key", expectedEdits: 1},
|
||||
{name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1},
|
||||
{
|
||||
name: "the field gets the key the parent view declined",
|
||||
keybindOnEdit: true,
|
||||
declineKeybinding: true,
|
||||
expectedPresses: 1,
|
||||
expectedEdits: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
child.Editable = true
|
||||
child.KeybindOnEdit = test.keybindOnEdit
|
||||
|
||||
edits := 0
|
||||
child.Editor = EditorFunc(func(*View, Key) bool {
|
||||
edits++
|
||||
return true
|
||||
})
|
||||
presses := 0
|
||||
g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error {
|
||||
presses++
|
||||
if test.declineKeybinding {
|
||||
return ErrKeybindingNotHandled
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')}))
|
||||
|
||||
assert.Equal(t, test.expectedPresses, presses)
|
||||
assert.Equal(t, test.expectedEdits, edits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
parent, child := setupParentAndChildView(t, g)
|
||||
|
||||
edited := []Key{}
|
||||
child.Editable = true
|
||||
child.Editor = EditorFunc(func(_ *View, key Key) bool {
|
||||
edited = append(edited, key)
|
||||
return true
|
||||
})
|
||||
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
|
||||
return ErrKeybindingNotHandled
|
||||
})
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
|
||||
|
||||
assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited)
|
||||
}
|
||||
58
pkg/gocui/search_test.go
Normal file
58
pkg/gocui/search_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// writeLines writes the given lines to the view, as a task rendering content into it
|
||||
// does: one line at a time.
|
||||
func writeLines(v *View, lines ...string) {
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(v, "%s\n", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchStatusAfterTheMatchesChange(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 40, 10, OutputNormal)
|
||||
writeLines(v, "match", "other", "match", "other", "match")
|
||||
|
||||
v.Search("match", nil)
|
||||
_ = v.gotoNextMatch()
|
||||
_ = v.gotoNextMatch()
|
||||
index, total := v.GetSearchStatus()
|
||||
assert.Equal(t, 2, index)
|
||||
assert.Equal(t, 3, total)
|
||||
|
||||
// The content is re-rendered with only the first of those matches left in it.
|
||||
v.Clear()
|
||||
writeLines(v, "match", "other", "other")
|
||||
|
||||
index, total = v.GetSearchStatus()
|
||||
assert.Equal(t, 0, index)
|
||||
assert.Equal(t, 1, total)
|
||||
}
|
||||
|
||||
func TestSearchPositionsFollowStreamedContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 40, 10, OutputNormal)
|
||||
v.Search("match", nil)
|
||||
|
||||
// A render arrives a line at a time, and the status describes all of it.
|
||||
writeLines(v, "other", "match", "other", "match")
|
||||
|
||||
_, total := v.GetSearchStatus()
|
||||
assert.Equal(t, 2, total)
|
||||
}
|
||||
|
||||
func BenchmarkWriteToSearchedView(b *testing.B) {
|
||||
for b.Loop() {
|
||||
v := NewView("name", 0, 0, 100, 40, OutputNormal)
|
||||
v.Search("match", nil)
|
||||
for i := range 2000 {
|
||||
fmt.Fprintf(v, "line %d of a diff, most of which does not match\n", i)
|
||||
}
|
||||
v.GetSearchStatus()
|
||||
}
|
||||
}
|
||||
|
|
@ -300,15 +300,15 @@ func (g *Gui) pollEvent() GocuiEvent {
|
|||
if g.playRecording {
|
||||
select {
|
||||
case ev := <-g.replayedEvents.Keys:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.Resizes:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
case ev := <-g.replayedEvents.MouseEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.FocusEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
43
pkg/gocui/ui_thread_test.go
Normal file
43
pkg/gocui/ui_thread_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// errStillWaiting stands in for the result of a wait that hasn't produced one.
|
||||
var errStillWaiting = errors.New("still waiting")
|
||||
|
||||
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
|
||||
// returned by the time we give up on it.
|
||||
func resultOrTimeout(result chan error) error {
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
return errStillWaiting
|
||||
}
|
||||
}
|
||||
|
||||
// A worker waiting for the UI thread must not be left parked there once the
|
||||
// main loop has stopped: nothing will ever run its callback, and the shutdown
|
||||
// that follows blocks until such workers have finished (see
|
||||
// tasks.ViewBufferManager.Close).
|
||||
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
|
||||
// Closing this is what MainLoop returning does. From here on nothing
|
||||
// dequeues user events, so the callback below is never going to run.
|
||||
close(g.loopExited)
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- g.OnUIThreadAndWait(func() {})
|
||||
}()
|
||||
|
||||
err := resultOrTimeout(result)
|
||||
assert.ErrorIs(t, err, ErrLoopExited)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/gdamore/tcell/v3/color"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -101,15 +102,13 @@ func TestWriteString(t *testing.T) {
|
|||
for _, test := range tests {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal)
|
||||
for _, l := range test.existingLines {
|
||||
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
|
||||
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
|
||||
}
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.lines {
|
||||
resultingLines = append(resultingLines, cellsToStrings(l.cells))
|
||||
}
|
||||
resultingLines := lo.Map(v.buf.lines,
|
||||
func(l lineType, _ int) []string { return cellsToStrings(l.cells) })
|
||||
assert.Equal(t, test.expectedLines, resultingLines)
|
||||
}
|
||||
}
|
||||
|
|
@ -144,19 +143,115 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
|||
|
||||
v.writeString("htt")
|
||||
// No hyperlinks are generated for incomplete URLs
|
||||
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same line makes the link complete (even
|
||||
// though we didn't see a newline yet)
|
||||
v.writeString("ps://example.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
|
||||
v.Clear()
|
||||
// Valid but incomplete URL
|
||||
v.writeString("https://exa")
|
||||
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same fixes the link
|
||||
v.writeString("mple.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
func TestOffscreenRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString("a\nb\nc")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Render new, longer content off-screen.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("w\nx\ny\nz")
|
||||
|
||||
// The displayed buffer is untouched: readers still see the previous render.
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Swapping in reveals the new content in one step.
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
|
||||
|
||||
// A further write now appends to the displayed buffer directly.
|
||||
v.writeString("\nmore")
|
||||
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// When a render produces fewer view lines than the previous one,
|
||||
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
|
||||
// than leaving the previous render's entries in the tail: with the off-screen
|
||||
// render there is no half-loaded buffer whose tail we'd want to keep showing,
|
||||
// and a leftover tail is just stale lines describing content that is gone.
|
||||
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Two lines of 27 characters each wrap into 3 view lines apiece.
|
||||
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
|
||||
// Re-render with three short, unwrapped lines: only 3 view lines remain.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("aaa\nbbb\nccc")
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
// snap back as the rest streams in. See View.scrollbarHeightFloor.
|
||||
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
// Initial render: 100 lines, scrolled well down.
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.SetOrigin(0, 80)
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A re-render begins while the previous render is still shown: hold the
|
||||
// scrollbar height at the current value.
|
||||
v.FreezeScrollbarHeight()
|
||||
|
||||
// The off-screen render swaps in only a screenful at its first paint.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Repeat("y\n", 30))
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
// The displayed buffer is now short, but the scrollbar height stays held, so
|
||||
// the thumb keeps its position instead of jumping.
|
||||
assert.Equal(t, 30, v.ViewLinesHeight())
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// The rest of the content streams in.
|
||||
v.writeString(strings.Repeat("y\n", 70))
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// Once the load ends, the scrollbar tracks the real content directly again.
|
||||
v.UnfreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
|
||||
// before it reaches its end, the held scrollbar height must be released, so the
|
||||
// scrollbar reflects the new content rather than the abandoned load's height.
|
||||
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.FreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A synchronous render replaces the content before the (notional) load ends.
|
||||
v.SetContent("just a few\nshort lines\nhere")
|
||||
assert.Equal(t, 3, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
|
|
@ -233,7 +328,7 @@ func TestContainsColoredText(t *testing.T) {
|
|||
for j, cells := range test.lines {
|
||||
lines[j] = lineType{cells: cells}
|
||||
}
|
||||
v := &View{lines: lines}
|
||||
v := &View{buf: &viewBuffer{lines: lines}}
|
||||
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
|
||||
}
|
||||
}
|
||||
|
|
@ -248,8 +343,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
|
|||
// "a", then "skip to row 3" (i.e. one blank row), then "b".
|
||||
v.writeString("a\r\n\x1b[3;1Hb\r\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +364,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
|
||||
v.writeString("c\x1b[5;1Hd\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -282,6 +377,31 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
|
||||
// Soft-wrap counting has to work in an off-screen render too: the content
|
||||
// width the parser counts wraps against is set by SetContentWidth before the
|
||||
// render starts, so the off-screen buffer's parser has to pick it up. If it
|
||||
// doesn't, no wraps are counted and the CUP below is evaluated against a
|
||||
// stale row, overshooting into an extra blank line.
|
||||
v := NewView("name", 0, 0, 30, 30, OutputNormal)
|
||||
v.SetContentWidth(5)
|
||||
|
||||
v.BeginOffscreenRender()
|
||||
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
|
||||
// row 2; CUP to row 3 should then skip no rows at all.
|
||||
v.writeString("aaaaaaa\x1b[3;1Hb\n")
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
{"a", "a", "a", "a", "a", "a", "a"},
|
||||
{"b"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorForwardEscape(t *testing.T) {
|
||||
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
|
||||
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
|
||||
|
|
@ -292,8 +412,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
|
|||
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
|
||||
v.writeString("a\x1b[5X\x1b[5Cb\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +432,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
|
|||
v.writeString("abcdefghij\n")
|
||||
v.writeString("\x1b[4;1Hxyz\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -344,11 +464,7 @@ func cellsToString(cells []cell) string {
|
|||
}
|
||||
|
||||
func cellsToStrings(cells []cell) []string {
|
||||
s := []string{}
|
||||
for _, c := range cells {
|
||||
s = append(s, c.chr)
|
||||
}
|
||||
return s
|
||||
return lo.Map(cells, func(c cell, _ int) string { return c.chr })
|
||||
}
|
||||
|
||||
func TestLineWrap(t *testing.T) {
|
||||
|
|
@ -534,7 +650,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
|
|||
// renders with bg=red. The trailing area past "foo" must NOT extend
|
||||
// the red bg because '\n' marks the line as cleanly terminated.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// First row: cells 1..3 are "foo" (render with red bg via reverse),
|
||||
// cells 4..10 are trailing and should be plain default.
|
||||
|
|
@ -560,7 +676,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
|
|||
// Reverse + red fg, "foo", no termination. The trailing cells past
|
||||
// "foo" should be plain default, NOT a continuation of the red bg.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Cells 4..10 are trailing and should be default with no reverse.
|
||||
for x := 4; x <= 10; x++ {
|
||||
|
|
@ -583,7 +699,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
|
|||
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
|
||||
// fill the remaining 8 cells with red.
|
||||
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All ten cells at (1..10, 1) should have red bg.
|
||||
for x := 1; x <= 10; x++ {
|
||||
|
|
@ -611,7 +727,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
|
|||
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
|
||||
// must pick up the red fill from \x1b[K.
|
||||
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All three wrapped rows should have the red fill background across
|
||||
// the full InnerWidth, including the trailing cells past each row's
|
||||
|
|
@ -645,7 +761,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
|||
// last cell red) and segment 2 is "ccc" (green, last cell green).
|
||||
// \x1b[K records the green bg on the source line.
|
||||
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Row 1's content ends with a red cell at x=7, so trailing columns
|
||||
// 8..10 should pick up red rather than the \x1b[K's green.
|
||||
|
|
|
|||
|
|
@ -119,13 +119,12 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
|
|||
var appStatusHelper *helpers.AppStatusHelper
|
||||
var branchesHelper *helpers.BranchesHelper
|
||||
var fetchGeneration int
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
appStatusHelper = self.gui.helpers.AppStatus
|
||||
branchesHelper = self.gui.helpers.BranchesHelper
|
||||
fetchGeneration = self.gui.c.State().GetRepoGeneration()
|
||||
self.gui.State.LastBackgroundFetchTime = time.Now()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -184,10 +183,9 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
|||
// reading them from this background goroutine would race the reassignment.
|
||||
var git *commands.GitCommand
|
||||
var refreshHelper *helpers.RefreshHelper
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
refreshHelper = self.gui.helpers.Refresh
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package gui
|
|||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
|
|
@ -179,11 +180,8 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
|||
self.gui.helpers.Window.SetWindowContext(c)
|
||||
|
||||
self.gui.helpers.Window.MoveToTopOfWindow(c)
|
||||
oldView := self.gui.c.GocuiGui().CurrentView()
|
||||
if oldView != nil && oldView.Name() != viewName {
|
||||
oldView.HighlightInactive = true
|
||||
}
|
||||
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
|
||||
inputViewName := c.GetInputViewName()
|
||||
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
|
|
@ -198,9 +196,37 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
|||
|
||||
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
|
||||
|
||||
self.updateSelectionHighlights()
|
||||
|
||||
c.HandleFocus(opts)
|
||||
}
|
||||
|
||||
// updateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// them draw theirs as the active one: a view shows a selection while its context is
|
||||
// on the stack and has something to select, and the context the user is in shows the
|
||||
// active selection while the ones behind it show inactive ones.
|
||||
//
|
||||
// Both of those can change, so this is called wherever they do: from Activate, which
|
||||
// every change to the stack goes through; after a refresh, since that is when the
|
||||
// contents of a list change; and from whoever tells a context that its content has
|
||||
// gained or lost something to select.
|
||||
func (self *ContextMgr) updateSelectionHighlights() {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
onStack := set.NewFromSlice(lo.Map(self.ContextStack,
|
||||
func(c types.Context, _ int) types.ContextKey { return c.GetKey() }))
|
||||
currentKey := self.currentContextWithoutLock().GetKey()
|
||||
|
||||
for _, c := range self.allContexts.Flatten() {
|
||||
// The global context has no view of its own.
|
||||
if view := c.GetView(); view != nil {
|
||||
view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent()
|
||||
view.HighlightInactive = c.GetKey() != currentKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ContextMgr) Current() types.Context {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type BaseContext struct {
|
|||
hasControlledBounds bool
|
||||
needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
|
||||
needsRerenderOnHeightChange bool
|
||||
highlightOnFocus bool
|
||||
hasSelectableContent bool
|
||||
|
||||
*ParentContextMgr
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ type NewBaseContextOpts struct {
|
|||
Focusable bool
|
||||
Transient bool
|
||||
HasUncontrolledBounds bool // negating for the sake of making false the default
|
||||
HighlightOnFocus bool
|
||||
HasSelectableContent bool
|
||||
NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
|
||||
NeedsRerenderOnHeightChange bool
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ func NewBaseContext(opts NewBaseContextOpts) *BaseContext {
|
|||
focusable: opts.Focusable,
|
||||
transient: opts.Transient,
|
||||
hasControlledBounds: hasControlledBounds,
|
||||
highlightOnFocus: opts.HighlightOnFocus,
|
||||
hasSelectableContent: opts.HasSelectableContent,
|
||||
needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange,
|
||||
needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange,
|
||||
ParentContextMgr: &ParentContextMgr{},
|
||||
|
|
@ -102,6 +102,10 @@ func (self *BaseContext) GetViewName() string {
|
|||
return self.view.Name()
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetInputViewName() string {
|
||||
return self.GetViewName()
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetView() *gocui.View {
|
||||
return self.view
|
||||
}
|
||||
|
|
@ -114,12 +118,16 @@ func (self *BaseContext) GetKind() types.ContextKind {
|
|||
return self.kind
|
||||
}
|
||||
|
||||
func (self *BaseContext) HasSelectableContent() bool {
|
||||
return self.hasSelectableContent
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKey() types.ContextKey {
|
||||
return self.key
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{}
|
||||
bindings := make([]*types.Binding, 0, len(self.keybindingsFns))
|
||||
for i := range self.keybindingsFns {
|
||||
// the first binding in the bindings array takes precedence but we want the
|
||||
// last keybindingsFn to take precedence to we add them in reverse
|
||||
|
|
@ -216,7 +224,7 @@ func (self *BaseContext) AddOnQuitFn(fn func()) {
|
|||
}
|
||||
|
||||
func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
bindings := []*gocui.ViewMouseBinding{}
|
||||
bindings := make([]*gocui.ViewMouseBinding, 0, len(self.mouseKeybindingsFns))
|
||||
for i := range self.mouseKeybindingsFns {
|
||||
// the first binding in the bindings array takes precedence but we want the
|
||||
// last keybindingsFn to take precedence to we add them in reverse
|
||||
|
|
|
|||
22
pkg/gui/context/context_test.go
Normal file
22
pkg/gui/context/context_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The config package validates a custom command's context against its own copy of
|
||||
// these names, being unable to import this package. A name in one list but not the
|
||||
// other would be either a context that validation rejects although you can bind to
|
||||
// it, or one it accepts although binding to it exits lazygit.
|
||||
func TestValidCustomCommandContextsMatchesAllContextKeys(t *testing.T) {
|
||||
keys := lo.Map(AllContextKeys, func(key types.ContextKey, _ int) string {
|
||||
return string(key)
|
||||
})
|
||||
|
||||
assert.Equal(t, keys, config.ValidCustomCommandContexts)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
package context
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
|
||||
type FilteredListViewModel[T HasID] struct {
|
||||
*FilteredList[T]
|
||||
*ListViewModel[T]
|
||||
|
|
@ -35,8 +33,3 @@ func (self *FilteredListViewModel[T]) ClearFilter() {
|
|||
|
||||
self.SetSelection(unfilteredIndex)
|
||||
}
|
||||
|
||||
// Default implementation of most filterable contexts. Can be overridden if needed.
|
||||
func (self *FilteredListViewModel[T]) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
return tr.FilterPrefix
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,19 @@ type ListContextTrait struct {
|
|||
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search
|
||||
// result index.
|
||||
inOnSearchSelect bool
|
||||
|
||||
// If set, this renders the "x of y" footer instead of the default, which puts
|
||||
// it on the bottom border of the list's own view. A list that is part of a
|
||||
// composite panel can use this to put it somewhere else; see MenuContext.
|
||||
renderFooter func(footer string)
|
||||
}
|
||||
|
||||
func (self *ListContextTrait) IsListContext() {}
|
||||
|
||||
func (self *ListContextTrait) HasSelectableContent() bool {
|
||||
return self.list.Len() > 0
|
||||
}
|
||||
|
||||
func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
|
||||
self.Context.FocusLine(scrollIntoView)
|
||||
|
||||
|
|
@ -81,7 +90,13 @@ func (self *ListContextTrait) refreshViewport() {
|
|||
}
|
||||
|
||||
func (self *ListContextTrait) setFooter() {
|
||||
self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()))
|
||||
footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())
|
||||
if self.renderFooter != nil {
|
||||
self.renderFooter(footer)
|
||||
return
|
||||
}
|
||||
|
||||
self.GetViewTrait().SetFooter(footer)
|
||||
}
|
||||
|
||||
func formatListFooter(selectedLineIdx int, length int) string {
|
||||
|
|
@ -89,9 +104,7 @@ func formatListFooter(selectedLineIdx int, length int) string {
|
|||
}
|
||||
|
||||
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
|
||||
self.FocusLine(opts.ScrollSelectionIntoView)
|
||||
|
||||
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
|
||||
self.FocusLine(!opts.KeepScrollPosition)
|
||||
|
||||
self.Context.HandleFocus(opts)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,7 +324,13 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit {
|
|||
}
|
||||
|
||||
func shouldShowGraph(c *ContextCommon) bool {
|
||||
if c.Modes().Filtering.Active() {
|
||||
// Whether we can draw a graph is a property of the commit list we have
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ func NewMainContext(
|
|||
ctx := &MainContext{
|
||||
SimpleContext: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: false,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HasSelectableContent: false,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
|
@ -45,6 +44,13 @@ func NewMenuContext(
|
|||
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
|
||||
getNonModelItems: viewModel.GetNonModelItems,
|
||||
},
|
||||
// While the filter row is showing, its top border covers the menu's bottom
|
||||
// border, so the footer has to be rendered on the row instead.
|
||||
renderFooter: func(footer string) {
|
||||
onFilterRow := viewModel.FilterStarted()
|
||||
c.Views().Menu.Footer = lo.Ternary(onFilterRow, "", footer)
|
||||
c.Views().MenuFilterFrame.Footer = lo.Ternary(onFilterRow, footer, "")
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
|
|
@ -58,6 +64,8 @@ type MenuViewModel struct {
|
|||
columnAlignment []utils.Alignment
|
||||
allowFilteringKeybindings bool
|
||||
keybindingsTakePrecedence bool
|
||||
filterAsYouType bool
|
||||
filterStarted bool
|
||||
onCancel func() error
|
||||
*FilteredListViewModel[*types.MenuItem]
|
||||
}
|
||||
|
|
@ -128,10 +136,37 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
|
|||
self.allowFilteringKeybindings = allow
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
|
||||
return self.allowFilteringKeybindings
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
|
||||
self.keybindingsTakePrecedence = value
|
||||
}
|
||||
|
||||
// Whether this menu has a filter row that filters the items as the user types,
|
||||
// instead of being filtered through the search prompt.
|
||||
func (self *MenuViewModel) SetFilterAsYouType(value bool) {
|
||||
self.filterAsYouType = value
|
||||
self.SetFilterStarted(false)
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) FilterAsYouType() bool {
|
||||
return self.filterAsYouType
|
||||
}
|
||||
|
||||
// Whether the user has started to filter, which is when the filter row appears.
|
||||
func (self *MenuViewModel) SetFilterStarted(value bool) {
|
||||
self.filterStarted = value
|
||||
// As long as there is nothing to type into, printable keys keep driving the
|
||||
// menu, so that the configured navigation keys work like in any other menu.
|
||||
self.c.Views().MenuFilter.KeybindOnEdit = !value
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) FilterStarted() bool {
|
||||
return self.filterStarted
|
||||
}
|
||||
|
||||
// TODO: move into presentation package
|
||||
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
|
||||
menuItems := self.FilteredListViewModel.GetItems()
|
||||
|
|
@ -209,6 +244,16 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
|
|||
|
||||
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
basicBindings := self.ListContextTrait.GetKeybindings(opts)
|
||||
|
||||
if self.filterAsYouType {
|
||||
// A menu item's keys are shown as a reminder of what they do outside the
|
||||
// menu, but pressing one types it into the filter rather than executing the
|
||||
// item, so we don't bind them at all. That leaves the bindings that drive
|
||||
// the menu itself, and the printable ones among those give way to the filter
|
||||
// as soon as there is something to type into (see View.KeybindOnEdit).
|
||||
return basicBindings
|
||||
}
|
||||
|
||||
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
|
||||
return len(item.Keys) > 0
|
||||
})
|
||||
|
|
@ -267,10 +312,13 @@ func (self *MenuContext) RangeSelectEnabled() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string {
|
||||
if self.allowFilteringKeybindings {
|
||||
return tr.FilterPrefixMenu
|
||||
// A menu that filters as you type points the keyboard at its filter input, so
|
||||
// that whatever the user types ends up there. Keys that the input doesn't take
|
||||
// still reach the menu, because the input view is embedded in the menu view.
|
||||
func (self *MenuContext) GetInputViewName() string {
|
||||
if self.filterAsYouType {
|
||||
return self.c.Views().MenuFilter.Name()
|
||||
}
|
||||
|
||||
return self.FilteredListViewModel.FilterPrefix(tr)
|
||||
return self.GetViewName()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ func NewMergeConflictsContext(
|
|||
viewModel: viewModel,
|
||||
Context: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: c.Views().MergeConflicts,
|
||||
WindowName: "main",
|
||||
Key: MERGE_CONFLICTS_CONTEXT_KEY,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: true,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: c.Views().MergeConflicts,
|
||||
WindowName: "main",
|
||||
Key: MERGE_CONFLICTS_CONTEXT_KEY,
|
||||
Focusable: true,
|
||||
HasSelectableContent: true,
|
||||
}),
|
||||
),
|
||||
c: c,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func NewPatchExplorerContext(
|
|||
Key: key,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
Focusable: true,
|
||||
HighlightOnFocus: true,
|
||||
HasSelectableContent: true,
|
||||
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
|
|
|
|||
|
|
@ -33,21 +33,16 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string
|
|||
}
|
||||
|
||||
func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
|
||||
if self.highlightOnFocus {
|
||||
self.GetViewTrait().SetHighlight(true)
|
||||
}
|
||||
|
||||
for _, fn := range self.onFocusFns {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
if self.onRenderToMainFn != nil {
|
||||
if self.onRenderToMainFn != nil && !opts.SkipMainViewUpdate {
|
||||
self.onRenderToMainFn()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) {
|
||||
self.GetViewTrait().SetHighlight(false)
|
||||
self.view.SetOriginX(0)
|
||||
for _, fn := range self.onFocusLostFns {
|
||||
fn(opts)
|
||||
|
|
|
|||
|
|
@ -43,11 +43,6 @@ func (self *ViewTrait) SetContent(content string) {
|
|||
self.view.SetContent(content)
|
||||
}
|
||||
|
||||
func (self *ViewTrait) SetHighlight(highlight bool) {
|
||||
self.view.Highlight = highlight
|
||||
self.view.HighlightInactive = false
|
||||
}
|
||||
|
||||
func (self *ViewTrait) SetFooter(value string) {
|
||||
self.view.Footer = value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ var _ types.IListContext = (*WorktreesContext)(nil)
|
|||
func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Worktree { return c.Model().Worktrees },
|
||||
func(Worktree *models.Worktree) []string {
|
||||
return []string{Worktree.Name}
|
||||
func(worktree *models.Worktree) []string {
|
||||
return []string{worktree.Name, worktree.Branch}
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
common := controllers.NewControllerCommon(helperCommon, gui)
|
||||
|
||||
listControllerFactory := controllers.NewListControllerFactory(common)
|
||||
menuListController := listControllerFactory.Create(gui.State.Contexts.Menu)
|
||||
|
||||
syncController := controllers.NewSyncController(
|
||||
common,
|
||||
)
|
||||
|
|
@ -156,7 +159,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
remoteBranchesController := controllers.NewRemoteBranchesController(common)
|
||||
|
||||
menuController := controllers.NewMenuController(common)
|
||||
menuController := controllers.NewMenuController(common, menuListController)
|
||||
localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
|
||||
tagsController := controllers.NewTagsController(common)
|
||||
filesController := controllers.NewFilesController(
|
||||
|
|
@ -359,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
|
||||
controllers.AttachControllers(gui.State.Contexts.Menu,
|
||||
menuController,
|
||||
menuListController,
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.CommitMessage,
|
||||
|
|
@ -412,8 +416,11 @@ func (gui *Gui) resetHelpersAndControllers() {
|
|||
)
|
||||
|
||||
// this must come last so that we've got our click handlers defined against the context
|
||||
listControllerFactory := controllers.NewListControllerFactory(common)
|
||||
for _, context := range gui.c.Context().AllList() {
|
||||
if context == gui.State.Contexts.Menu {
|
||||
// already attached above, next to the menu controller that delegates to it
|
||||
continue
|
||||
}
|
||||
controllers.AttachControllers(context, listControllerFactory.Create(context))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,18 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
|
|||
}
|
||||
}
|
||||
|
||||
commitTagsItem := &types.MenuItem{
|
||||
Label: self.c.Tr.CommitTags,
|
||||
OnPress: func() error {
|
||||
return self.copyCommitTagsToClipboard(commit)
|
||||
},
|
||||
Keys: menuKey('t'),
|
||||
}
|
||||
|
||||
if len(commit.Tags) == 0 {
|
||||
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
|
||||
}
|
||||
|
||||
items := []*types.MenuItem{
|
||||
{
|
||||
Label: self.c.Tr.CommitHash,
|
||||
|
|
@ -207,22 +219,9 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
|
|||
},
|
||||
Keys: menuKey('a'),
|
||||
},
|
||||
commitTagsItem,
|
||||
}
|
||||
|
||||
commitTagsItem := types.MenuItem{
|
||||
Label: self.c.Tr.CommitTags,
|
||||
OnPress: func() error {
|
||||
return self.copyCommitTagsToClipboard(commit)
|
||||
},
|
||||
Keys: menuKey('t'),
|
||||
}
|
||||
|
||||
if len(commit.Tags) == 0 {
|
||||
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
|
||||
}
|
||||
|
||||
items = append(items, &commitTagsItem)
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
|
||||
Items: items,
|
||||
|
|
|
|||
|
|
@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
|
|||
HandleConfirm: func() error {
|
||||
commits := self.c.Model().Commits
|
||||
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
var filePaths []string
|
||||
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
|
||||
|
||||
|
|
@ -613,24 +616,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
|
|||
}
|
||||
}
|
||||
|
||||
// pathsForDiff returns the file paths to use for a diff command. When a text
|
||||
// filter is active and the node is a directory, only the visible (filtered)
|
||||
// file paths are returned so the diff reflects what the user sees.
|
||||
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
|
||||
if !node.IsFile() && self.context().IsFiltering() {
|
||||
var paths []string
|
||||
_ = node.ForEachFile(func(file *models.CommitFile) error {
|
||||
// For a rename we need to pass both paths so that git detects it as
|
||||
// a rename rather than an unrelated delete and add.
|
||||
paths = append(paths, file.Names()...)
|
||||
return nil
|
||||
})
|
||||
return paths
|
||||
}
|
||||
if file := node.GetFile(); file != nil {
|
||||
return file.Names()
|
||||
}
|
||||
return []string{node.GetPath()}
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
|
||||
}
|
||||
|
||||
// NOTE: these functions are identical to those in files_controller.go (except for types) and
|
||||
|
|
|
|||
132
pkg/gui/controllers/diff_paths.go
Normal file
132
pkg/gui/controllers/diff_paths.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Both models.File and models.CommitFile satisfy this. Names returns the file's
|
||||
// path, plus the path it was renamed from if it is a rename.
|
||||
type fileWithNames[T any] interface {
|
||||
*T
|
||||
GetPath() string
|
||||
GetPreviousPath() string
|
||||
Names() []string
|
||||
}
|
||||
|
||||
// diffPathsForNode returns the paths to limit a diff command to for showing the
|
||||
// changes of the given node. files are all the files that the diff contains,
|
||||
// while root is the root of the tree the node belongs to, which holds only the
|
||||
// files matching the text filter when there is one.
|
||||
func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string {
|
||||
if file := node.GetFile(); file != nil {
|
||||
return PT(file).Names()
|
||||
}
|
||||
|
||||
dir := node.GetPath()
|
||||
|
||||
if isFiltering {
|
||||
// Passing the directory would bring back the files that the filter hides,
|
||||
// so we spell out the ones it leaves.
|
||||
var paths []string
|
||||
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
|
||||
paths = append(paths, PT(file).Names()...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// The directory covers everything below it, but git only pairs up the two
|
||||
// ends of a rename if both are in the pathspec, and one end can well be
|
||||
// outside the directory. Without that end we would get an addition or a
|
||||
// deletion where the diff has a rename.
|
||||
var outsidePaths []string
|
||||
for _, f := range filesInDir[T, PT](files, dir) {
|
||||
file := PT(f)
|
||||
if p := file.GetPath(); !isInDir(p, dir) {
|
||||
outsidePaths = append(outsidePaths, p)
|
||||
}
|
||||
if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) {
|
||||
outsidePaths = append(outsidePaths, p)
|
||||
}
|
||||
}
|
||||
|
||||
return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...))
|
||||
}
|
||||
|
||||
// dropContainedPaths removes the paths that another one of them contains, since
|
||||
// a pathspec that matches a directory matches everything below it anyway.
|
||||
func dropContainedPaths(paths []string) []string {
|
||||
return lo.Filter(paths, func(p string, _ int) bool {
|
||||
return !lo.SomeBy(paths, func(other string) bool {
|
||||
return other != p && isInDir(p, other)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// collapseToDirs replaces each of the given paths with the highest directory
|
||||
// that can stand in for it, so that moving a whole directory elsewhere costs a
|
||||
// single pathspec rather than one per file. There is a limit to how long a
|
||||
// command line may get, and a commit can move a great many files at once.
|
||||
func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A directory can stand in for the paths under it as long as everything it
|
||||
// contains ends up in the diff anyway, which is to say as long as all of it
|
||||
// is in the directory we are diffing too.
|
||||
canStandIn := make(map[string]bool)
|
||||
standsIn := func(candidate string) bool {
|
||||
if result, ok := canStandIn[candidate]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
result := lo.EveryBy(files, func(file *T) bool {
|
||||
return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir)
|
||||
})
|
||||
canStandIn[candidate] = result
|
||||
return result
|
||||
}
|
||||
|
||||
return lo.Uniq(lo.Map(paths, func(p string, _ int) string {
|
||||
// A directory that can't stand in for the path rules out its parents
|
||||
// too, since they contain everything it contains. We stop short of the
|
||||
// repository root: it would leave the command with nothing to say about
|
||||
// the directory whose diff we are showing.
|
||||
for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) {
|
||||
p = candidate
|
||||
}
|
||||
return p
|
||||
}))
|
||||
}
|
||||
|
||||
func filesInTree[T any](root *filetree.Node[T]) []*T {
|
||||
files := []*T{}
|
||||
_ = root.ForEachFile(func(file *T) error {
|
||||
files = append(files, file)
|
||||
return nil
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
// filesInDir returns the files that the given directory contains, either at
|
||||
// their current or at their previous path.
|
||||
func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T {
|
||||
return lo.Filter(files, func(file *T, _ int) bool {
|
||||
return fileIsInDir[T, PT](file, dir)
|
||||
})
|
||||
}
|
||||
|
||||
func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool {
|
||||
file := PT(f)
|
||||
previousPath := file.GetPreviousPath()
|
||||
return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir))
|
||||
}
|
||||
|
||||
func isInDir(path string, dir string) bool {
|
||||
// "." is the root item, which contains every file
|
||||
return dir == "." || strings.HasPrefix(path, dir+"/")
|
||||
}
|
||||
113
pkg/gui/controllers/diff_paths_test.go
Normal file
113
pkg/gui/controllers/diff_paths_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiffPathsForNode(t *testing.T) {
|
||||
files := []*models.CommitFile{
|
||||
{Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"},
|
||||
{Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"},
|
||||
{Path: "dir/sub/file3", ChangeStatus: "M"},
|
||||
{Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"},
|
||||
{Path: "file5", ChangeStatus: "M"},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
files []*models.CommitFile // defaults to the files above
|
||||
selectedPath string
|
||||
isFiltering bool
|
||||
expectedPaths []string
|
||||
}{
|
||||
{
|
||||
testName: "file",
|
||||
selectedPath: "dir/sub/file3",
|
||||
expectedPaths: []string{"dir/sub/file3"},
|
||||
},
|
||||
{
|
||||
testName: "renamed file",
|
||||
selectedPath: "dir/file1",
|
||||
expectedPaths: []string{"dir/file1", "file1"},
|
||||
},
|
||||
{
|
||||
testName: "directory: pass the other end of each rename that crosses its boundary",
|
||||
selectedPath: "dir",
|
||||
// dir/file2-renamed was renamed within the directory, so both of its
|
||||
// paths are covered by it already
|
||||
expectedPaths: []string{"dir", "file1", "file4"},
|
||||
},
|
||||
{
|
||||
testName: "directory without renames crossing its boundary",
|
||||
selectedPath: "dir/sub",
|
||||
expectedPaths: []string{"dir/sub", "file4"},
|
||||
},
|
||||
{
|
||||
testName: "root",
|
||||
selectedPath: ".",
|
||||
expectedPaths: []string{"."},
|
||||
},
|
||||
{
|
||||
testName: "a whole directory moved into the selected one collapses to that directory",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
|
||||
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
|
||||
{Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"},
|
||||
{Path: "unrelated", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "dir",
|
||||
expectedPaths: []string{"dir", "src"},
|
||||
},
|
||||
{
|
||||
testName: "a directory that stands in for the selected one as well",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"},
|
||||
{Path: "a/b/d", ChangeStatus: "M"},
|
||||
{Path: "unrelated", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "a/b",
|
||||
expectedPaths: []string{"a"},
|
||||
},
|
||||
{
|
||||
testName: "a directory with changes of its own doesn't collapse",
|
||||
files: []*models.CommitFile{
|
||||
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
|
||||
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
|
||||
{Path: "src/nested/c", ChangeStatus: "M"},
|
||||
},
|
||||
selectedPath: "dir",
|
||||
// src/nested is left out of it, so that only src/a stays behind
|
||||
expectedPaths: []string{"dir", "src/a", "src/nested/b"},
|
||||
},
|
||||
{
|
||||
testName: "directory while filtering",
|
||||
selectedPath: "dir",
|
||||
isFiltering: true,
|
||||
expectedPaths: []string{
|
||||
"dir/file1", "file1",
|
||||
"dir/file2-renamed", "dir/file2",
|
||||
"dir/sub/file3",
|
||||
"file4", "dir/sub/file4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
files := lo.Ternary(s.files != nil, s.files, files)
|
||||
cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true)
|
||||
root := filetree.BuildTreeFromCommitFiles(files, true, cmp)
|
||||
node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool {
|
||||
return node.GetPath() == s.selectedPath
|
||||
})
|
||||
assert.True(t, found, "no node for path %s", s.selectedPath)
|
||||
|
||||
assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -130,10 +130,11 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
|||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
||||
Handler: self.toggleStagedAll,
|
||||
Description: self.c.Tr.ToggleStagedAll,
|
||||
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
|
||||
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
||||
Handler: self.toggleStagedAll,
|
||||
GetDisabledReason: self.require(self.anyFilesDisplayed),
|
||||
Description: self.c.Tr.ToggleStagedAll,
|
||||
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
|
||||
|
|
@ -328,7 +329,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
|
|||
// (it was resolved in an editor), in which case the caller should fall back to
|
||||
// showing the file's diff.
|
||||
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
|
@ -368,8 +369,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
|
|
@ -384,7 +385,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
|
|
@ -642,19 +643,9 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// pathOverridesForDiff returns file paths to override the node's path in diff
|
||||
// commands when a text filter is active and the node is a directory. This
|
||||
// ensures the diff only shows filtered/visible files.
|
||||
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
|
||||
if !node.IsFile() && self.context().IsFiltering() {
|
||||
var paths []string
|
||||
_ = node.ForEachFile(func(file *models.File) error {
|
||||
paths = append(paths, file.Path)
|
||||
return nil
|
||||
})
|
||||
return paths
|
||||
}
|
||||
return nil
|
||||
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string {
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering())
|
||||
}
|
||||
|
||||
// unstageFilteredFiles unstages only the visible (filtered) files from the
|
||||
|
|
@ -916,6 +907,17 @@ func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error
|
|||
})
|
||||
}
|
||||
|
||||
// The stage-all command acts on the file tree as it is displayed, so there has
|
||||
// to be something in it. This is also the case before the first files refresh
|
||||
// has come in, when there is no tree at all yet.
|
||||
func (self *FilesController) anyFilesDisplayed() *types.DisabledReason {
|
||||
if self.context().FileTreeViewModel.Len() == 0 {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoChangedFiles}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) toggleStagedAll() error {
|
||||
if err := self.toggleStagedAllWithLock(); err != nil {
|
||||
return err
|
||||
|
|
@ -1264,7 +1266,7 @@ func (self *FilesController) switchToMerge() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path)
|
||||
return self.c.Helpers().MergeConflicts.SwitchToMerge(file)
|
||||
}
|
||||
|
||||
func (self *FilesController) createStashMenu() error {
|
||||
|
|
@ -1508,13 +1510,20 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
|
|||
self.c.Prompt(types.PromptOpts{
|
||||
Title: self.c.Tr.StashChanges,
|
||||
HandleConfirm: func(stashComment string) error {
|
||||
self.c.LogAction(action)
|
||||
return self.c.WithWaitingStatusBlockingInput(
|
||||
types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus},
|
||||
func(gocui.Task) error {
|
||||
self.c.LogAction(action)
|
||||
|
||||
if err := stashFunc(stashComment); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
|
||||
return nil
|
||||
if err := stashFunc(stashComment); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
BatchUIUpdates: true,
|
||||
Scope: []types.RefreshableView{types.STASH, types.FILES},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AllowEmptyInput: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ func (self *FilterController) Context() types.Context {
|
|||
return self.context
|
||||
}
|
||||
|
||||
// A context that filters as the user types has an input field of its own, so it
|
||||
// has no use for the filter prompt.
|
||||
type contextThatFiltersAsYouType interface {
|
||||
FilterAsYouType() bool
|
||||
}
|
||||
|
||||
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*types.Binding{
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
|
||||
|
|
@ -44,5 +54,6 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type
|
|||
}
|
||||
|
||||
func (self *FilterController) OpenFilterPrompt() error {
|
||||
return self.c.Helpers().Search.OpenFilterPrompt(self.context)
|
||||
self.c.Helpers().Search.OpenFilterPrompt(self.context)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package controllers
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +41,7 @@ func (self *FilteringMenuAction) Call() error {
|
|||
menuItems = append(menuItems, &types.MenuItem{
|
||||
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName),
|
||||
OnPress: func() error {
|
||||
return self.setFilteringPath(fileName)
|
||||
return self.c.Helpers().Mode.SetFilteringPath(fileName)
|
||||
},
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
|
|
@ -52,7 +51,7 @@ func (self *FilteringMenuAction) Call() error {
|
|||
menuItems = append(menuItems, &types.MenuItem{
|
||||
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author),
|
||||
OnPress: func() error {
|
||||
return self.setFilteringAuthor(author)
|
||||
return self.c.Helpers().Mode.SetFilteringAuthor(author)
|
||||
},
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
|
|
@ -65,7 +64,7 @@ func (self *FilteringMenuAction) Call() error {
|
|||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(),
|
||||
Title: self.c.Tr.EnterFileName,
|
||||
HandleConfirm: func(response string) error {
|
||||
return self.setFilteringPath(response)
|
||||
return self.c.Helpers().Mode.SetFilteringPath(response)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -81,7 +80,7 @@ func (self *FilteringMenuAction) Call() error {
|
|||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
|
||||
Title: self.c.Tr.EnterAuthor,
|
||||
HandleConfirm: func(response string) error {
|
||||
return self.setFilteringAuthor(response)
|
||||
return self.c.Helpers().Mode.SetFilteringAuthor(response)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -99,34 +98,3 @@ func (self *FilteringMenuAction) Call() error {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,30 +85,32 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
|
|||
// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks
|
||||
// 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
|
||||
// gocui.BeginBlockingEvents). Use it for operations that manipulate an
|
||||
// in-progress rebase or otherwise rewrite commits, where a racing keypress
|
||||
// would target the wrong commit or todo.
|
||||
// gocui.BeginBlockingEvents). Use it for operations whose following keypress
|
||||
// depends on the state they produce, e.g. ones that manipulate an in-progress
|
||||
// rebase or otherwise rewrite commits, where a racing keypress would target the
|
||||
// wrong commit or todo.
|
||||
//
|
||||
// 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
|
||||
// between.
|
||||
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) {
|
||||
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) {
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
// Hide the rebasing-mode indicator (and its reset button) while we drive the
|
||||
// rebase ourselves; it reflects the transient on-disk state and would
|
||||
// otherwise flash on for the duration of the operation.
|
||||
self.modeHelper.SetSuppressRebasingMode(true)
|
||||
if opts.HideWorkingTreeState {
|
||||
self.modeHelper.SetSuppressWorkingTreeStateMode(true)
|
||||
}
|
||||
self.c.OnWorker(func(task gocui.Task) error {
|
||||
// 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's model bounces and Then (which RefreshFromWorker has already
|
||||
// enqueued by the time f returns), so the replayed keys act on the
|
||||
// refreshed state and any resulting rebase state shows correctly.
|
||||
// refreshed state and any resulting working tree state shows correctly.
|
||||
defer self.c.OnUIThread(func() error {
|
||||
self.modeHelper.SetSuppressRebasingMode(false)
|
||||
if opts.HideWorkingTreeState {
|
||||
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
|
||||
}
|
||||
return self.c.GocuiGui().EndBlockingEvents()
|
||||
})
|
||||
return self.WithWaitingStatusImpl(message, f, task)
|
||||
return self.WithWaitingStatusImpl(opts.Message, f, task)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,10 @@ func (self *CherryPickHelper) Paste() error {
|
|||
HandleConfirm: func() error {
|
||||
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
cherryPickedCommits := self.getData().CherryPickedCommits
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.CherryPickingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.CherryPick)
|
||||
|
||||
if mustStash {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type ConfirmationHelper struct {
|
||||
|
|
@ -323,19 +324,70 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() {
|
|||
}
|
||||
}
|
||||
|
||||
// The rows that a filter row adds to a menu popup: one for the input, and one
|
||||
// for its bottom border. Its top border is the menu's bottom border.
|
||||
const menuFilterRowHeight = 2
|
||||
|
||||
// The prompts for the filter row, from the most to the least informative. The
|
||||
// keybindings menu can also filter by keybinding, which is worth spelling out
|
||||
// when there is room for it.
|
||||
func (self *ConfirmationHelper) menuFilterPromptCandidates() []string {
|
||||
if self.c.Contexts().Menu.AllowFilteringKeybindings() {
|
||||
return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix}
|
||||
}
|
||||
|
||||
return []string{self.c.Tr.FilterPrefix}
|
||||
}
|
||||
|
||||
// Returns the first prompt that still leaves room to type in, or no prompt at
|
||||
// all if the row is too narrow even for the shortest one.
|
||||
func menuFilterPrompt(candidates []string, contentWidth int) string {
|
||||
const minimumInputWidth = 4
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) {
|
||||
menuContext := self.c.Contexts().Menu
|
||||
// we want the unfiltered length here so that if we're filtering we don't
|
||||
// resize the window
|
||||
itemCount := self.c.Contexts().Menu.UnfilteredLen()
|
||||
itemCount := menuContext.UnfilteredLen()
|
||||
offset := 3
|
||||
panelWidth := self.getPopupPanelWidth(90)
|
||||
contentWidth := panelWidth - 2 // minus 2 for the frame
|
||||
promptLinesCount := self.layoutMenuPrompt(contentWidth)
|
||||
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext)
|
||||
menuBottom := y1 - offset
|
||||
// The row is reserved for the whole time the menu is open, even though it only
|
||||
// becomes visible once the user starts typing, so that revealing it doesn't
|
||||
// move the menu.
|
||||
filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0)
|
||||
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(
|
||||
contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext)
|
||||
menuBottom := y1 - offset - filterRowHeight
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
|
||||
|
||||
tooltipTop := menuBottom + 1
|
||||
if menuContext.FilterAsYouType() {
|
||||
filterRowBottom := menuBottom + filterRowHeight
|
||||
// The row hangs off the bottom of the menu, sharing its bottom border.
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0)
|
||||
|
||||
prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth)
|
||||
self.c.Views().MenuFilterFrame.SetContent(prompt)
|
||||
// A view's content starts one column inside its bounds, so the input field
|
||||
// starts one column to the left of where its text is to appear.
|
||||
inputLeft := x0 + utils.StringWidth(prompt)
|
||||
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0)
|
||||
|
||||
if menuContext.FilterStarted() {
|
||||
tooltipTop = filterRowBottom + 1
|
||||
}
|
||||
}
|
||||
tooltip := ""
|
||||
selectedItem := self.c.Contexts().Menu.GetSelected()
|
||||
if selectedItem != nil {
|
||||
|
|
|
|||
31
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal file
31
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMenuFilterPrompt(t *testing.T) {
|
||||
longPrompt := "Filter ('@' for keybindings): "
|
||||
shortPrompt := "Filter: "
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []string
|
||||
contentWidth int
|
||||
expected string
|
||||
}{
|
||||
{name: "room for four characters", candidates: []string{shortPrompt}, contentWidth: 12, expected: shortPrompt},
|
||||
{name: "room for three characters", candidates: []string{shortPrompt}, contentWidth: 11, expected: ""},
|
||||
{name: "prefers the first candidate", candidates: []string{longPrompt, shortPrompt}, contentWidth: 34, expected: longPrompt},
|
||||
{name: "falls back to the next one", candidates: []string{longPrompt, shortPrompt}, contentWidth: 33, expected: shortPrompt},
|
||||
{name: "measures display width", candidates: []string{"篩選: "}, contentWidth: 9, expected: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, menuFilterPrompt(test.candidates, test.contentWidth))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +141,6 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
|
|||
}
|
||||
|
||||
self.c.Contexts().LocalCommits.SetSelection(index)
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
},
|
||||
|
|
|
|||
|
|
@ -188,9 +188,8 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
|
|||
}
|
||||
|
||||
result := false
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
|
||||
result = check()
|
||||
return nil
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
|
@ -17,14 +18,14 @@ func NewMergeConflictsHelper(
|
|||
}
|
||||
}
|
||||
|
||||
func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) {
|
||||
func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) {
|
||||
self.context().GetMutex().Lock()
|
||||
defer self.context().GetMutex().Unlock()
|
||||
|
||||
return self.setMergeStateWithoutLock(path)
|
||||
return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize)
|
||||
}
|
||||
|
||||
func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) {
|
||||
func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) {
|
||||
content, err := self.c.Git().File.Cat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -34,7 +35,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, e
|
|||
self.context().SetUserScrolling(false)
|
||||
}
|
||||
|
||||
self.context().GetState().SetContent(content, path)
|
||||
self.context().GetState().SetContent(content, path, markerSize)
|
||||
|
||||
return !self.context().GetState().NoConflicts(), nil
|
||||
}
|
||||
|
|
@ -72,7 +73,8 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
|
|||
self.context().GetMutex().Lock()
|
||||
defer self.context().GetMutex().Unlock()
|
||||
|
||||
hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath())
|
||||
state := self.context().GetState()
|
||||
hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -84,9 +86,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
|
|||
return false, nil
|
||||
}
|
||||
|
||||
func (self *MergeConflictsHelper) SwitchToMerge(path string) error {
|
||||
if self.context().GetState().GetPath() != path {
|
||||
hasConflicts, err := self.SetMergeState(path)
|
||||
func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error {
|
||||
if self.context().GetState().GetPath() != file.Path {
|
||||
hasConflicts, err := self.SetMergeState(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
|
|
@ -12,12 +13,12 @@ import (
|
|||
type ModeHelper struct {
|
||||
c *HelperCommon
|
||||
|
||||
diffHelper *DiffHelper
|
||||
patchBuildingHelper *PatchBuildingHelper
|
||||
cherryPickHelper *CherryPickHelper
|
||||
mergeAndRebaseHelper *MergeAndRebaseHelper
|
||||
bisectHelper *BisectHelper
|
||||
suppressRebasingMode bool
|
||||
diffHelper *DiffHelper
|
||||
patchBuildingHelper *PatchBuildingHelper
|
||||
cherryPickHelper *CherryPickHelper
|
||||
mergeAndRebaseHelper *MergeAndRebaseHelper
|
||||
bisectHelper *BisectHelper
|
||||
suppressWorkingTreeStateMode bool
|
||||
}
|
||||
|
||||
func NewModeHelper(
|
||||
|
|
@ -130,7 +131,7 @@ func (self *ModeHelper) Statuses() []ModeStatus {
|
|||
},
|
||||
{
|
||||
IsActive: func() bool {
|
||||
return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any()
|
||||
return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any()
|
||||
},
|
||||
InfoLabel: func() string {
|
||||
workingTreeState := self.c.Git().Status.WorkingTreeState()
|
||||
|
|
@ -182,16 +183,39 @@ func (self *ModeHelper) ExitFilterMode() error {
|
|||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: ScopesToRefreshWhenFilteringModeChanges(),
|
||||
Then: func() error {
|
||||
return self.changeFiltering(
|
||||
self.c.Modes().Filtering.Reset,
|
||||
func() {
|
||||
// Find the commit that was last selected in filtering mode, and select it again after refreshing
|
||||
if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) {
|
||||
// If we couldn't find it (either because no commit was selected
|
||||
|
|
@ -200,12 +224,57 @@ func (self *ModeHelper) ClearFiltering() error {
|
|||
// before we entered filtering
|
||||
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
|
||||
|
|
@ -219,6 +288,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *ModeHelper) SetSuppressRebasingMode(value bool) {
|
||||
self.suppressRebasingMode = value
|
||||
func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) {
|
||||
self.suppressWorkingTreeStateMode = value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ type refreshEnv struct {
|
|||
// persist its refreshed stat cache.
|
||||
backgroundRoutine bool
|
||||
|
||||
// Whether the views this refresh updates must keep the scroll position they
|
||||
// have. Focusing a list scrolls its selection into view, which is what a
|
||||
// user action should do — but a refresh that no user action is behind must
|
||||
// leave the viewport wherever the user last scrolled it to. That's the case
|
||||
// for the unattended background routines, and for the refreshes that merely
|
||||
// reload state (see RefreshOptions.DontBlockRepoSwitch).
|
||||
keepScrollPosition bool
|
||||
|
||||
// Whether refreshing a side context should leave the main view unchanged.
|
||||
skipMainViewUpdate bool
|
||||
|
||||
// the repo generation captured when the refresh started
|
||||
generation int
|
||||
|
||||
|
|
@ -220,13 +231,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// against the repo it started in, and the generation guard drops its
|
||||
// writes.
|
||||
env := refreshEnv{
|
||||
background: options.Background || options.DontBlockRepoSwitch,
|
||||
backgroundRoutine: options.Background,
|
||||
background: options.Background || options.DontBlockRepoSwitch,
|
||||
backgroundRoutine: options.Background,
|
||||
keepScrollPosition: options.Background || options.DontBlockRepoSwitch,
|
||||
skipMainViewUpdate: options.SkipMainViewUpdate,
|
||||
}
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
env.generation = self.c.State().GetRepoGeneration()
|
||||
env.git = self.c.Git()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if options.BatchUIUpdates {
|
||||
env.batch = &refreshBounceBatch{}
|
||||
}
|
||||
|
|
@ -262,6 +277,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// - merge conflicts are part of what the files refresh produces
|
||||
// - pull requests are fetched for the tracking branches against the
|
||||
// remotes, so refresh both alongside to fetch against fresh data
|
||||
// - commits and branches always go together: changing commits changes
|
||||
// the branches' upstream/downstream counts, and changing branches
|
||||
// (e.g. checking one out) changes the commits we show. This one comes
|
||||
// last, so that it also covers the branches the rules above add.
|
||||
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
|
||||
scopeSet.Add(types.COMMITS, types.BRANCHES)
|
||||
}
|
||||
|
|
@ -274,6 +293,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
if scopeSet.Includes(types.PULL_REQUESTS) {
|
||||
scopeSet.Add(types.BRANCHES, types.REMOTES)
|
||||
}
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
|
||||
scopeSet.Add(types.COMMITS, types.BRANCHES)
|
||||
}
|
||||
|
||||
// Capture the refs snapshot now, before we start reading git's state
|
||||
// below, rather than after. This is important to guard against the race
|
||||
|
|
@ -300,6 +322,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
})
|
||||
}
|
||||
|
||||
// The branches view shows worktrees against branches, so a branches render
|
||||
// that happens before the refreshed worktrees have landed in the model shows
|
||||
// stale ones, and rendering again once they land makes the view flicker.
|
||||
// Refresh the worktrees first, then, and let the branches refresh wait for
|
||||
// them: waitForWorktrees returns once the worktrees model write is queued,
|
||||
// so the branches write that follows is queued behind it and the view
|
||||
// renders once, with both.
|
||||
worktreesWg := sync.WaitGroup{}
|
||||
waitForWorktrees := func() { worktreesWg.Wait() }
|
||||
if scopeSet.Includes(types.WORKTREES) {
|
||||
worktreesWg.Add(1)
|
||||
refresh("worktrees", func() {
|
||||
defer worktreesWg.Done()
|
||||
self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES))
|
||||
})
|
||||
}
|
||||
|
||||
branchesAndRemotesWg := sync.WaitGroup{}
|
||||
// The pull-request fetch (below) needs the just-loaded branches and
|
||||
// remotes. Their model writes are bounced onto the UI thread, so the
|
||||
|
|
@ -309,32 +348,49 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// branchesAndRemotesWg gives the fetch the happens-before to read them.
|
||||
var loadedBranches []*models.Branch
|
||||
var loadedRemotes []*models.Remote
|
||||
includeWorktreesWithBranches := false
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
|
||||
// whenever we change commits, we should update branches because the upstream/downstream
|
||||
// counts can change. Whenever we change branches we should also change commits
|
||||
// e.g. in the case of switching branches.
|
||||
// Capture the commits, reflog and branches refresh inputs (model,
|
||||
// contexts, modes) on the UI thread, before the git work is dispatched
|
||||
// to a worker, so the workers compute from an immutable snapshot
|
||||
// instead of reading state the UI thread concurrently mutates.
|
||||
if scopeSet.Includes(types.COMMITS) {
|
||||
// Capture the refresh's inputs (model, contexts, modes) on the UI
|
||||
// thread, before the git work is dispatched to a worker, so the worker
|
||||
// computes from an immutable snapshot instead of reading state the UI
|
||||
// thread concurrently mutates. Every scope below does the same.
|
||||
var capturedCommits capturedCommitState
|
||||
var capturedReflog capturedReflogState
|
||||
var capturedBranches capturedBranchState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedCommits = self.captureCommitsState()
|
||||
capturedReflog = self.captureReflogState()
|
||||
capturedBranches = self.captureBranchState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("commits and commit files", func() {
|
||||
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
|
||||
})
|
||||
} else if scopeSet.Includes(types.REBASE_COMMITS) {
|
||||
// the commits refresh above loads the rebase commits as well, so we only
|
||||
// need this one when the rebase commits are all that was asked for
|
||||
var rebaseHashPool *utils.StringPool
|
||||
var rebaseCommits []*models.Commit
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.BRANCHES) {
|
||||
// The reflog is refreshed here rather than in a scope of its own,
|
||||
// because sorting the branches by recency needs it to be loaded first.
|
||||
var capturedReflog capturedReflogState
|
||||
var capturedBranches capturedBranchState
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedReflog = self.captureReflogState()
|
||||
capturedBranches = self.captureBranchState()
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
|
||||
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
|
||||
branchesAndRemotesWg.Add(1)
|
||||
refresh("reflog and branches", func() {
|
||||
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env)
|
||||
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env)
|
||||
branchesAndRemotesWg.Done()
|
||||
})
|
||||
} else {
|
||||
|
|
@ -343,47 +399,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// Not a recency sort, so branches doesn't depend on the reflog
|
||||
// being fresh; it runs concurrently with the reflog refresh
|
||||
// below and uses the reflog we captured up front, as it always has.
|
||||
loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env)
|
||||
loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env)
|
||||
branchesAndRemotesWg.Done()
|
||||
})
|
||||
refresh("reflog", func() {
|
||||
_, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit)
|
||||
})
|
||||
}
|
||||
} else if scopeSet.Includes(types.REBASE_COMMITS) {
|
||||
// the above block handles rebase commits so we only need to call this one
|
||||
// if we've asked specifically for rebase commits and not those other things
|
||||
var rebaseHashPool *utils.StringPool
|
||||
var rebaseCommits []*models.Commit
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
|
||||
})
|
||||
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.SUB_COMMITS) {
|
||||
var capturedSubCommits capturedSubCommitState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedSubCommits = self.captureSubCommitState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
|
||||
}
|
||||
|
||||
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
|
||||
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
|
||||
var capturedCommitFiles capturedCommitFilesState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedCommitFiles = self.captureCommitFilesState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
|
||||
}
|
||||
|
||||
fileWg := sync.WaitGroup{}
|
||||
if scopeSet.Includes(types.FILES) {
|
||||
var capturedFiles capturedFilesState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedFiles = self.captureFilesState()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
fileWg.Add(1)
|
||||
refresh("files", func() {
|
||||
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
|
||||
|
|
@ -393,9 +446,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
|
||||
if scopeSet.Includes(types.STASH) {
|
||||
var stashFilterPath string
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
stashFilterPath = self.c.Modes().Filtering.GetPath()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
|
||||
}
|
||||
|
||||
|
|
@ -408,9 +463,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
// needs it to keep the remote-branches selection valid, and reading
|
||||
// the Remotes context off the UI thread races its render.
|
||||
var prevSelectedRemote *models.Remote
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
|
||||
})
|
||||
}) {
|
||||
return
|
||||
}
|
||||
branchesAndRemotesWg.Add(1)
|
||||
refresh("remotes", func() {
|
||||
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
|
||||
|
|
@ -443,10 +500,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
|||
})
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
|
||||
refresh("worktrees", func() { self.refreshWorktrees(env) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.STAGING) {
|
||||
refresh("staging", func() {
|
||||
fileWg.Wait()
|
||||
|
|
@ -673,17 +726,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
|
||||
switch self.c.State().GetRepoState().GetStartupStage() {
|
||||
case types.INITIAL:
|
||||
// Return the immediate (non-recency) load's branches; the recency-sorted
|
||||
// reload below runs on its own worker after we return. Both hold the same
|
||||
// set of branches, which is all the caller (the PR fetch) needs.
|
||||
branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
|
||||
branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
|
||||
|
||||
self.onWorker(env.background, func(_ gocui.Task) error {
|
||||
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false)
|
||||
self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env)
|
||||
// The load above already waited for the worktrees, so this one has
|
||||
// nothing left to wait for.
|
||||
self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env)
|
||||
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
|
||||
return nil
|
||||
})
|
||||
|
|
@ -692,7 +747,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo
|
|||
|
||||
case types.COMPLETE:
|
||||
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit)
|
||||
return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env)
|
||||
return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -810,13 +865,16 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
|
|||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
var selectionRange *localCommitSelectionRange
|
||||
var newConflictedCommitIdx *int
|
||||
if commitSelection == types.KeepCommitSelectionByHash {
|
||||
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
|
||||
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
|
||||
newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits)
|
||||
}
|
||||
|
||||
self.c.Model().BisectInfo = bisectInfo
|
||||
self.c.Model().Commits = commits
|
||||
self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != ""
|
||||
self.RefreshAuthors(commits)
|
||||
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
|
||||
if checkedOutRef != nil {
|
||||
|
|
@ -825,33 +883,23 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
|
|||
self.c.Model().CheckedOutBranch = ""
|
||||
}
|
||||
|
||||
scrollSelectionIntoView := false
|
||||
switch commitSelection {
|
||||
case types.SelectHeadCommit:
|
||||
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
|
||||
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
|
||||
scrollSelectionIntoView = true
|
||||
}
|
||||
case types.KeepCommitSelectionByHash:
|
||||
if selectionRange != nil {
|
||||
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
|
||||
if newConflictedCommitIdx != nil {
|
||||
self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx)
|
||||
} else if selectionRange != nil {
|
||||
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange)
|
||||
if found {
|
||||
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
|
||||
scrollSelectionIntoView = didMove
|
||||
}
|
||||
}
|
||||
case types.KeepCommitSelectionIndex:
|
||||
// The caller set the selection index deliberately; leave it untouched.
|
||||
}
|
||||
|
||||
if scrollSelectionIntoView {
|
||||
// Enqueued from within this bounce so it runs after refreshView's
|
||||
// render below (which was enqueued first), matching the previous
|
||||
// ordering where FocusLine ran after the view was re-rendered.
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
self.refreshView(self.c.Contexts().LocalCommits, env)
|
||||
|
|
@ -863,8 +911,6 @@ type localCommitSelectionRange struct {
|
|||
selectedIsTODO bool
|
||||
rangeStartHash string
|
||||
rangeStartIsTODO bool
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
mode traits.RangeSelectMode
|
||||
}
|
||||
|
||||
|
|
@ -883,8 +929,6 @@ func captureLocalCommitSelectionRange(
|
|||
selectedIsTODO: commits[selectedIdx].IsTODO(),
|
||||
rangeStartHash: commits[rangeStartIdx].Hash(),
|
||||
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
|
@ -892,17 +936,16 @@ func captureLocalCommitSelectionRange(
|
|||
func findLocalCommitSelectionRange(
|
||||
commits []*models.Commit,
|
||||
selectionRange *localCommitSelectionRange,
|
||||
) (int, int, bool, bool) {
|
||||
) (int, int, bool) {
|
||||
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
|
||||
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
|
||||
if !foundSelected || !foundRangeStart {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
|
||||
return selectedIdx, rangeStartIdx, didMove, true
|
||||
return selectedIdx, rangeStartIdx, true
|
||||
}
|
||||
|
||||
// findCommitByHashPreferringTODOStatus finds the commit with the given hash.
|
||||
|
|
@ -933,6 +976,24 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
|
|||
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
|
||||
}
|
||||
|
||||
// Returns the index of the conflicted commit in the new commits slice, if there is one and it has a
|
||||
// different hash than the one before had (or there wasn't one before). Otherwise returns nil.
|
||||
func findNewConflictedCommit(previousCommits []*models.Commit, commits []*models.Commit) *int {
|
||||
previousConflictedCommit, _ := lo.Find(previousCommits, func(commit *models.Commit) bool {
|
||||
return commit.Status == models.StatusConflicted
|
||||
})
|
||||
|
||||
newConflictedCommit, idx, hasConflict := lo.FindIndexOf(commits, func(commit *models.Commit) bool {
|
||||
return commit.Status == models.StatusConflicted
|
||||
})
|
||||
|
||||
if hasConflict && (previousConflictedCommit == nil || previousConflictedCommit.Hash() != newConflictedCommit.Hash()) {
|
||||
return &idx
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// capturedSubCommitState holds the sub-commits refresh's model/context/mode
|
||||
// inputs, gathered on the UI thread (see captureSubCommitState) before the git
|
||||
// work is dispatched to a worker.
|
||||
|
|
@ -1073,7 +1134,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
|
|||
|
||||
// self.refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a State.Model.Branches array to pick the current branch from
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
loadSeq := self.branchLoadSeq.Add(1)
|
||||
|
||||
branches, err := env.git.Loaders.BranchLoader.Load(
|
||||
|
|
@ -1107,10 +1168,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
self.c.Log.Error(err)
|
||||
}
|
||||
|
||||
var worktrees []*models.Worktree
|
||||
if refreshWorktrees {
|
||||
worktrees = self.loadWorktrees(env)
|
||||
}
|
||||
// Render only once the refreshed worktrees are in the model; the branches
|
||||
// view shows them against the branches (see performRefresh).
|
||||
waitForWorktrees()
|
||||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
// Drop this write if a branch load that started later has already applied
|
||||
|
|
@ -1133,11 +1193,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
// the branches we just wrote, on the UI thread.
|
||||
self.rebuildPullRequestsMap()
|
||||
|
||||
if refreshWorktrees {
|
||||
self.c.Model().Worktrees = worktrees
|
||||
self.refreshView(self.c.Contexts().Worktrees, env)
|
||||
}
|
||||
|
||||
// Setting the selection here, in the same bounce that writes the list,
|
||||
// keeps it on the UI thread and keeps the list and selection updating in
|
||||
// the same frame.
|
||||
|
|
@ -1153,10 +1208,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
|
|||
}
|
||||
}
|
||||
case types.SelectCheckedOutBranch:
|
||||
// The checked-out branch is always at the top of the list. Setting
|
||||
// the selection doesn't scroll the view, so also reset the origin.
|
||||
// The checked-out branch is always at the top of the list.
|
||||
self.c.Contexts().Branches.SetSelectedLineIdx(0)
|
||||
self.c.Contexts().Branches.GetView().SetOriginY(0)
|
||||
}
|
||||
|
||||
// Need to re-render the commits view because the visualization of local
|
||||
|
|
@ -1247,21 +1300,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
|
|||
// waiting for a callback that only it can run), and capturing inline also
|
||||
// guarantees the snapshot reflects the state at the moment Refresh was called,
|
||||
// before the calling handler regains control and can mutate it.
|
||||
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
|
||||
//
|
||||
// It returns false when fn didn't run because the app is shutting down, in
|
||||
// which case the caller must abandon the refresh rather than compute from a
|
||||
// snapshot that was never taken.
|
||||
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool {
|
||||
if !calledFromWorker {
|
||||
fn()
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
wrapped := func() error {
|
||||
fn()
|
||||
return nil
|
||||
}
|
||||
if background {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped)
|
||||
} else {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
|
||||
return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil
|
||||
}
|
||||
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
|
||||
}
|
||||
|
||||
// capturedFilesState holds the files refresh's context/model inputs, gathered
|
||||
|
|
@ -1304,7 +1356,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
// process working directory, which may already point at another
|
||||
// repo if the user switched while this refresh was in flight.
|
||||
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 {
|
||||
self.c.Log.Error(err)
|
||||
} else if !hasConflicts {
|
||||
|
|
@ -1327,12 +1380,9 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
Background: env.backgroundRoutine,
|
||||
})
|
||||
|
||||
conflictFileCount := 0
|
||||
for _, file := range files {
|
||||
if file.HasMergeConflicts {
|
||||
conflictFileCount++
|
||||
}
|
||||
}
|
||||
conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) {
|
||||
return file.Path, file.HasMergeConflicts
|
||||
})
|
||||
|
||||
repoState := self.c.State().GetRepoState()
|
||||
workingTreeState := env.git.Status.WorkingTreeState()
|
||||
|
|
@ -1342,7 +1392,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
repoState.SetMergeOrRebaseStartedInLazygit(false)
|
||||
}
|
||||
|
||||
if workingTreeState.Any() && conflictFileCount == 0 {
|
||||
if workingTreeState.Any() && len(conflictedPaths) == 0 {
|
||||
if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() {
|
||||
// The conflicts of an operation we started have just been resolved
|
||||
// (e.g. in the user's editor). Offer to continue it. We only do this
|
||||
|
|
@ -1380,24 +1430,61 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
|
|||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
// only taking over the filter if it hasn't already been set by the user.
|
||||
if conflictFileCount > 0 && prevConflictFileCount == 0 {
|
||||
if len(conflictedPaths) > 0 && prevConflictFileCount == 0 {
|
||||
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
|
||||
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
|
||||
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
|
||||
}
|
||||
} else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.SetStatusFilter(filetree.DisplayAll)
|
||||
} else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll)
|
||||
self.c.Contexts().Files.GetView().Subtitle = ""
|
||||
}
|
||||
|
||||
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
|
||||
fileTreeViewModel.RememberConflictedPaths(conflictedPaths)
|
||||
}
|
||||
|
||||
self.c.Model().Submodules = submoduleConfigs
|
||||
self.c.Model().Files = files
|
||||
markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath())
|
||||
fileTreeViewModel.SetTree()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markWorktreeFiles marks the files that are linked worktrees of this repo, so
|
||||
// that the files view can render them as such. `git status` reports a worktree
|
||||
// as an untracked directory, i.e. with a trailing slash, which we take off:
|
||||
// keeping it would build a directory node with a nameless file inside it.
|
||||
//
|
||||
// It must run on the UI thread, as it works on the model. Both models it needs
|
||||
// are written by refreshes of their own, so it is called after either of them
|
||||
// lands; it reports whether it changed anything.
|
||||
func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool {
|
||||
changed := false
|
||||
|
||||
for _, file := range files {
|
||||
absPath := filepath.Join(worktreePath, file.Path)
|
||||
isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool {
|
||||
return worktree.Path == absPath
|
||||
})
|
||||
|
||||
if isWorktree != file.IsWorktree {
|
||||
file.IsWorktree = isWorktree
|
||||
changed = true
|
||||
}
|
||||
if isWorktree {
|
||||
if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path {
|
||||
file.Path = trimmed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// the reflogs panel is the only panel where we cache data, in that we only
|
||||
// load entries that have been created since we last ran the call. This means
|
||||
// we need to be more careful with how we use this, and to ensure we're emptying
|
||||
|
|
@ -1447,11 +1534,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
|
|||
self.c.Model().ReflogCommits = reflogCommits
|
||||
self.c.Model().FilteredReflogCommits = filteredReflogCommits
|
||||
// Setting the selection here, in the same bounce that writes the list,
|
||||
// keeps it on the UI thread and atomic with the list update. Setting the
|
||||
// selection doesn't scroll the view, so also reset the origin.
|
||||
// keeps it on the UI thread and atomic with the list update.
|
||||
if selectTopEntry {
|
||||
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
|
||||
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1501,16 +1586,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {
|
|||
return worktrees
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshWorktrees(env refreshEnv) {
|
||||
func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) {
|
||||
worktrees := self.loadWorktrees(env)
|
||||
|
||||
self.onUIThreadUnlessRepoChanged(env, func() {
|
||||
self.c.Model().Worktrees = worktrees
|
||||
|
||||
// A worktree inside our working tree is one of the files, so the files
|
||||
// view has to be told about the ones we just loaded (see
|
||||
// markWorktreeFiles). Rebuild the tree because a file's path can change.
|
||||
if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) {
|
||||
self.c.Contexts().Files.FileTreeViewModel.SetTree()
|
||||
self.refreshView(self.c.Contexts().Files, env)
|
||||
}
|
||||
})
|
||||
|
||||
// need to refresh branches because the branches view shows worktrees against
|
||||
// branches
|
||||
self.refreshView(self.c.Contexts().Branches, env)
|
||||
// The branches view shows worktrees against branches, so it needs to be
|
||||
// rendered again as well. When the branches are being refreshed too, they
|
||||
// render after waiting for the write above, so leave it to them.
|
||||
if !branchesAreRefreshing {
|
||||
self.refreshView(self.c.Contexts().Branches, env)
|
||||
}
|
||||
self.refreshView(self.c.Contexts().Worktrees, env)
|
||||
}
|
||||
|
||||
|
|
@ -1578,7 +1674,10 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
|
|||
// the filtered list model is up to date for rendering.
|
||||
self.searchHelper.ReApplyFilter(context)
|
||||
|
||||
self.c.PostRefreshUpdate(context)
|
||||
self.c.PostRefreshUpdateWithOptions(context, types.OnFocusOpts{
|
||||
KeepScrollPosition: env.keepScrollPosition,
|
||||
SkipMainViewUpdate: env.skipMainViewUpdate,
|
||||
})
|
||||
|
||||
self.c.AfterLayout(func() error {
|
||||
// Re-applying the search must be done after re-rendering the view though,
|
||||
|
|
@ -1754,7 +1853,11 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
|
|||
// the branches and remotes as they are on the UI thread, after their
|
||||
// own refreshes' bounces have applied.
|
||||
self.rebuildPullRequestsMap()
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
|
||||
// This lands whenever the network call happens to return, and only
|
||||
// changes how the branches are rendered, not which one is selected, so
|
||||
// it has no business moving the viewport.
|
||||
self.c.PostRefreshUpdateWithOptions(self.c.Contexts().Branches,
|
||||
types.OnFocusOpts{KeepScrollPosition: true})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
|
|
@ -28,8 +29,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: &localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "a",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 0,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
},
|
||||
},
|
||||
|
|
@ -74,15 +73,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
type expectation struct {
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
moved bool
|
||||
found bool
|
||||
}
|
||||
|
||||
selectionRange := localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "c",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 2,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +93,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -126,7 +121,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -139,7 +133,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
expected: expectation{
|
||||
selectedIdx: 0,
|
||||
rangeStartIdx: 1,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -147,11 +140,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
|
||||
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
|
||||
actual := expectation{
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
moved: moved,
|
||||
found: found,
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +152,62 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFindNewConflictedCommit(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
previousCommits []*models.Commit
|
||||
commits []*models.Commit
|
||||
expectedIdx *int
|
||||
}{
|
||||
{
|
||||
name: "finds a newly conflicted commit",
|
||||
previousCommits: makeCommits("a", "b"),
|
||||
commits: []*models.Commit{
|
||||
makeCommits("a")[0],
|
||||
makeConflictedCommit("b"),
|
||||
},
|
||||
expectedIdx: lo.ToPtr(1),
|
||||
},
|
||||
{
|
||||
name: "finds a different conflicted commit",
|
||||
previousCommits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
commits: []*models.Commit{
|
||||
makeConflictedCommit("b"),
|
||||
},
|
||||
expectedIdx: lo.ToPtr(0),
|
||||
},
|
||||
{
|
||||
name: "ignores the same conflicted commit",
|
||||
previousCommits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
commits: []*models.Commit{
|
||||
makeConflictedCommit("a"),
|
||||
},
|
||||
expectedIdx: nil,
|
||||
},
|
||||
{
|
||||
name: "reports not found when there is no conflict",
|
||||
previousCommits: makeCommits("a"),
|
||||
commits: makeCommits("a", "b"),
|
||||
expectedIdx: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits)
|
||||
|
||||
assert.Equal(t, testCase.expectedIdx != nil, idx != nil)
|
||||
if idx != nil {
|
||||
assert.Equal(t, *testCase.expectedIdx, *idx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGithubBaseRemote(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
@ -252,6 +300,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) {
|
|||
}, callsByHost)
|
||||
}
|
||||
|
||||
func TestMarkWorktreeFiles(t *testing.T) {
|
||||
worktreePath := filepath.Join("/", "path", "to", "repo")
|
||||
worktrees := []*models.Worktree{
|
||||
{Path: worktreePath},
|
||||
{Path: filepath.Join(worktreePath, "worktree1")},
|
||||
{Path: filepath.Join(worktreePath, "dir", "worktree2")},
|
||||
{Path: filepath.Join("/", "path", "to", "worktree3")},
|
||||
}
|
||||
|
||||
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
|
||||
files := []*models.File{
|
||||
{Path: "file"},
|
||||
{Path: "worktree1/"},
|
||||
{Path: "dir/worktree2/"},
|
||||
{Path: "dir/"},
|
||||
}
|
||||
|
||||
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
|
||||
assert.Equal(t, []*models.File{
|
||||
{Path: "file"},
|
||||
{Path: "worktree1", IsWorktree: true},
|
||||
{Path: "dir/worktree2", IsWorktree: true},
|
||||
{Path: "dir/"},
|
||||
}, files)
|
||||
})
|
||||
|
||||
t.Run("reports no change when there is nothing to mark", func(t *testing.T) {
|
||||
files := []*models.File{{Path: "file"}, {Path: "dir/"}}
|
||||
|
||||
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
|
||||
})
|
||||
|
||||
t.Run("unmarks a file whose worktree is gone", func(t *testing.T) {
|
||||
files := []*models.File{{Path: "worktree1", IsWorktree: true}}
|
||||
|
||||
assert.True(t, markWorktreeFiles(files, nil, worktreePath))
|
||||
assert.Equal(t, []*models.File{{Path: "worktree1"}}, files)
|
||||
})
|
||||
}
|
||||
|
||||
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
|
||||
return lo.Map(names, func(name string, _ int) githubRemoteInfo {
|
||||
return makeGithubRemoteInfo(name, name)
|
||||
|
|
@ -288,3 +376,7 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit {
|
|||
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
|
||||
}
|
||||
|
||||
func makeConflictedCommit(hash string) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.State().GetRepoPathStack().Push(wd)
|
||||
self.c.State().GetRepoPathStack().Push(types.RepoLocation{
|
||||
Path: wd,
|
||||
GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
|
||||
})
|
||||
|
||||
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
}
|
||||
|
|
@ -150,7 +153,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
|
|||
}
|
||||
})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.RecentRepos,
|
||||
Items: menuItems,
|
||||
FilterAsYouType: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SwitchToParentRepo switches back to the repo the current submodule was
|
||||
|
|
@ -164,7 +171,7 @@ func (self *ReposHelper) SwitchToParentRepo() error {
|
|||
if self.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
}
|
||||
|
||||
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
||||
|
|
@ -189,23 +196,41 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// switchTo switches lazygit to the repository (or worktree) at the given path.
|
||||
// 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.
|
||||
// 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
|
||||
// we switch to without having been there before.
|
||||
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
||||
env.UnsetGitLocationEnvVars()
|
||||
return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey)
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
originalGitLocationEnvVars := env.GetGitLocationEnvVars()
|
||||
|
||||
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
|
||||
env.SetGitLocationEnvVars(location.GitLocationEnvVars)
|
||||
|
||||
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
|
||||
self.c.LogCommand(msg, false)
|
||||
|
||||
if err := os.Chdir(path); err != nil {
|
||||
if err := os.Chdir(location.Path); err != nil {
|
||||
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
|
@ -213,6 +238,7 @@ func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.C
|
|||
}
|
||||
|
||||
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
|
||||
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
|
||||
if err := os.Chdir(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ func NewSearchHelper(
|
|||
}
|
||||
}
|
||||
|
||||
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
|
||||
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) {
|
||||
state := self.searchState()
|
||||
|
||||
state.PrevSearchIndex = -1
|
||||
|
||||
state.Context = context
|
||||
|
||||
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
|
||||
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
||||
promptView := self.promptView()
|
||||
promptView.ClearTextArea()
|
||||
self.OnPromptContentChanged("")
|
||||
|
|
@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err
|
|||
|
||||
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
|
||||
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) {
|
||||
state := self.searchState()
|
||||
|
||||
state.PrevSearchIndex = -1
|
||||
|
|
@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) err
|
|||
|
||||
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
|
||||
|
|
@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext)
|
|||
state.Context = context
|
||||
searchString := context.GetFilter()
|
||||
|
||||
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
|
||||
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
||||
|
||||
promptView := self.promptView()
|
||||
keybindingConfig := self.c.UserConfig().Keybinding
|
||||
|
|
@ -103,10 +103,11 @@ func (self *SearchHelper) promptContent() string {
|
|||
return self.c.Contexts().Search.GetView().TextArea.GetContent()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) Confirm() error {
|
||||
func (self *SearchHelper) Confirm() {
|
||||
state := self.searchState()
|
||||
if self.promptContent() == "" {
|
||||
return self.CancelPrompt()
|
||||
self.CancelPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
switch state.SearchType() {
|
||||
|
|
@ -118,7 +119,7 @@ func (self *SearchHelper) Confirm() error {
|
|||
self.c.Context().Pop()
|
||||
}
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ConfirmFilter() {
|
||||
|
|
@ -175,12 +176,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition
|
|||
return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
|
||||
}
|
||||
|
||||
func (self *SearchHelper) CancelPrompt() error {
|
||||
func (self *SearchHelper) CancelPrompt() {
|
||||
self.Cancel()
|
||||
|
||||
self.c.Context().Pop()
|
||||
|
||||
return self.c.ResetKeybindings()
|
||||
self.c.ResetKeybindings()
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
|
||||
|
|
@ -224,10 +225,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
|
|||
state := self.searchState()
|
||||
switch context := state.Context.(type) {
|
||||
case types.IFilterableContext:
|
||||
context.SetSelection(0)
|
||||
context.GetView().SetOriginY(0)
|
||||
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
self.c.PostRefreshUpdate(context)
|
||||
self.ApplyFilter(context, searchString)
|
||||
case types.ISearchableContext:
|
||||
// do nothing
|
||||
default:
|
||||
|
|
@ -235,12 +233,21 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ApplyFilter(context types.IFilterableContext, filter string) {
|
||||
context.SetSelection(0)
|
||||
context.SetFilter(filter, self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
self.c.PostRefreshUpdate(context)
|
||||
}
|
||||
|
||||
func (self *SearchHelper) ReApplyFilter(context types.Context) {
|
||||
filterableContext, ok := context.(types.IFilterableContext)
|
||||
if ok {
|
||||
state := self.searchState()
|
||||
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
|
||||
filterableContext.SetSelection(0)
|
||||
// This runs as part of a refresh, and a refresh that no user action
|
||||
// is behind keeps the scroll position, which would leave the view
|
||||
// scrolled somewhere the filtered list no longer has anything at.
|
||||
filterableContext.GetView().SetOriginY(0)
|
||||
}
|
||||
filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch())
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
|
|||
subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix
|
||||
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
|
||||
subCommitsContext.FocusLine(true)
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
|
|||
repoState := self.c.State().GetRepoState()
|
||||
|
||||
var searchPrefix string
|
||||
if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
|
||||
searchPrefix = filterableContext.FilterPrefix(self.c.Tr)
|
||||
if _, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
|
||||
searchPrefix = self.c.Tr.FilterPrefix
|
||||
} else {
|
||||
searchPrefix = self.c.Tr.SearchPrefix
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error {
|
|||
self.context.SetNeedRerenderVisibleLines()
|
||||
}
|
||||
|
||||
self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
} else {
|
||||
// If the selection did not change (because, for example, we are at the top of the list and
|
||||
// press up), we still want to ensure that the selection is visible. This is useful after
|
||||
|
|
@ -205,9 +205,10 @@ func (self *ListController) handlePageChange(delta int) error {
|
|||
// must tell it explicitly to rerender.
|
||||
self.context.SetNeedRerenderVisibleLines()
|
||||
|
||||
// Since we are maintaining the scroll position ourselves above, there's no point in passing
|
||||
// ScrollSelectionIntoView=true here.
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
// This function scrolls the view itself, keeping the selection at the edge of
|
||||
// the viewport rather than in its middle, so the scroll position is ours to
|
||||
// maintain, not the focus mechanism's.
|
||||
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -280,7 +281,10 @@ func (self *ListController) selectRangeThroughViewIndex(viewIndex int) {
|
|||
newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex)
|
||||
list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx())
|
||||
|
||||
self.context.HandleFocus(types.OnFocusOpts{})
|
||||
// The pointer can be outside the viewport, in which case so is the end of
|
||||
// the range; the drag autoscroller takes care of following it, one line at a
|
||||
// time, for as long as the pointer stays there.
|
||||
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
|
||||
}
|
||||
|
||||
func (self *ListController) handleDragAutoscroll(viewIndex int) bool {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding
|
|||
|
||||
self.commitDrag.hasMoved = true
|
||||
if self.updateCommitDragInsertion(opts.Y) {
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
self.c.PostRefreshUpdateWithOptions(self.context(),
|
||||
types.OnFocusOpts{KeepScrollPosition: true})
|
||||
}
|
||||
originY := self.context().GetView().OriginY()
|
||||
self.dragAutoscroller.Update(opts.Y - originY)
|
||||
|
|
@ -344,7 +345,9 @@ func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex i
|
|||
func (self *LocalCommitsController) stopMovingCommitsIndicator() {
|
||||
self.stopMovingCommitsIndicatorTicker()
|
||||
self.context().ClearDropInsertionIndex()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
self.c.PostRefreshUpdateWithOptions(
|
||||
self.context(), types.OnFocusOpts{SkipMainViewUpdate: true},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() {
|
||||
|
|
@ -743,7 +746,10 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
|
|||
HandleConfirm: func() error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.SquashingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
|
||||
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
|
||||
})
|
||||
|
|
@ -767,7 +773,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
|
|||
OnPress: func() error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.FixingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
|
||||
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
|
||||
})
|
||||
|
|
@ -780,7 +789,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
|
|||
OnPress: func() error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.FixingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
|
||||
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
|
||||
})
|
||||
|
|
@ -891,7 +903,10 @@ func (self *LocalCommitsController) handleReword(summary string, description str
|
|||
self.c.Tr.RewordingStatus, nil, nil)
|
||||
}
|
||||
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RewordingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -977,7 +992,10 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
|
|||
if !isMerge {
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
}
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.DroppingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DropCommit)
|
||||
if isMerge {
|
||||
return self.dropMergeCommit(commits, startIdx)
|
||||
|
|
@ -1002,7 +1020,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
|
|||
|
||||
commits := self.c.Model().Commits
|
||||
if !commits[endIdx].IsMerge() {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{BatchUIUpdates: true})
|
||||
|
|
@ -1024,7 +1045,10 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error {
|
|||
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
|
||||
commitsToEdit []*models.Commit,
|
||||
) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.EditCommit)
|
||||
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
|
|
@ -1149,22 +1173,30 @@ func (self *LocalCommitsController) move(
|
|||
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
|
||||
// Block input until the refresh has landed: a quick second press must
|
||||
// read the moved todo from the refreshed model, not grab whatever the
|
||||
// advanced selection index points at in the stale one.
|
||||
self.c.RefreshBlockingInput(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
Then: onComplete,
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
SkipMainViewUpdate: true,
|
||||
Then: func() error {
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().FocusLine(true)
|
||||
if onComplete != nil {
|
||||
return onComplete()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
commits := self.c.Model().Commits
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.MovingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
if offset > 0 {
|
||||
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
|
||||
} else {
|
||||
|
|
@ -1180,7 +1212,7 @@ func (self *LocalCommitsController) move(
|
|||
Then: func() error {
|
||||
if err == nil {
|
||||
self.context().MoveSelection(offset)
|
||||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
self.context().HandleFocus(types.OnFocusOpts{})
|
||||
}
|
||||
if onComplete != nil {
|
||||
return onComplete()
|
||||
|
|
@ -1209,7 +1241,10 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
|
|||
selectedIdx := self.context().GetView().SelectedLineIdx()
|
||||
handleCommit = func() error {
|
||||
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.AmendingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
|
||||
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
|
|
@ -1271,7 +1306,10 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en
|
|||
}
|
||||
|
||||
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.AmendingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
|
||||
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
|
||||
return err
|
||||
|
|
@ -1287,7 +1325,10 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e
|
|||
Title: self.c.Tr.SetAuthorPromptTitle,
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
|
||||
HandleConfirm: func(value string) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.AmendingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
|
||||
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
|
||||
return err
|
||||
|
|
@ -1307,7 +1348,10 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start,
|
|||
Title: self.c.Tr.AddCoAuthorPromptTitle,
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
|
||||
HandleConfirm: func(value string) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.AmendingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
|
||||
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
|
||||
return err
|
||||
|
|
@ -1341,7 +1385,10 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
|
|||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
|
||||
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RevertingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
if mustStash {
|
||||
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
|
||||
return err
|
||||
|
|
@ -1392,7 +1439,10 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
|
|||
selectedIdx := self.context().GetSelectedLineIdx()
|
||||
commits := self.c.Model().Commits
|
||||
branches := self.c.Model().Branches
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.CreatingFixupCommitStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1500,7 +1550,10 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
|
|||
selectedIdx := self.context().GetSelectedLineIdx()
|
||||
commits := self.c.Model().Commits
|
||||
branches := self.c.Model().Branches
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.CreatingFixupCommitStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1561,7 +1614,10 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba
|
|||
// up by that many rows to stay on the same commit. Compute the target as an
|
||||
// absolute index now, on the current list.
|
||||
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.SquashingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
|
||||
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
|
|
@ -1628,7 +1684,8 @@ func (self *LocalCommitsController) openSearch() error {
|
|||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
|
||||
}
|
||||
|
||||
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
|
||||
self.c.Helpers().Search.OpenSearchPrompt(self.context())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) handleOpenLogMenu() error {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ func (self *MainViewController) openSearch() error {
|
|||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MenuController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*types.MenuItem]
|
||||
c *ControllerCommon
|
||||
// for delegating navigation to, see physicalKeyBindings
|
||||
listController *ListController
|
||||
}
|
||||
|
||||
var _ types.IController = &MenuController{}
|
||||
|
||||
func NewMenuController(
|
||||
c *ControllerCommon,
|
||||
listController *ListController,
|
||||
) *MenuController {
|
||||
return &MenuController{
|
||||
baseController: baseController{},
|
||||
|
|
@ -24,12 +30,13 @@ func NewMenuController(
|
|||
c.Contexts().Menu.GetSelected,
|
||||
c.Contexts().Menu.GetSelectedItems,
|
||||
),
|
||||
c: c,
|
||||
c: c,
|
||||
listController: listController,
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: if you add a new keybinding here, you'll also need to add it to
|
||||
// `reservedKeys` in `pkg/gui/context/menu_context.go`
|
||||
// `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it
|
||||
func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{
|
||||
{
|
||||
|
|
@ -52,6 +59,51 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
|
|||
},
|
||||
}
|
||||
|
||||
if self.context().FilterAsYouType() {
|
||||
bindings = append(bindings, self.physicalKeyBindings(opts)...)
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
// In a menu that filters as you type, the keys configured for driving the menu
|
||||
// may all be printable, and printable keys become filter text once the user
|
||||
// starts typing. These keys can't, so binding them on top guarantees that the
|
||||
// menu stays usable no matter how the keybindings are configured.
|
||||
func (self *MenuController) physicalKeyBindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
candidates := []struct {
|
||||
key gocui.Key
|
||||
configured config.Keybinding
|
||||
binding *types.Binding
|
||||
}{
|
||||
{
|
||||
key: gocui.NewKeyName(gocui.KeyEnter),
|
||||
configured: opts.Config.Universal.ConfirmMenu,
|
||||
binding: &types.Binding{
|
||||
Handler: self.withItem(self.press),
|
||||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||||
},
|
||||
},
|
||||
{gocui.NewKeyName(gocui.KeyEsc), opts.Config.Universal.Return, &types.Binding{Handler: self.close}},
|
||||
{gocui.NewKeyName(gocui.KeyArrowUp), opts.Config.Universal.PrevItem, &types.Binding{Handler: self.listController.HandlePrevLine}},
|
||||
{gocui.NewKeyName(gocui.KeyArrowDown), opts.Config.Universal.NextItem, &types.Binding{Handler: self.listController.HandleNextLine}},
|
||||
{gocui.NewKeyName(gocui.KeyPgup), opts.Config.Universal.PrevPage, &types.Binding{Handler: self.listController.HandlePrevPage}},
|
||||
{gocui.NewKeyName(gocui.KeyPgdn), opts.Config.Universal.NextPage, &types.Binding{Handler: self.listController.HandleNextPage}},
|
||||
{gocui.NewKeyName(gocui.KeyHome), opts.Config.Universal.GotoTop, &types.Binding{Handler: self.listController.HandleGotoTop}},
|
||||
{gocui.NewKeyName(gocui.KeyEnd), opts.Config.Universal.GotoBottom, &types.Binding{Handler: self.listController.HandleGotoBottom}},
|
||||
}
|
||||
|
||||
bindings := []*types.Binding{}
|
||||
for _, candidate := range candidates {
|
||||
if lo.Contains(opts.GetKeys(candidate.configured), candidate.key) {
|
||||
// this key is the configured one, so it drives the menu already
|
||||
continue
|
||||
}
|
||||
|
||||
candidate.binding.Keys = []gocui.Key{candidate.key}
|
||||
bindings = append(bindings, candidate.binding)
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +125,11 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
|
|||
}
|
||||
|
||||
func (self *MenuController) close() error {
|
||||
if self.context().FilterStarted() {
|
||||
self.stopFiltering()
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.context().IsFiltering() {
|
||||
self.c.Helpers().Search.Cancel()
|
||||
return nil
|
||||
|
|
@ -81,6 +138,17 @@ func (self *MenuController) close() error {
|
|||
return self.context().OnMenuPress(nil)
|
||||
}
|
||||
|
||||
// Hides the filter row again and puts the menu back the way it was, keeping the
|
||||
// item that was selected. It takes another escape to close the menu.
|
||||
func (self *MenuController) stopFiltering() {
|
||||
self.c.Views().MenuFilter.ClearTextArea()
|
||||
self.c.Views().MenuFilter.RenderTextArea()
|
||||
|
||||
self.context().SetFilterStarted(false)
|
||||
self.context().ClearFilter()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
}
|
||||
|
||||
func (self *MenuController) context() *context.MenuContext {
|
||||
return self.c.Contexts().Menu
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error {
|
|||
ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft},
|
||||
AllowFilteringKeybindings: true,
|
||||
KeepConflictingKeybindings: true,
|
||||
FilterAsYouType: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -230,9 +230,8 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
|
|||
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
||||
// Escape pops the patch-building context, so run it on the UI thread
|
||||
// before the refresh below.
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
|
||||
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
|
||||
self.c.Helpers().PatchBuilding.Escape()
|
||||
return nil
|
||||
})
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue