mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-03 20:46:47 -04:00
Compare commits
158
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"/Users/stk/Stk/Dev/Builds/delta",
|
||||
"/Users/stk/Stk/Dev/Builds/difftastic"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,16 @@ 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.
|
||||
|
||||
**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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,12 +371,6 @@ git:
|
||||
# # renderer command.
|
||||
# name: ""
|
||||
#
|
||||
# # If true, selected diff lines are highlighted only with a narrow bar
|
||||
# # at the left edge of the view, rather than across the whole width.
|
||||
# # Useful for pagers whose own line background colors should remain
|
||||
# # visible.
|
||||
# narrowSelectionHighlight: false
|
||||
#
|
||||
# # Value of the --color arg in the git diff command. Only used for type
|
||||
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
# # want it set to 'never'.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -224,15 +224,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
|
||||
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Main panel (patch building)
|
||||
@@ -338,15 +329,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
|
||||
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -193,15 +193,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## タグ
|
||||
@@ -315,15 +306,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## メニュー
|
||||
|
||||
@@ -85,15 +85,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
|
||||
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## Stash
|
||||
@@ -172,15 +163,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
|
||||
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## 메인 패널 (Patch Building)
|
||||
|
||||
@@ -232,15 +232,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Patch bouwen
|
||||
@@ -289,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. |
|
||||
@@ -304,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. |
|
||||
@@ -316,15 +307,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Staging
|
||||
|
||||
@@ -100,15 +100,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
|
||||
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Drzewa pracy
|
||||
@@ -211,15 +202,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
|
||||
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Panel główny (scalanie)
|
||||
|
||||
@@ -236,15 +236,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
|
||||
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Painel Principal (preparação)
|
||||
@@ -347,15 +338,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
|
||||
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -75,15 +75,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Индексирование)
|
||||
@@ -116,15 +107,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Слияние)
|
||||
|
||||
@@ -287,15 +287,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 正在合并
|
||||
@@ -344,15 +335,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 状态
|
||||
|
||||
@@ -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,17 +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 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
@@ -99,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> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
@@ -117,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> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 功能表
|
||||
@@ -144,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> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -165,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 `` | 查看批量子模組選項 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -178,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」提交? |
|
||||
@@ -207,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> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -240,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> `` | 檢視所選項目的檔案 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -271,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> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -295,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 `` | 排序規則 | |
|
||||
@@ -314,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> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -322,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> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -339,49 +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 | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
@@ -391,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 `` | 聚焦主檢視 | |
|
||||
|
||||
## 確認面板
|
||||
|
||||
@@ -403,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 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 遠端分支
|
||||
@@ -420,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
|
||||
|
||||
+5
-1
@@ -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> `` | 檢視提交 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -137,9 +137,6 @@ func NewGitCommandAux(
|
||||
patchBuilder := patch.NewPatchBuilder(cmn.Log,
|
||||
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
|
||||
},
|
||||
func() (string, error) {
|
||||
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
|
||||
})
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
@@ -240,10 +240,10 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
|
||||
return self.cmd.New(cmdArgs)
|
||||
}
|
||||
|
||||
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
|
||||
cmdArgs := NewGitCmd("show").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("--submodule").
|
||||
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
|
||||
Arg("--stat").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
}
|
||||
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
|
||||
|
||||
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, false).Run())
|
||||
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
|
||||
runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/mgutz/str"
|
||||
)
|
||||
|
||||
type DiffCommands struct {
|
||||
@@ -19,118 +16,13 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
|
||||
}
|
||||
}
|
||||
|
||||
// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first
|
||||
// output, to announce that it speaks the diff-line-metadata protocol: a version-only
|
||||
// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's
|
||||
// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake.
|
||||
const metadataHandshake = "\x1b]1717"
|
||||
|
||||
// ProbePagerEmitsDiffMetadata reports whether the configured diff renderer speaks the
|
||||
// diff-line-metadata protocol, by running it on empty input and checking for its
|
||||
// handshake. It's the focused main view's signal for whether it can act on the rendered
|
||||
// diff or must fall back to the raw diff (see
|
||||
// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent — the
|
||||
// handshake is the renderer's first output regardless of the diff — so the caller caches
|
||||
// it per renderer.
|
||||
//
|
||||
// No PTY is needed: git needs a terminal to decide to invoke a pager, but a renderer
|
||||
// emits the handshake whenever OSC1717 is set, so we can run it directly with empty
|
||||
// input.
|
||||
//
|
||||
// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via
|
||||
// .gitattributes and a single diff can mix drivers, so there's no one renderer to probe;
|
||||
// we conservatively report false (the focused main view then always renders raw).
|
||||
func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool {
|
||||
if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" {
|
||||
return self.externalDiffEmitsMetadata(extDiffCmd)
|
||||
}
|
||||
if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" {
|
||||
return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, ""))
|
||||
}
|
||||
if rawGitArgs := self.diffRendererConfigManager.GetRawGitArgs(); len(rawGitArgs) > 0 {
|
||||
return self.rawGitEmitsMetadata(rawGitArgs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rawGitEmitsMetadata probes git itself, run with the diff renderer's own arguments.
|
||||
//
|
||||
// The arguments are the point: git describes only the formats whose output can't be read
|
||||
// back from its own text, and announces itself for exactly those, so the same git answers
|
||||
// differently depending on what it is asked for — a handshake for --color-words, silence
|
||||
// for a unified diff.
|
||||
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
|
||||
oldPath, newPath, cleanup, ok := probeFiles()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return self.probeEmitsMetadata(self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Arg("--no-index").
|
||||
Arg(rawGitArgs...).
|
||||
Arg(oldPath, newPath).
|
||||
ToArgv(),
|
||||
))
|
||||
}
|
||||
|
||||
// externalDiffEmitsMetadata probes an external diff command, invoking it the way git
|
||||
// invokes a diff.external driver — with 7 positional args
|
||||
// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp
|
||||
// files, so it emits its handshake without there being a real diff to render.
|
||||
func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool {
|
||||
oldPath, newPath, cleanup, ok := probeFiles()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
args := append(str.ToArgv(extDiffCmd),
|
||||
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
|
||||
return self.probeEmitsMetadata(self.cmd.New(args))
|
||||
}
|
||||
|
||||
// probeFiles creates the two empty temp files a probe stands a diff up from, and a
|
||||
// cleanup that removes them. Empty because a probe needs the renderer to announce
|
||||
// itself, not to have anything to render.
|
||||
func probeFiles() (string, string, func(), bool) {
|
||||
oldFile, err := os.CreateTemp("", "lazygit-probe-old-*")
|
||||
if err != nil {
|
||||
return "", "", nil, false
|
||||
}
|
||||
oldFile.Close()
|
||||
|
||||
newFile, err := os.CreateTemp("", "lazygit-probe-new-*")
|
||||
if err != nil {
|
||||
os.Remove(oldFile.Name())
|
||||
return "", "", nil, false
|
||||
}
|
||||
newFile.Close()
|
||||
|
||||
return oldFile.Name(), newFile.Name(), func() {
|
||||
os.Remove(oldFile.Name())
|
||||
os.Remove(newFile.Name())
|
||||
}, true
|
||||
}
|
||||
|
||||
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
|
||||
cmdObj.AddEnvVars("OSC1717=V1")
|
||||
// The pager may exit non-zero on the synthetic input; we only care about whether
|
||||
// it emitted the handshake first, and the output is captured either way.
|
||||
output, _ := cmdObj.RunWithOutput()
|
||||
return strings.Contains(output, metadataHandshake)
|
||||
}
|
||||
|
||||
// This is for generating diffs to be shown in the UI (e.g. rendering a range
|
||||
// diff to the main view). It uses a custom diff renderer if one is configured, unless
|
||||
// ignoreExternalDiff is set (the focused main view's raw-diff fallback; keeps the
|
||||
// colour, unlike a plain diff).
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
// diff to the main view). It uses a custom diff renderer if one is configured.
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(diffArgs...).
|
||||
@@ -139,27 +31,6 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string, ignoreExternalDiff bool)
|
||||
)
|
||||
}
|
||||
|
||||
// CustomPatchDiffCmdObj builds the command that renders the custom patch shown in the
|
||||
// secondary pane: a `git diff --no-index` of the two file trees PatchCommands materialized
|
||||
// under dir (a/ = before, b/ = after; see WriteCustomPatchDiffTrees). It uses the same pager
|
||||
// wiring as DiffCmdObj so the patch renders exactly like any other diff — through a stdin
|
||||
// pager, an external diff tool, or (when ignoreExternalDiff is set, the focused main view's
|
||||
// raw-diff fallback) git's own colour. --no-prefix is used because the a/ and b/ tree names
|
||||
// already stand in for git's conventional a//b/ path prefixes, so the diff's paths come out
|
||||
// as the real repo-relative paths.
|
||||
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
Arg("--no-index").
|
||||
Arg("--no-prefix").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg("a", "b").
|
||||
Dir(dir).
|
||||
ToArgv(),
|
||||
)
|
||||
}
|
||||
|
||||
// This is a basic generic diff command that can be used for any diff operation
|
||||
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
|
||||
// and does not use user configs such as ignore whitespace.
|
||||
|
||||
@@ -2,7 +2,6 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -91,26 +90,6 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
||||
|
||||
self.setConflictMarkerSizes(files)
|
||||
|
||||
// Go through the files to see if any of these files are actually worktrees
|
||||
// so that we can render them correctly
|
||||
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
|
||||
for _, file := range files {
|
||||
for _, worktreePath := range worktreePaths {
|
||||
absFilePath, err := filepath.Abs(file.Path)
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
continue
|
||||
}
|
||||
if absFilePath == worktreePath {
|
||||
file.IsWorktree = true
|
||||
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
|
||||
// If we include the slash, it will be rendered as a folder with a null file inside.
|
||||
file.Path = strings.TrimSuffix(file.Path, "/")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
||||
return queryString, variables
|
||||
}
|
||||
|
||||
// GetAuthToken returns the token to authenticate against the given host with,
|
||||
// or an empty string if there is none.
|
||||
//
|
||||
// The token has to come from gh itself rather than from an in-process lookup
|
||||
// with go-gh: that reads gh's config file once per process and answers from
|
||||
// that snapshot ever after, whereas gh rewrites the file whenever the active
|
||||
// account changes, and keeps the active account's token either there or in the
|
||||
// system keyring. Under a long-running lazygit the snapshot therefore drifts
|
||||
// out of date, leaving us with a token for an account that is no longer active,
|
||||
// or with no token at all.
|
||||
func (self *GitHubCommands) GetAuthToken(host string) string {
|
||||
token, _ := auth.TokenForHost(host)
|
||||
return token
|
||||
ghExe := ghExecutable()
|
||||
if ghExe == "" {
|
||||
// Without gh installed, the environment variables and config file that
|
||||
// gh would have consulted are still worth a look.
|
||||
token, _ := auth.TokenFromEnvOrConfig(host)
|
||||
return token
|
||||
}
|
||||
|
||||
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
|
||||
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
// Not being logged in to this host is a normal state rather than
|
||||
// something to report; the runner logs gh's stderr for the rest.
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
// ghExecutable returns the path of the gh binary, or an empty string if it
|
||||
// isn't installed.
|
||||
func ghExecutable() string {
|
||||
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// A gh found in the current directory rather than on PATH comes back as
|
||||
// exec.ErrDot, which we treat as not having found one at all.
|
||||
ghExe, err := exec.LookPath("gh")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
|
||||
|
||||
@@ -2,9 +2,7 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -22,11 +20,6 @@ type PatchCommands struct {
|
||||
stash *StashCommands
|
||||
|
||||
PatchBuilder *patch.PatchBuilder
|
||||
|
||||
// lastBuiltTreeGeneration is the patch builder generation the diff trees were last
|
||||
// materialized for, so EnsureCustomPatchDiffTrees rebuilds them only when the patch
|
||||
// actually changed (not on every render / navigation).
|
||||
lastBuiltTreeGeneration int
|
||||
}
|
||||
|
||||
func NewPatchCommands(
|
||||
@@ -47,91 +40,6 @@ func NewPatchCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCustomPatchDiffTrees materializes the custom patch's diff trees if they're stale —
|
||||
// i.e. the patch changed since they were last built. Called before rendering the secondary
|
||||
// pane, so a mere re-render (e.g. navigating commits, which doesn't change the patch) reuses
|
||||
// the existing trees, while a toggle/removal/whole-file change rebuilds them. This is what
|
||||
// keeps the trees current across every path that mutates the patch.
|
||||
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
|
||||
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
|
||||
return nil
|
||||
}
|
||||
if err := self.WriteCustomPatchDiffTrees(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteCustomPatchDiffTrees materializes the custom patch under the patch builder's temp
|
||||
// dir as two file trees — a/ holds each patched file's "from"-side content, b/ holds that
|
||||
// content with the patch applied — so the patch can be re-diffed with `git diff --no-index`
|
||||
// and rendered through any pager (see DiffCommands.CustomPatchDiffCmdObj). This is what lets
|
||||
// a partial, in-memory custom patch be shown the same way as any other diff; the in-memory
|
||||
// aggregated patch on its own could only be fed to a stdin pager, never an external diff
|
||||
// tool. The dirs are named a/b so that, with `--no-prefix`, the diff's paths come out as the
|
||||
// real repo-relative paths (git's conventional a//b/ prefixes).
|
||||
//
|
||||
// Called whenever the patch's contents change. The temp dir's lifetime is owned by the
|
||||
// patch builder (created on Start, removed on Reset), so there's nothing to clean up here
|
||||
// beyond wiping the trees before rebuilding them.
|
||||
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
|
||||
dir := self.PatchBuilder.TempDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
aDir := filepath.Join(dir, "a")
|
||||
bDir := filepath.Join(dir, "b")
|
||||
for _, d := range []string{aDir, bDir} {
|
||||
if err := os.RemoveAll(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, filename := range self.PatchBuilder.ActiveFilenames() {
|
||||
// The "from"-side content; the patch applied below turns b/ into the "after" side
|
||||
// while a/ stays the "before".
|
||||
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, filename).RunWithOutput()
|
||||
added := err != nil // absent on the "from" side — a file the patch adds
|
||||
|
||||
// a/ always holds the "before" content — empty for an added file, so the rendered
|
||||
// diff still pairs it with b/ and shows the real a//b/ paths (rather than git's
|
||||
// directory-comparison "added in b" form, which mangles the header).
|
||||
beforeContent := content
|
||||
if added {
|
||||
beforeContent = ""
|
||||
}
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(aDir, filename), beforeContent); err != nil {
|
||||
return err
|
||||
}
|
||||
// b/ is seeded only for existing files (which the patch modifies in place); an added
|
||||
// file is left absent so the patch — rendered as a /dev/null creation — creates it.
|
||||
if !added {
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render added files as /dev/null creations (the natural form), not as diffs against an
|
||||
// empty file: the latter (--- a/file) would make the atomic `git apply` expect them to
|
||||
// already exist in b/, where they don't.
|
||||
patchText := self.PatchBuilder.PatchToApply(false, false)
|
||||
if strings.TrimSpace(patchText) == "" {
|
||||
// An empty patch leaves a/ and b/ identical (or empty), so the diff is empty.
|
||||
return nil
|
||||
}
|
||||
patchFilePath, err := self.SaveTemporaryPatch(patchText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).ToArgv()).Run()
|
||||
}
|
||||
|
||||
type ApplyPatchOpts struct {
|
||||
ThreeWay bool
|
||||
Cached bool
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
ioFs "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RepoPaths struct {
|
||||
@@ -302,41 +300,3 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
|
||||
}
|
||||
return strings.TrimSpace(res), nil
|
||||
}
|
||||
|
||||
// Returns the paths of linked worktrees
|
||||
func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string {
|
||||
result := []string{}
|
||||
// For each directory in this path we're going to cat the `gitdir` file and append its contents to our result
|
||||
// That file points us to the `.git` file in the worktree.
|
||||
worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees")
|
||||
|
||||
// ensure the directory exists
|
||||
_, err := fs.Stat(worktreeGitDirsPath)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
_ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitDirPath := filepath.Join(currPath, "gitdir")
|
||||
gitDirBytes, err := afero.ReadFile(fs, gitDirPath)
|
||||
if err != nil {
|
||||
// ignoring error
|
||||
return nil
|
||||
}
|
||||
trimmedGitDir := strings.TrimSpace(string(gitDirBytes))
|
||||
// removing the .git part
|
||||
worktreeDir := filepath.Dir(trimmedGitDir)
|
||||
result = append(result, worktreeDir)
|
||||
return nil
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -80,10 +80,10 @@ func (self *StashCommands) Hash(index int) (string, error) {
|
||||
return strings.Trim(hash, "\r\n"), err
|
||||
}
|
||||
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
|
||||
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
|
||||
cmdArgs := NewGitCmd("stash").Arg("show").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("-p").
|
||||
Arg("--stat").
|
||||
Arg("-u").
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
}
|
||||
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
|
||||
|
||||
cmdStr := instance.ShowStashEntryCmdObj(s.index, false).Args()
|
||||
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
|
||||
assert.Equal(t, s.expected, cmdStr)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,32 +385,24 @@ 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, false, 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).
|
||||
// ignoreExternalDiff forces git's own (coloured) diff regardless of a configured
|
||||
// external diff command, for the focused main view's raw-diff fallback (see
|
||||
// StagingHelper.DiffMainViewShouldRenderRaw); unlike plain it keeps the colour.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, ignoreExternalDiff 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 && !ignoreExternalDiff).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
ArgIf(cached, "--cached").
|
||||
@@ -418,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()
|
||||
|
||||
@@ -434,10 +425,10 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
|
||||
if previousPath != "" {
|
||||
fileNames = append(fileNames, previousPath)
|
||||
}
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain, false).RunWithOutput()
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
@@ -445,7 +436,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
Arg(from).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,11 +16,6 @@ type Hunk struct {
|
||||
newStart int
|
||||
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
|
||||
headerContext string
|
||||
// the lengths declared in the hunk header ('2' and '3' in the above example),
|
||||
// kept so we can verify the parsed body actually matches the header (see
|
||||
// Patch.IsWellFormed). Only populated by Parse.
|
||||
declaredOldLength int
|
||||
declaredNewLength int
|
||||
// the body of the hunk, excluding the header line
|
||||
bodyLines []*PatchLine
|
||||
}
|
||||
@@ -49,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
|
||||
}
|
||||
|
||||
+11
-26
@@ -7,9 +7,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
// Captures, in order: old start, old length (omitted when 1), new start, new
|
||||
// length (omitted when 1), and the trailing context.
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
|
||||
|
||||
func Parse(patchStr string) *Patch {
|
||||
// ignore trailing newline.
|
||||
@@ -21,15 +19,13 @@ func Parse(patchStr string) *Patch {
|
||||
var currentHunk *Hunk
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
|
||||
oldStart, newStart, headerContext := headerInfo(line)
|
||||
|
||||
currentHunk = &Hunk{
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
declaredOldLength: oldLength,
|
||||
declaredNewLength: newLength,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
}
|
||||
hunks = append(hunks, currentHunk)
|
||||
} else if currentHunk != nil {
|
||||
@@ -45,25 +41,14 @@ func Parse(patchStr string) *Patch {
|
||||
}
|
||||
}
|
||||
|
||||
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
|
||||
func headerInfo(header string) (int, int, string) {
|
||||
match := hunkHeaderRegexp.FindStringSubmatch(header)
|
||||
|
||||
oldStart = utils.MustConvertToInt(match[1])
|
||||
oldLength = declaredLength(match[2])
|
||||
newStart = utils.MustConvertToInt(match[3])
|
||||
newLength = declaredLength(match[4])
|
||||
headerContext = match[5]
|
||||
oldStart := utils.MustConvertToInt(match[1])
|
||||
newStart := utils.MustConvertToInt(match[2])
|
||||
headerContext := match[3]
|
||||
|
||||
return oldStart, oldLength, newStart, newLength, headerContext
|
||||
}
|
||||
|
||||
// declaredLength parses a hunk header length capture, which git omits when it
|
||||
// is 1 (e.g. "@@ -0,0 +1 @@").
|
||||
func declaredLength(match string) int {
|
||||
if match == "" {
|
||||
return 1
|
||||
}
|
||||
return utils.MustConvertToInt(match)
|
||||
return oldStart, newStart, headerContext
|
||||
}
|
||||
|
||||
func newHunkLine(line string) *PatchLine {
|
||||
|
||||
@@ -79,23 +79,6 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
||||
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
|
||||
}
|
||||
|
||||
// IsWellFormed reports whether every hunk's body matches the lengths declared
|
||||
// in its header. A faithful unified diff always satisfies this. A rendering that
|
||||
// restructured the diff body does not — e.g. delta with line-number gutters
|
||||
// shifts the +/- marker off the start of each line, so every body line reads as
|
||||
// context and the computed lengths no longer match the header. This lets a
|
||||
// parser tell a real unified diff from a mangled one and fall back rather than
|
||||
// trust a mis-parse. Only meaningful for patches produced by Parse (the lengths
|
||||
// are read from the header there).
|
||||
func (self *Patch) IsWellFormed() bool {
|
||||
for _, hunk := range self.hunks {
|
||||
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *Patch) ContainsChanges() bool {
|
||||
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
|
||||
return hunk.containsChanges()
|
||||
@@ -131,105 +114,6 @@ func (self *Patch) LineNumberOfLine(idx int) int {
|
||||
return hunk.newStart + offset
|
||||
}
|
||||
|
||||
// Takes a line index in the patch and returns the line number in the old file.
|
||||
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
|
||||
// the line's position in the old file (additions get the position they sit at).
|
||||
// If the line is a header line, returns 1.
|
||||
// If the line is a hunk header line, returns the first old-file line number in
|
||||
// that hunk.
|
||||
// If the line is out of range below, returns the last old-file line number in
|
||||
// the last hunk.
|
||||
func (self *Patch) OldLineNumberOfLine(idx int) int {
|
||||
if idx < len(self.header) || len(self.hunks) == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
hunkIdx := self.HunkContainingLine(idx)
|
||||
// cursor out of range, return last file line number
|
||||
if hunkIdx == -1 {
|
||||
lastHunk := self.hunks[len(self.hunks)-1]
|
||||
return lastHunk.oldStart + lastHunk.oldLength() - 1
|
||||
}
|
||||
|
||||
hunk := self.hunks[hunkIdx]
|
||||
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
||||
idxInHunk := idx - hunkStartIdx
|
||||
|
||||
if idxInHunk == 0 {
|
||||
return hunk.oldStart
|
||||
}
|
||||
|
||||
lines := hunk.bodyLines[:idxInHunk-1]
|
||||
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
|
||||
return hunk.oldStart + offset
|
||||
}
|
||||
|
||||
// Takes a line number in the new file and returns the line index in the patch.
|
||||
// This is the opposite of LineNumberOfLine.
|
||||
// If the line number is not contained in any of the hunks, it returns the
|
||||
// closest position.
|
||||
func (self *Patch) PatchLineForLineNumber(lineNumber int) int {
|
||||
if len(self.hunks) == 0 {
|
||||
return len(self.header)
|
||||
}
|
||||
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
if lineNumber <= hunk.newStart {
|
||||
return self.HunkStartIdx(hunkIdx)
|
||||
}
|
||||
|
||||
if lineNumber < hunk.newStart+hunk.newLength() {
|
||||
lines := hunk.bodyLines
|
||||
offset := lineNumber - hunk.newStart
|
||||
for i, line := range lines {
|
||||
if offset == 0 {
|
||||
return self.HunkStartIdx(hunkIdx) + i + 1
|
||||
}
|
||||
|
||||
if line.Kind == ADDITION || line.Kind == CONTEXT {
|
||||
offset--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self.LineCount() - 1
|
||||
}
|
||||
|
||||
// Takes a line number in the old file and returns the line index in the patch.
|
||||
// This is the old-file counterpart of PatchLineForLineNumber. It is what lets us
|
||||
// land on the right patch line for a deletion: two consecutive deletions share a
|
||||
// new-file line number, so only the old-file number tells them apart.
|
||||
// If the line number is not contained in any of the hunks, it returns the
|
||||
// closest position.
|
||||
func (self *Patch) PatchLineForOldLineNumber(lineNumber int) int {
|
||||
if len(self.hunks) == 0 {
|
||||
return len(self.header)
|
||||
}
|
||||
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
if lineNumber <= hunk.oldStart {
|
||||
return self.HunkStartIdx(hunkIdx)
|
||||
}
|
||||
|
||||
if lineNumber < hunk.oldStart+hunk.oldLength() {
|
||||
lines := hunk.bodyLines
|
||||
offset := lineNumber - hunk.oldStart
|
||||
for i, line := range lines {
|
||||
if offset == 0 {
|
||||
return self.HunkStartIdx(hunkIdx) + i + 1
|
||||
}
|
||||
|
||||
if line.Kind == DELETION || line.Kind == CONTEXT {
|
||||
offset--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self.LineCount() - 1
|
||||
}
|
||||
|
||||
// Returns hunk index containing the line at the given patch line index
|
||||
func (self *Patch) HunkContainingLine(idx int) int {
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -61,26 +60,12 @@ type PatchBuilder struct {
|
||||
|
||||
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
|
||||
loadFileDiff loadFileDiffFunc
|
||||
|
||||
// newTempDir creates a fresh temp dir for the current patch, into which the patch is
|
||||
// materialized as two file trees so it can be rendered through any pager (see
|
||||
// PatchCommands.WriteCustomPatchDiffTrees). The patch builder owns its lifetime: a dir
|
||||
// is created on Start and removed on Reset. Nil in tests that don't render the patch.
|
||||
newTempDir func() (string, error)
|
||||
tempDir string
|
||||
|
||||
// generation is bumped on every change to the patch's contents, so a consumer that
|
||||
// materializes the patch (the secondary pane's two file trees) can tell when it's stale
|
||||
// and rebuild only then — covering every path that mutates the patch (the focused main
|
||||
// view and the old explorer alike) without rebuilding on mere navigation.
|
||||
generation int
|
||||
}
|
||||
|
||||
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error)) *PatchBuilder {
|
||||
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
|
||||
return &PatchBuilder{
|
||||
Log: log,
|
||||
loadFileDiff: loadFileDiff,
|
||||
newTempDir: newTempDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +73,6 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.removeTempDir()
|
||||
if p.newTempDir != nil {
|
||||
if dir, err := p.newTempDir(); err != nil {
|
||||
p.Log.Error(err)
|
||||
} else {
|
||||
p.tempDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
p.To = to
|
||||
p.From = from
|
||||
p.reverse = reverse
|
||||
@@ -116,37 +91,6 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
|
||||
return p.fileInfoMap
|
||||
}
|
||||
|
||||
// TempDir is the directory the current patch is materialized into for rendering, or "" if
|
||||
// none was created. See PatchCommands.WriteCustomPatchDiffTrees.
|
||||
func (p *PatchBuilder) TempDir() string {
|
||||
return p.tempDir
|
||||
}
|
||||
|
||||
// Generation is bumped each time the patch's contents change; see the field comment.
|
||||
func (p *PatchBuilder) Generation() int {
|
||||
return p.generation
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) removeTempDir() {
|
||||
if p.tempDir != "" {
|
||||
_ = os.RemoveAll(p.tempDir)
|
||||
p.tempDir = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveFilenames returns the files currently part of the patch (mode != UNSELECTED), in
|
||||
// sorted order — the files to materialize when rendering the patch.
|
||||
func (p *PatchBuilder) ActiveFilenames() []string {
|
||||
filenames := make([]string, 0, len(p.fileInfoMap))
|
||||
for filename, info := range p.fileInfoMap {
|
||||
if info.mode != UNSELECTED {
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
}
|
||||
sort.Strings(filenames)
|
||||
return filenames
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
|
||||
var patch strings.Builder
|
||||
|
||||
@@ -191,7 +135,6 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.addFileWhole(info)
|
||||
|
||||
return nil
|
||||
@@ -203,8 +146,6 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
|
||||
p.removeFile(info)
|
||||
|
||||
return nil
|
||||
@@ -241,7 +182,6 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
|
||||
|
||||
@@ -253,7 +193,6 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
|
||||
if len(info.includedLineIndices) == 0 {
|
||||
@@ -360,109 +299,11 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
|
||||
return info.includedLineIndices, nil
|
||||
}
|
||||
|
||||
// LineIdentity identifies a change line (an addition or deletion) by its file line
|
||||
// number and whether it's a deletion, independently of the line's index in the parsed
|
||||
// patch. It is the identity the diff-line metadata resolves a rendered row to (see
|
||||
// types.DiffLineInfo.PatchSelectLine), and lets the focused main view toggle patch
|
||||
// membership and drive the inclusion gutter without dealing in patch-line indices —
|
||||
// which differ between the raw diff and however a pager renders it.
|
||||
type LineIdentity struct {
|
||||
LineNumber int
|
||||
IsDeletion bool
|
||||
}
|
||||
|
||||
// changeLineIndexByIdentity scans a parsed diff and returns, for each change line, its
|
||||
// index in the patch keyed by the line's identity. An addition is keyed by its new-file
|
||||
// line number, a deletion by its old-file line number, so each change line has a
|
||||
// distinct identity (two consecutive deletions share a new-file number but differ in
|
||||
// the old-file one).
|
||||
func changeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
|
||||
byIdentity := map[LineIdentity]int{}
|
||||
for idx, line := range parsed.Lines() {
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
|
||||
case line.IsDeletion():
|
||||
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
|
||||
}
|
||||
}
|
||||
return byIdentity
|
||||
}
|
||||
|
||||
// PatchLineIndicesForLines maps the given change-line identities to their indices in
|
||||
// filename's parsed diff — the index form that AddFileLineRange / RemoveFileLineRange
|
||||
// and GetFileIncLineIndices work in. Identities that don't correspond to a change line
|
||||
// (e.g. a context line) are skipped. It is how the focused main view, which knows a
|
||||
// selection only as metadata identities, drives patch building.
|
||||
func (p *PatchBuilder) PatchLineIndicesForLines(filename string, lines []LineIdentity) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIdentity := changeLineIndexByIdentity(Parse(info.diff))
|
||||
indices := make([]int, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if idx, ok := byIdentity[line]; ok {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
// IncludedChangeLineIndices returns the patch-line indices of the change lines (additions
|
||||
// and deletions) currently included in the patch for filename, in ascending order. These
|
||||
// are exactly the change lines the aggregated patch renders for the file, in the same
|
||||
// order, so the k-th change line shown in the custom-patch (secondary) view corresponds to
|
||||
// the k-th index here. That correspondence lets the focused main view remove a selection
|
||||
// from the patch by its ordinal among the shown change lines, sidestepping the line-number
|
||||
// renumbering the aggregated patch applies (which makes matching by identity unreliable for
|
||||
// additions). Empty when the file isn't part of the patch.
|
||||
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
|
||||
info, ok := p.fileInfoMap[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
lines := Parse(info.diff).Lines()
|
||||
included := append([]int{}, info.includedLineIndices...)
|
||||
sort.Ints(included)
|
||||
result := make([]int, 0, len(included))
|
||||
for _, idx := range included {
|
||||
if idx >= 0 && idx < len(lines) && (lines[idx].IsAddition() || lines[idx].IsDeletion()) {
|
||||
result = append(result, idx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IncludedLineIdentities returns the identities of the change lines currently included
|
||||
// in the patch for filename — the identity space the inclusion gutter matches rendered
|
||||
// rows against. Empty when the file isn't part of the patch.
|
||||
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
|
||||
info, ok := p.fileInfoMap[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
includedIdx := make(map[int]bool, len(info.includedLineIndices))
|
||||
for _, idx := range info.includedLineIndices {
|
||||
includedIdx[idx] = true
|
||||
}
|
||||
var identities []LineIdentity
|
||||
for identity, idx := range changeLineIndexByIdentity(Parse(info.diff)) {
|
||||
if includedIdx[idx] {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// clears the patch
|
||||
func (p *PatchBuilder) Reset() {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.removeTempDir()
|
||||
|
||||
p.To = ""
|
||||
p.fileInfoMap = map[string]*fileInfo{}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newTestPatchBuilder returns a PatchBuilder whose files all resolve to the given
|
||||
// diff, started for a dummy commit.
|
||||
func newTestPatchBuilder(diff string) *PatchBuilder {
|
||||
pb := NewPatchBuilder(logrus.New().WithField("test", "test"),
|
||||
func(from, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
return diff, nil
|
||||
},
|
||||
nil)
|
||||
pb.Start("from", "to", false, true)
|
||||
return pb
|
||||
}
|
||||
|
||||
// In simpleDiff the deletion "-orange" is patch line index 6 (old-file line 2) and the
|
||||
// addition "+grape" is index 7 (new-file line 2).
|
||||
func TestPatchLineIndicesForLines(t *testing.T) {
|
||||
pb := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
indices, err := pb.PatchLineIndicesForLines("filename", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
{LineNumber: 1, IsDeletion: false}, // " apple" — a context line, no change index
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{6, 7}, indices, "context-line identity is skipped; change lines map to their indices")
|
||||
}
|
||||
|
||||
func TestIncludedLineIdentities(t *testing.T) {
|
||||
pb := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// Nothing included yet.
|
||||
assert.Empty(t, pb.IncludedLineIdentities("filename"))
|
||||
|
||||
// Include only the deletion: it comes back as its identity, the addition does not.
|
||||
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
|
||||
pb.IncludedLineIdentities("filename"))
|
||||
|
||||
// Including the addition too yields both identities (order-independent).
|
||||
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{7}))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
pb.IncludedLineIdentities("filename"))
|
||||
}
|
||||
@@ -120,20 +120,6 @@ index 9320895..6d79956 100644
|
||||
lemon
|
||||
`
|
||||
|
||||
// Two deletions with no line between them: they share a new-file line number
|
||||
// (both sit at the same new-file position), so only their old-file line numbers
|
||||
// distinguish them.
|
||||
const consecutiveDeletions = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
`
|
||||
|
||||
const newFile = `diff --git a/newfile b/newfile
|
||||
new file mode 100644
|
||||
index 0000000..4e680cc
|
||||
@@ -696,95 +682,6 @@ func TestLineNumberOfLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldLineNumberOfLine(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
indexes []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "twoChangesInOneHunk",
|
||||
patchStr: twoChangesInOneHunk,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
|
||||
},
|
||||
{
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, idx := range s.indexes {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.OldLineNumberOfLine(idx)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchLineForOldLineNumber(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
lineNumbers []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
// The two consecutive deletions (grape, pear) sit at patch line
|
||||
// indices 6 and 7; their old-file line numbers (2 and 3) keep them
|
||||
// distinct even though their new-file line numbers are both 2.
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
lineNumbers: []int{0, 1, 2, 3, 4, 5},
|
||||
expecteds: []int{4, 4, 6, 7, 8, 8},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, lineNumber := range s.lineNumbers {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.PatchLineForOldLineNumber(lineNumber)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
assert.True(t, Parse(simpleDiff).IsWellFormed())
|
||||
assert.True(t, Parse(twoHunks).IsWellFormed())
|
||||
assert.True(t, Parse(consecutiveDeletions).IsWellFormed())
|
||||
assert.True(t, Parse(newFile).IsWellFormed())
|
||||
assert.True(t, Parse(deletedFile).IsWellFormed())
|
||||
assert.True(t, Parse(addNewlineToEndOfFile).IsWellFormed())
|
||||
|
||||
// A diff whose body has been mangled so the +/- markers no longer start each
|
||||
// line (as delta's line-number gutters do) reads as all-context, so the body
|
||||
// lengths no longer match the header.
|
||||
gutterMangled := `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
`
|
||||
assert.False(t, Parse(gutterMangled).IsWellFormed())
|
||||
}
|
||||
|
||||
func TestGetNextStageableLineIndex(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -87,14 +87,6 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetNarrowSelectionHighlight() bool {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil {
|
||||
return false
|
||||
}
|
||||
return currentDiffRendererConfig.NarrowSelectionHighlight
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
|
||||
|
||||
@@ -70,20 +70,6 @@ func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
|
||||
assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr))
|
||||
}
|
||||
|
||||
func TestGetNarrowSelectionHighlight(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{{NarrowSelectionHighlight: true}}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
assert.Equal(t, true, config.GetNarrowSelectionHighlight())
|
||||
}
|
||||
|
||||
func TestGetNarrowSelectionHighlightWithoutPagers(t *testing.T) {
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
assert.Equal(t, false, config.GetNarrowSelectionHighlight())
|
||||
}
|
||||
|
||||
func TestCycleDiffRenderers(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -281,12 +281,6 @@ type GitConfig struct {
|
||||
// # renderer command.
|
||||
// name: ""
|
||||
//
|
||||
// # If true, selected diff lines are highlighted only with a narrow bar
|
||||
// # at the left edge of the view, rather than across the whole width.
|
||||
// # Useful for pagers whose own line background colors should remain
|
||||
// # visible.
|
||||
// narrowSelectionHighlight: false
|
||||
//
|
||||
// # Value of the --color arg in the git diff command. Only used for type
|
||||
// # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
// # want it set to 'never'.
|
||||
@@ -383,8 +377,6 @@ type DiffRendererConfig struct {
|
||||
Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"`
|
||||
// A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command.
|
||||
Name string `yaml:"name"`
|
||||
// If true, selected diff lines are highlighted only with a narrow bar at the left edge of the view, rather than across the whole width. Useful for pagers whose own line background colors should remain visible.
|
||||
NarrowSelectionHighlight bool `yaml:"narrowSelectionHighlight"`
|
||||
// Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'.
|
||||
ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"`
|
||||
// The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.
|
||||
|
||||
@@ -191,6 +191,51 @@ func validateCustomCommandKey(key Keybinding) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidCustomCommandContexts lists the names a custom command's 'context' may
|
||||
// use. It mirrors context.AllContextKeys in the gui package, which this package
|
||||
// can't import; a test over there keeps the two in sync.
|
||||
var ValidCustomCommandContexts = []string{
|
||||
"global",
|
||||
"status",
|
||||
"files",
|
||||
"localBranches",
|
||||
"remotes",
|
||||
"worktrees",
|
||||
"remoteBranches",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflogCommits",
|
||||
"subCommits",
|
||||
"commitFiles",
|
||||
"stash",
|
||||
"normal",
|
||||
"normalSecondary",
|
||||
"staging",
|
||||
"stagingSecondary",
|
||||
"patchBuilding",
|
||||
"patchBuildingSecondary",
|
||||
"mergeConflicts",
|
||||
"menu",
|
||||
"confirmation",
|
||||
"prompt",
|
||||
"search",
|
||||
"commitMessage",
|
||||
"submodules",
|
||||
"suggestions",
|
||||
"cmdLog",
|
||||
}
|
||||
|
||||
func validateCustomCommandContext(context string) error {
|
||||
for _, name := range strings.Split(context, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if !slices.Contains(ValidCustomCommandContexts, name) {
|
||||
return fmt.Errorf("Unknown context '%s' for custom command. Allowed values: %s",
|
||||
name, strings.Join(ValidCustomCommandContexts, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomCommands(customCommands []CustomCommand) error {
|
||||
for _, customCommand := range customCommands {
|
||||
if err := validateCustomCommandKey(customCommand.Key); err != nil {
|
||||
@@ -216,6 +261,15 @@ func validateCustomCommands(customCommands []CustomCommand) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// A command in a menu may leave the context out, in which case it is
|
||||
// offered whatever is focused; a top-level one may not, but that is
|
||||
// only noticed when the keybindings are built.
|
||||
if customCommand.Context != "" {
|
||||
if err := validateCustomCommandContext(customCommand.Context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, prompt := range customCommand.Prompts {
|
||||
if err := validateCustomCommandPrompt(prompt); err != nil {
|
||||
return err
|
||||
|
||||
@@ -225,6 +225,43 @@ func TestUserConfigValidate_enums(t *testing.T) {
|
||||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Context: value,
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "global", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "commits, subCommits", valid: true},
|
||||
{value: "commits,subCommits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
{value: "commits, invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command context in a sub menu",
|
||||
setup: func(config *UserConfig, value string) {
|
||||
config.CustomCommands = []CustomCommand{
|
||||
{
|
||||
Key: Keybinding{"X"},
|
||||
CommandMenu: []CustomCommand{
|
||||
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: true},
|
||||
{value: "commits", valid: true},
|
||||
{value: "invalid_value", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom command sub menu",
|
||||
setup: func(config *UserConfig, _ string) {
|
||||
|
||||
@@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
doubleClicks := []bool{}
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
for _, event := range []GocuiEvent{
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+20
-95
@@ -20,24 +20,6 @@ type escapeInterpreter struct {
|
||||
instruction instruction
|
||||
hyperlink strings.Builder
|
||||
|
||||
// the OSC number being accumulated while we don't yet know which OSC this is
|
||||
oscNumber strings.Builder
|
||||
// the payload of an OSC 1717 per-line diff-metadata sequence (see
|
||||
// diff-line-metadata-notes.md), accumulated like hyperlink
|
||||
metadata strings.Builder
|
||||
// OSC 1717 payloads that were superseded by the next record before any
|
||||
// cell consumed them. A row can carry several records back-to-back with
|
||||
// nothing rendered in between — difftastic's combined file+hunk banner, or
|
||||
// the deletion+addition pair of a modification row collapsed to a single
|
||||
// column — so a record's region can be zero-width. The write loop drains
|
||||
// these into content-less carrier cells, keeping every record of the row
|
||||
// discoverable instead of only the last one.
|
||||
orphanedMetadata []string
|
||||
// whether the payload currently in metadata has been stamped onto at
|
||||
// least one cell; an unconsumed payload is orphaned rather than dropped
|
||||
// when the next record arrives or the line ends
|
||||
metadataConsumed bool
|
||||
|
||||
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
|
||||
// rows rather than emitting LFs for them. To convert those into row
|
||||
// advances the view can act on, we track where in the pseudo-terminal
|
||||
@@ -100,9 +82,9 @@ const (
|
||||
stateParams
|
||||
stateCSIDiscard
|
||||
stateOSC
|
||||
stateOSCWaitForParams
|
||||
stateOSCParams
|
||||
stateOSCHyperlink
|
||||
stateOSCMetadata
|
||||
stateOSCEndEscape
|
||||
stateOSCSkipUnknown
|
||||
|
||||
@@ -445,40 +427,27 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
}
|
||||
return true, nil
|
||||
case stateOSC:
|
||||
// Accumulate the OSC number until its terminating ';', then dispatch on
|
||||
// it. (The previous code only recognised the single-digit '8'; a number
|
||||
// like 1717 needs more than one character.)
|
||||
switch {
|
||||
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
|
||||
ei.oscNumber.WriteByte(ch[0])
|
||||
return true, nil
|
||||
case characterEquals(ch, ';'):
|
||||
switch ei.oscNumber.String() {
|
||||
case "8":
|
||||
ei.hyperlink.Reset()
|
||||
ei.state = stateOSCParams
|
||||
case "1717":
|
||||
ei.orphanUnconsumedMetadata()
|
||||
ei.state = stateOSCMetadata
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
ei.oscNumber.Reset()
|
||||
return true, nil
|
||||
default:
|
||||
// Not a recognized OSC; skip to its terminator (handling the case
|
||||
// where this character already is one).
|
||||
ei.oscNumber.Reset()
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
if characterEquals(ch, '8') {
|
||||
ei.state = stateOSCWaitForParams
|
||||
ei.hyperlink.Reset()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
case stateOSCWaitForParams:
|
||||
if !characterEquals(ch, ';') {
|
||||
// Malformed OSC 8 (expected ';' after '8'). Rather than
|
||||
// erroring — which would reset state mid-OSC and cause the
|
||||
// rest of the sequence to leak as literal text — treat the
|
||||
// whole OSC as one we don't understand and skip to its
|
||||
// terminator.
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCParams
|
||||
return true, nil
|
||||
case stateOSCParams:
|
||||
if characterEquals(ch, ';') {
|
||||
ei.state = stateOSCHyperlink
|
||||
@@ -494,18 +463,6 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
ei.hyperlink.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCMetadata:
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.metadata.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCEndEscape:
|
||||
ei.state = stateNone
|
||||
return true, nil
|
||||
@@ -521,38 +478,6 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// orphanUnconsumedMetadata prepares the metadata accumulator for a new OSC 1717
|
||||
// record. If the previous record's payload never made it onto a cell (its region
|
||||
// is zero-width because the next record follows immediately — see
|
||||
// orphanedMetadata), it is kept as an orphan rather than dropped.
|
||||
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
|
||||
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
|
||||
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
|
||||
}
|
||||
ei.metadata.Reset()
|
||||
ei.metadataConsumed = false
|
||||
}
|
||||
|
||||
// takeOrphanedMetadata hands the accumulated zero-width-record payloads (see
|
||||
// orphanedMetadata) to the caller and clears the list.
|
||||
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
|
||||
result := ei.orphanedMetadata
|
||||
ei.orphanedMetadata = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that carries no
|
||||
// fields (no ';'). A metadata-aware pager emits such a version-only record once, as
|
||||
// its first output, to announce it speaks the protocol (so we can probe it; see the
|
||||
// raw-diff fallback in the lazygit staging helper). It isn't per-line metadata, so it
|
||||
// must not linger in the accumulator and attach to the following line. Per-line
|
||||
// payloads always have fields, so they're kept.
|
||||
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
|
||||
if !strings.Contains(ei.metadata.String(), ";") {
|
||||
ei.metadata.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (ei *escapeInterpreter) outputCSI() error {
|
||||
n := len(ei.csiParam)
|
||||
for i := 0; i < n; {
|
||||
|
||||
+84
-53
@@ -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 (
|
||||
@@ -86,14 +91,6 @@ type ViewMouseBinding struct {
|
||||
|
||||
// must be a mouse key
|
||||
Key KeyName
|
||||
|
||||
// If true, this binding is dispatched before ShouldHandleMouseEvent is
|
||||
// consulted, so it fires even when a popup panel is focused and the click
|
||||
// lands on a view other than that panel (which is normally swallowed). This
|
||||
// is the same early phase that hyperlink clicks are handled in; use it for
|
||||
// clicks that must stay live behind a popup, e.g. opening a diff line in the
|
||||
// editor from the main view behind the commit-message panel.
|
||||
HandleWhenPopupPanelFocused bool
|
||||
}
|
||||
|
||||
type ViewMouseBindingOpts struct {
|
||||
@@ -225,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
|
||||
@@ -309,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
|
||||
}
|
||||
|
||||
@@ -667,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
|
||||
@@ -901,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
|
||||
@@ -1436,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
|
||||
@@ -1597,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 {
|
||||
@@ -1617,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
|
||||
@@ -1741,26 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mouseOpts ViewMouseBindingOpts
|
||||
if IsMouseKey(ev.Key) {
|
||||
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
mouseOpts = ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
|
||||
|
||||
// Dispatch bindings that opt into firing while a popup panel is focused
|
||||
// before the gate below gets a chance to reject the click.
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matched {
|
||||
return nil
|
||||
if link := v.hyperlinkAt(newX, newY); link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1813,7 +1827,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
|
||||
if IsMouseKey(ev.Key) {
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
|
||||
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
|
||||
matched, err := g.execMouseKeybindings(v, ev, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1877,12 +1893,11 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
|
||||
return isDoubleClick
|
||||
}
|
||||
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
|
||||
isMatch := func(binding *ViewMouseBinding) bool {
|
||||
return binding.ViewName == view.Name() &&
|
||||
ev.Key.KeyName() == binding.Key &&
|
||||
ev.Key.Mod() == binding.Modifier &&
|
||||
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
|
||||
ev.Key.Mod() == binding.Modifier
|
||||
}
|
||||
|
||||
// first pass looks for ones that match the focused view
|
||||
@@ -1978,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 == "" {
|
||||
@@ -2013,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)
|
||||
}
|
||||
@@ -2076,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,6 @@ const (
|
||||
|
||||
var (
|
||||
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
||||
lastMouseMod tcell.ModMask = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
@@ -301,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 {
|
||||
@@ -371,15 +370,6 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
|
||||
newButtonPress = true
|
||||
lastMouseKey = button
|
||||
// The keyboard modifiers held at press time apply to the whole
|
||||
// gesture: the press (a click is reported on the press, so this is
|
||||
// what makes modified clicks bindable), every drag event, and the
|
||||
// release. Snapshotting them here keeps the gesture atomic: a
|
||||
// modified press can't produce drag/release events that match an
|
||||
// unmodified binding, and pressing or releasing a modifier while
|
||||
// the button is held doesn't change the gesture midway.
|
||||
lastMouseMod = tev.Modifiers()
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
switch button {
|
||||
case tcell.ButtonPrimary:
|
||||
mouseKey = MouseLeft
|
||||
@@ -405,8 +395,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
case tcell.ButtonMiddle:
|
||||
default:
|
||||
}
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
lastMouseMod = tcell.ModNone
|
||||
mouseMod = ModNone
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
}
|
||||
default:
|
||||
@@ -437,10 +426,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
// reaches drag bindings instead of being delivered with the
|
||||
// default MouseRelease key.
|
||||
dragState = DRAGGING
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
case DRAGGING:
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,55 +36,21 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
}
|
||||
|
||||
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
|
||||
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, dragEvent.Type)
|
||||
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func resetMouseState() {
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
lastMouseMod = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+102
-359
@@ -25,8 +25,6 @@ const (
|
||||
RIGHT = 8 // view is overlapping at right edge
|
||||
)
|
||||
|
||||
// A View is a window. It maintains its own internal buffer and cursor
|
||||
// position.
|
||||
// viewBuffer holds a view's content as cells, together with the cursor and
|
||||
// escape-sequence decoder state used to turn incoming bytes into those cells.
|
||||
// A view normally has a single buffer (the one it displays), but bundling this
|
||||
@@ -49,6 +47,8 @@ type viewBuffer struct {
|
||||
pendingNewline bool
|
||||
}
|
||||
|
||||
// A View is a window. It maintains its own internal buffer and cursor
|
||||
// position.
|
||||
type View struct {
|
||||
name string
|
||||
x0, y0, x1, y1 int // left top right bottom
|
||||
@@ -64,11 +64,12 @@ type View struct {
|
||||
|
||||
// While non-nil, writes go here instead of buf, so an async re-render can
|
||||
// build its new content without disturbing what readers (draw, clicks,
|
||||
// scrolling, the diff-line readers, …) see. The task swaps it into buf once
|
||||
// it has read enough to paint (SwapInOffscreenRender), so the displayed
|
||||
// content jumps straight from the previous render to the new one with no
|
||||
// half-written frame in between. nil during normal (non-async) writes.
|
||||
// scrolling, …) see. The task swaps it into buf once it has read enough to
|
||||
// paint (SwapInOffscreenRender), so the displayed content jumps straight
|
||||
// from the previous render to the new one with no half-written frame in
|
||||
// between. nil during normal (non-async) writes.
|
||||
offscreen *viewBuffer
|
||||
|
||||
// The y position of the first line of a range selection.
|
||||
// This is not relative to the view's origin: it is relative to the first line
|
||||
// of the view's content, so you can scroll the view and this value will remain
|
||||
@@ -156,21 +157,6 @@ type View struct {
|
||||
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
|
||||
HighlightInactive bool
|
||||
|
||||
// If SelectedLineBgColorWidth is greater than zero, selection background
|
||||
// color is painted only at the left edge of highlighted lines.
|
||||
SelectedLineBgColorWidth int
|
||||
|
||||
// InclusionGutterMarker is the glyph drawn in the on-demand inclusion gutter
|
||||
// (see SetInclusionGutter) on marked lines; InclusionGutterMarkerColor is its
|
||||
// color. Both are set once at view creation.
|
||||
InclusionGutterMarker string
|
||||
InclusionGutterMarkerColor Attribute
|
||||
// showInclusionGutter reserves the gutter column at the left of every line, and
|
||||
// inclusionGutterMarks (indexed by buffer line) selects which lines get the
|
||||
// marker. Set together via SetInclusionGutter.
|
||||
showInclusionGutter bool
|
||||
inclusionGutterMarks []bool
|
||||
|
||||
// If Frame is true, a border will be drawn around the view.
|
||||
Frame bool
|
||||
|
||||
@@ -224,7 +210,9 @@ type View struct {
|
||||
// Overlaps describes which edges are overlapping with another view's edges
|
||||
Overlaps byte
|
||||
|
||||
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
|
||||
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler.
|
||||
// Views related this way are also drawn as a single focused unit: while one of
|
||||
// them is the current view, they all get the focused frame and title colors.
|
||||
ParentView *View
|
||||
|
||||
searcher *searcher
|
||||
@@ -283,6 +271,12 @@ type searcher struct {
|
||||
currentSearchIndex int
|
||||
onSelectItem func(*View, int)
|
||||
renderSearchStatus func(*View, int, int)
|
||||
|
||||
// Whether the content has changed since the positions were worked out, so that
|
||||
// they have to be worked out again before they are read. Working them out walks
|
||||
// the whole view, and content arrives a line at a time, so it happens once per
|
||||
// read rather than once per line written.
|
||||
positionsStale bool
|
||||
}
|
||||
|
||||
func (v *View) setRenderSearchStatus(renderSearchStatus func(*View, int, int)) {
|
||||
@@ -299,7 +293,40 @@ func (v *View) renderSearchStatus(index int, itemCount int) {
|
||||
}
|
||||
}
|
||||
|
||||
// refreshSearchPositions works the search positions out again if the content has
|
||||
// changed since they were last worked out. Every read of the positions goes through
|
||||
// this, so that no caller has to know whether the view has been drawn since the
|
||||
// content it is asking about arrived.
|
||||
func (v *View) refreshSearchPositions() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshSearchPositionsIfNeeded()
|
||||
}
|
||||
|
||||
// refreshSearchPositions for a caller that already holds writeMutex.
|
||||
func (v *View) refreshSearchPositionsIfNeeded() {
|
||||
if v.searcher.positionsStale {
|
||||
v.updateSearchPositions()
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshSearch runs the search again over content the view has just been re-rendered
|
||||
// with, and shows the "x of y" status of what it finds. The view stays where it is: the
|
||||
// position in the content is the user's, and the search follows it rather than moving
|
||||
// it.
|
||||
func (v *View) RefreshSearch() {
|
||||
if !v.IsSearching() {
|
||||
return
|
||||
}
|
||||
|
||||
v.UpdateSearchResults(v.searcher.searchString, v.searcher.modelSearchResults)
|
||||
v.renderSearchStatus(v.searcher.currentSearchIndex, len(v.searcher.searchPositions))
|
||||
}
|
||||
|
||||
func (v *View) gotoNextMatch() error {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
if len(v.searcher.searchPositions) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -319,6 +346,8 @@ func (v *View) gotoNextMatch() error {
|
||||
}
|
||||
|
||||
func (v *View) gotoPreviousMatch() error {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
if len(v.searcher.searchPositions) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -340,6 +369,8 @@ func (v *View) gotoPreviousMatch() error {
|
||||
}
|
||||
|
||||
func (v *View) SelectSearchResult(index int) {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
itemCount := len(v.searcher.searchPositions)
|
||||
if itemCount == 0 {
|
||||
return
|
||||
@@ -359,6 +390,8 @@ func (v *View) SelectSearchResult(index int) {
|
||||
|
||||
// Returns <current match index>, <total matches>
|
||||
func (v *View) GetSearchStatus() (int, int) {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
return v.searcher.currentSearchIndex, len(v.searcher.searchPositions)
|
||||
}
|
||||
|
||||
@@ -432,6 +465,8 @@ func (v *View) nearestSearchPosition() int {
|
||||
}
|
||||
|
||||
func (v *View) SetNearestSearchPosition() {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
if len(v.searcher.searchPositions) > 0 {
|
||||
newPos := v.nearestSearchPosition()
|
||||
if newPos != v.searcher.currentSearchIndex {
|
||||
@@ -521,7 +556,7 @@ type viewLine struct {
|
||||
trailingFillAttributes *trailingFillAttributes
|
||||
}
|
||||
|
||||
// lineType is one of v.lines: the cells of a source line, plus optional
|
||||
// lineType is one of v.buf.lines: the cells of a source line, plus optional
|
||||
// trailingFillAttributes recording the colors used to extend the bg
|
||||
// past the line's content when the writer emitted '\x1b[K'.
|
||||
type lineType struct {
|
||||
@@ -553,9 +588,6 @@ type cell struct {
|
||||
width int // number of terminal cells occupied by chr (always 1 or 2)
|
||||
bgColor, fgColor Attribute
|
||||
hyperlink string
|
||||
// per-line diff metadata from an OSC 1717 sequence (see
|
||||
// diff-line-metadata-notes.md); empty unless a pager emitted it
|
||||
metadata string
|
||||
}
|
||||
|
||||
type cells []cell
|
||||
@@ -667,35 +699,10 @@ func (v *View) Name() string {
|
||||
return v.name
|
||||
}
|
||||
|
||||
// SetInclusionGutter configures the on-demand inclusion marker gutter: a fixed-width
|
||||
// column reserved at the left of every line, used by the custom-patch inclusion
|
||||
// overlay to show which change lines are in the patch. When show is true the gutter
|
||||
// is reserved and the content is shifted right to make room; marks, indexed by
|
||||
// buffer line, selects which lines get InclusionGutterMarker drawn (on every wrapped
|
||||
// segment of the line). It is pure draw-time decoration — the content buffer (and so the
|
||||
// diff-line metadata, click resolution, etc.) is untouched. Toggling show changes the
|
||||
// wrap width, so the view is re-wrapped.
|
||||
func (v *View) SetInclusionGutter(show bool, marks []bool) {
|
||||
if v.showInclusionGutter != show {
|
||||
v.showInclusionGutter = show
|
||||
v.tainted = true
|
||||
}
|
||||
v.inclusionGutterMarks = marks
|
||||
}
|
||||
|
||||
// inclusionGutterWidth is the number of columns the inclusion gutter occupies when
|
||||
// shown (the marker glyph plus a one-cell separator), or 0 when hidden.
|
||||
func (v *View) inclusionGutterWidth() int {
|
||||
if !v.showInclusionGutter {
|
||||
return 0
|
||||
}
|
||||
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
|
||||
}
|
||||
|
||||
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
|
||||
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
|
||||
// position is valid.
|
||||
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
|
||||
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isWindowFocused bool) {
|
||||
maxX, maxY := v.Size()
|
||||
if x < 0 || x >= maxX || y < 0 || y >= maxY {
|
||||
return
|
||||
@@ -714,15 +721,14 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
|
||||
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
|
||||
}
|
||||
|
||||
bgColorWidth := v.SelectedLineBgColorWidth
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd && (bgColorWidth == 0 || x < bgColorWidth) {
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
||||
// this ensures we use the bright variant of a colour upon highlight
|
||||
fgColorComponent := fgColor & ^AttrAll
|
||||
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
|
||||
fgColor += 8
|
||||
}
|
||||
fgColor = fgColor | AttrBold
|
||||
if v.HighlightInactive {
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
} else {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.SelBgColor
|
||||
@@ -936,14 +942,14 @@ func (v *View) write(p []byte) {
|
||||
}
|
||||
|
||||
v.tainted = true
|
||||
// write only ever touches lines from v.wy onwards, so any cached wrapping
|
||||
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
|
||||
// below that stays valid.
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
|
||||
v.clearHover()
|
||||
|
||||
v.buf.write(v, p)
|
||||
|
||||
v.updateSearchPositions()
|
||||
v.searcher.positionsStale = true
|
||||
}
|
||||
|
||||
// write parses p into cells and appends them to the buffer at its write cursor.
|
||||
@@ -956,20 +962,6 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
|
||||
finishLine := func() {
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
// A record whose region reached the line end without covering any cell
|
||||
// still belongs to this line: a zero-width record region (see
|
||||
// escapeInterpreter.orphanedMetadata), or a blank changed line that a
|
||||
// pager renders as just its OSC 1717 metadata followed by the newline —
|
||||
// delta does this for some empty deleted/added lines. Keep content-less
|
||||
// cells to carry those payloads, so the line is still recognized as a
|
||||
// change; without them the line resolves to nothing and breaks a change
|
||||
// block in two (e.g. when selecting a hunk in the focused main view).
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
b.writeCells([]cell{{metadata: payload}})
|
||||
}
|
||||
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
|
||||
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
|
||||
}
|
||||
}
|
||||
|
||||
advanceToNextLine := func() {
|
||||
@@ -978,10 +970,6 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
if b.wy >= len(b.lines) {
|
||||
b.lines = append(b.lines, lineType{})
|
||||
}
|
||||
// An OSC 1717 diff-metadata sequence applies only to the line it prefixes
|
||||
// (the pager re-emits one per line and never closes it), so drop it at the
|
||||
// line boundary rather than letting it carry onto a line with no metadata.
|
||||
b.ei.metadata.Reset()
|
||||
}
|
||||
|
||||
if b.pendingNewline {
|
||||
@@ -1126,15 +1114,6 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
truncateLine := false
|
||||
|
||||
isEscape, err := b.ei.parseOne(ch)
|
||||
|
||||
// An OSC 1717 record superseded before any cell consumed it (a zero-width
|
||||
// record region — see escapeInterpreter.orphanedMetadata) still belongs to
|
||||
// this line: materialize each such payload as a content-less carrier cell,
|
||||
// in emission order, ahead of whatever this byte produces.
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
cells = append(cells, cell{metadata: payload})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
for _, chr := range b.ei.characters() {
|
||||
c := cell{
|
||||
@@ -1161,7 +1140,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
fg: b.ei.curFgColor,
|
||||
bg: b.ei.curBgColor,
|
||||
}
|
||||
return truncateLine, cells
|
||||
return truncateLine, []cell{}
|
||||
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
|
||||
// emit `n` space cells under the parser-tracked SGR — used
|
||||
// to materialize ConPTY's compressed runs of spaces (which
|
||||
@@ -1171,11 +1150,8 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
} else if isEscape {
|
||||
// no visible output — but carrier cells still need writing
|
||||
if len(cells) == 0 {
|
||||
return truncateLine, nil
|
||||
}
|
||||
return truncateLine, cells
|
||||
// do not output anything
|
||||
return truncateLine, nil
|
||||
} else if characterEquals(ch, '\t') {
|
||||
// fill tab-sized space
|
||||
tabWidth := v.TabWidth
|
||||
@@ -1190,13 +1166,9 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
fgColor: b.ei.curFgColor,
|
||||
bgColor: b.ei.curBgColor,
|
||||
hyperlink: b.ei.hyperlink.String(),
|
||||
metadata: b.ei.metadata.String(),
|
||||
chr: string(ch),
|
||||
width: width,
|
||||
}
|
||||
if c.metadata != "" {
|
||||
b.ei.metadataConsumed = true
|
||||
}
|
||||
for range repeatCount {
|
||||
cells = append(cells, c)
|
||||
}
|
||||
@@ -1325,17 +1297,6 @@ func (v *View) Reset() {
|
||||
v.offscreen = nil
|
||||
}
|
||||
|
||||
// This is for when we've done a restart for the sake of avoiding a flicker and
|
||||
// we've reached the end of the new content to display: we need to clear the remaining
|
||||
// content from the previous round. We do this by setting v.viewLines to nil so that
|
||||
// we just render the new content from v.buf.lines directly
|
||||
func (v *View) FlushStaleCells() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.clearViewLines()
|
||||
}
|
||||
|
||||
// BeginOffscreenRender starts building a re-render into an off-screen buffer.
|
||||
// Until SwapInOffscreenRender promotes it, writes go to that buffer and the
|
||||
// displayed buffer — what every reader sees — is left as it was. This is how an
|
||||
@@ -1345,7 +1306,13 @@ func (v *View) BeginOffscreenRender() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.offscreen = &viewBuffer{ei: newEscapeInterpreter(v.outMode)}
|
||||
ei := newEscapeInterpreter(v.outMode)
|
||||
// The screen width content is wrapped at is render configuration set by
|
||||
// SetContentWidth, not per-buffer state, so the off-screen buffer's parser
|
||||
// needs it too — otherwise it counts no soft wraps and cursor-positioning
|
||||
// escapes land on the wrong rows.
|
||||
ei.screenColMax = v.buf.ei.screenColMax
|
||||
v.offscreen = &viewBuffer{ei: ei}
|
||||
}
|
||||
|
||||
// SwapInOffscreenRender promotes the off-screen buffer (see BeginOffscreenRender)
|
||||
@@ -1433,6 +1400,8 @@ func stringToGraphemes(s string) []string {
|
||||
}
|
||||
|
||||
func (v *View) updateSearchPositions() {
|
||||
v.searcher.positionsStale = false
|
||||
|
||||
if v.searcher.searchString != "" {
|
||||
var normalizeRune func(s string) string
|
||||
var normalizedSearchStr string
|
||||
@@ -1511,6 +1480,11 @@ func (v *View) updateSearchPositions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The content may hold fewer matches than it did, so the current one is brought
|
||||
// back into range: readers index the positions by it.
|
||||
v.searcher.currentSearchIndex = min(v.searcher.currentSearchIndex,
|
||||
max(0, len(v.searcher.searchPositions)-1))
|
||||
}
|
||||
|
||||
// IsTainted tells us if the view is tainted
|
||||
@@ -1521,7 +1495,7 @@ func (v *View) IsTainted() bool {
|
||||
}
|
||||
|
||||
// draw re-draws the view's contents.
|
||||
func (v *View) draw() {
|
||||
func (v *View) draw(isWindowFocused bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
@@ -1541,6 +1515,7 @@ func (v *View) draw() {
|
||||
}
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
v.refreshSearchPositionsIfNeeded()
|
||||
|
||||
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
|
||||
if v.Autoscroll && visibleViewLinesHeight > maxY {
|
||||
@@ -1558,10 +1533,6 @@ func (v *View) draw() {
|
||||
|
||||
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
|
||||
|
||||
// The inclusion gutter (when shown) reserves the leftmost columns; content is
|
||||
// drawn shifted right past it. See SetInclusionGutter.
|
||||
gutterWidth := v.inclusionGutterWidth()
|
||||
|
||||
for y, vline := range v.viewLines[start:] {
|
||||
if y >= maxY {
|
||||
break
|
||||
@@ -1576,21 +1547,10 @@ func (v *View) draw() {
|
||||
trailingCell.bgColor = attrs.bg
|
||||
}
|
||||
|
||||
// Paint the inclusion gutter: blanks across its width, with the marker on
|
||||
// every wrapped segment of a marked buffer line.
|
||||
if gutterWidth > 0 {
|
||||
for gx := range gutterWidth {
|
||||
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor)
|
||||
}
|
||||
if vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
|
||||
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor)
|
||||
}
|
||||
}
|
||||
|
||||
// x tracks the current x position in the view, and cellIdx tracks the
|
||||
// index of the cell. If we print a double-sized rune, we increment cellIdx
|
||||
// by one but x by two.
|
||||
x := gutterWidth - v.ox
|
||||
x := -v.ox
|
||||
cellIdx := 0
|
||||
|
||||
var c cell
|
||||
@@ -1626,7 +1586,7 @@ func (v *View) draw() {
|
||||
fgColor |= AttrUnderline
|
||||
}
|
||||
|
||||
v.setCharacter(x, y, c.chr, fgColor, bgColor)
|
||||
v.setCharacter(x, y, c.chr, fgColor, bgColor, isWindowFocused)
|
||||
|
||||
x += c.width
|
||||
cellIdx++
|
||||
@@ -1639,7 +1599,7 @@ func (v *View) refreshViewLinesIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
maxX := v.InnerWidth() - v.inclusionGutterWidth()
|
||||
maxX := v.InnerWidth()
|
||||
wrap := 0
|
||||
if v.Wrap {
|
||||
wrap = maxX
|
||||
@@ -1877,225 +1837,6 @@ func (v *View) Word(x, y int) (string, bool) {
|
||||
return str[nl:nr], true
|
||||
}
|
||||
|
||||
func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
linesY, ok := v.bufferLineForViewLine(y)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, c := range v.buf.lines[linesY].cells {
|
||||
if strings.HasPrefix(c.hyperlink, urlScheme) {
|
||||
return c.hyperlink, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DiffLineMetadataInLine returns the OSC 1717 per-line diff metadata payload
|
||||
// attached to the given (wrapped) view line, if a pager emitted one. In the
|
||||
// single-column case every cell of the line carries the same payload, so the
|
||||
// first non-empty one is the answer. See diff-line-metadata-notes.md.
|
||||
func (v *View) DiffLineMetadataInLine(y int) (string, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
linesY, ok := v.bufferLineForViewLine(y)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, c := range v.buf.lines[linesY].cells {
|
||||
if c.metadata != "" {
|
||||
return c.metadata, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DiffLineMetadataPayloads returns, per unwrapped buffer line, the distinct
|
||||
// OSC-1717 metadata payloads carried by that line's cells, in left-to-right order.
|
||||
// A single-column rendering tags every cell of a line with the same payload (one
|
||||
// entry); a side-by-side rendering tags each side differently, so a changed row
|
||||
// yields one payload per side (and a context row, where both sides match, still
|
||||
// one). It is the multi-record counterpart of DiffLineContent.Metadata, which keeps
|
||||
// only the first payload — enough to identify a single-column row, but it drops the
|
||||
// other side of a side-by-side row. Staging a selection uses this to act on every
|
||||
// change a row covers. Taken under the write lock in one pass so the payloads stay
|
||||
// consistent with the buffer even if a concurrent re-render is rebuilding it.
|
||||
func (v *View) DiffLineMetadataPayloads() [][]string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
result := make([][]string, len(v.buf.lines))
|
||||
for i, line := range v.buf.lines {
|
||||
var payloads []string
|
||||
for _, c := range line.cells {
|
||||
if c.metadata != "" && !slices.Contains(payloads, c.metadata) {
|
||||
payloads = append(payloads, c.metadata)
|
||||
}
|
||||
}
|
||||
result[i] = payloads
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DiffLineContent is the raw per-line material the diff-line backends parse to
|
||||
// recover a rendered row's patch-space identity (see diff-line-metadata-notes.md):
|
||||
// the decolorized text (for host-side parsing, mechanism #1), the OSC-1717
|
||||
// metadata payload a pager emitted (#2), and the line's hyperlink (delta's
|
||||
// lazygit-edit fallback). It is indexed by unwrapped buffer line, so one entry
|
||||
// covers all the (wrapped) view lines that line maps to.
|
||||
type DiffLineContent struct {
|
||||
Text string
|
||||
Metadata string
|
||||
Hyperlink string
|
||||
}
|
||||
|
||||
// DiffLineContents returns the per-line diff material (see DiffLineContent) for
|
||||
// every line of the displayed buffer. It is the snapshot the diff-line backends
|
||||
// scan: taken under the write lock in one call, so the text, metadata and
|
||||
// hyperlink of a given line stay consistent with each other even if a concurrent
|
||||
// re-render is rebuilding the buffer.
|
||||
func (v *View) DiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return diffLineContents(v.buf)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContents returns the per-line diff material (see
|
||||
// DiffLineContent) for the lines read so far into an in-progress off-screen
|
||||
// re-render (see BeginOffscreenRender), or nil when no off-screen render is
|
||||
// underway. While a focused main view re-renders, its displayed buffer still
|
||||
// holds the previous render; this is how the escape restore scans the *incoming*
|
||||
// content as it loads, to find the row matching a target patch identity and
|
||||
// decide when it has read far enough to swap in and scroll there.
|
||||
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return nil
|
||||
}
|
||||
return diffLineContents(v.offscreen)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the
|
||||
// rows from index `from` onward (relative to the off-screen buffer's start, so
|
||||
// result[0] is buffer line `from`). It lets a scan that tracks how far it has read
|
||||
// process only the lines that have arrived since, instead of re-snapshotting the
|
||||
// whole off-screen buffer on every line — the difference between an O(n) and an
|
||||
// O(n²) restore scan on a large diff. Returns nil when no off-screen render is
|
||||
// underway or `from` is past the lines read so far.
|
||||
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
|
||||
return nil
|
||||
}
|
||||
return diffLineContentsFrom(v.offscreen, from)
|
||||
}
|
||||
|
||||
// OffscreenLineCount returns the number of unwrapped buffer lines read so far
|
||||
// into an in-progress off-screen re-render (see BeginOffscreenRender), or 0 if
|
||||
// none is underway. The escape restore uses it to tell, cheaply, once it has
|
||||
// found its target line, when a screenful below it has loaded too — so the swap
|
||||
// shows the target with context rather than at the very bottom of a part-filled
|
||||
// view.
|
||||
func (v *View) OffscreenLineCount() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return 0
|
||||
}
|
||||
return len(v.offscreen.lines)
|
||||
}
|
||||
|
||||
func diffLineContents(buf *viewBuffer) []DiffLineContent {
|
||||
return diffLineContentsFrom(buf, 0)
|
||||
}
|
||||
|
||||
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
|
||||
lines := buf.lines[from:]
|
||||
contents := make([]DiffLineContent, len(lines))
|
||||
for i, line := range lines {
|
||||
text := strings.ReplaceAll(line.cells.String(), "\x00", "")
|
||||
var metadata, hyperlink string
|
||||
for _, c := range line.cells {
|
||||
if metadata == "" {
|
||||
metadata = c.metadata
|
||||
}
|
||||
if hyperlink == "" {
|
||||
hyperlink = c.hyperlink
|
||||
}
|
||||
}
|
||||
contents[i] = DiffLineContent{Text: text, Metadata: metadata, Hyperlink: hyperlink}
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
|
||||
// the index of the corresponding line in the unwrapped internal buffer (as
|
||||
// returned by BufferLines). Several view lines can map to the same buffer line
|
||||
// when wrapping is on. Returns false if the view line is out of range.
|
||||
func (v *View) BufferLineForViewLine(y int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return v.bufferLineForViewLine(y)
|
||||
}
|
||||
|
||||
// bufferLineForViewLine maps a (wrapped) view line index to the index of the
|
||||
// corresponding line in the unwrapped internal buffer (v.buf.lines). It is the
|
||||
// shared core of the public readers that look up information about the buffer
|
||||
// line under a given view line (its buffer index, its hyperlink, its diff
|
||||
// metadata); they all need the same view-line→buffer-line mapping to stay
|
||||
// consistent with the buffer they then read. The caller must hold writeMutex,
|
||||
// so that the mapping and the subsequent read of v.buf.lines see the same buffer
|
||||
// even if a concurrent re-render is rebuilding it.
|
||||
func (v *View) bufferLineForViewLine(y int) (int, bool) {
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// refreshViewLinesIfNeeded overwrites viewLines in place without truncating,
|
||||
// so while a shorter re-render is loading, the tail of viewLines can still
|
||||
// hold stale entries pointing past the (shrunk) v.buf.lines. Guard against that.
|
||||
linesY := v.viewLines[y].linesY
|
||||
if linesY >= len(v.buf.lines) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return linesY, true
|
||||
}
|
||||
|
||||
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
|
||||
// first (wrapped) view line that renders it — the inverse of BufferLineForViewLine.
|
||||
// The escape restore uses it to turn the buffer line it matched against a target
|
||||
// patch identity into the view line to scroll to and select. Returns false if the
|
||||
// buffer line isn't currently rendered into any view line.
|
||||
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
for i, vl := range v.viewLines {
|
||||
if vl.linesY == bufferLineIdx {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// indexFunc allows to split lines by words taking into account spaces
|
||||
// and 0.
|
||||
func indexFunc(r rune) bool {
|
||||
@@ -2229,16 +1970,6 @@ func (v *View) SelectedLineIdx() int {
|
||||
return seletedLineIdx
|
||||
}
|
||||
|
||||
// MiddleVisibleLineIdx returns the index of the view line at the middle of the
|
||||
// content currently on screen. When the content is taller than the viewport this is
|
||||
// the middle row of the viewport; when it's shorter, it's the middle of the content,
|
||||
// so the result lands within the content rather than in the empty space below it.
|
||||
func (v *View) MiddleVisibleLineIdx() int {
|
||||
top := v.OriginY()
|
||||
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
|
||||
return (top + bottom) / 2
|
||||
}
|
||||
|
||||
// expected to only be used in tests
|
||||
func (v *View) SelectedLine() string {
|
||||
v.writeMutex.Lock()
|
||||
@@ -2394,6 +2125,8 @@ func (v *View) setContentLineCount(lineCount int) {
|
||||
// result that is visible in the view, if any, or the first one that is below the view if none is
|
||||
// visible.
|
||||
func (v *View) selectVisibleSearchResultAfterScrollUp() {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
|
||||
windowBottom := v.oy + v.InnerHeight()
|
||||
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom {
|
||||
@@ -2417,6 +2150,8 @@ func (v *View) selectVisibleSearchResultAfterScrollUp() {
|
||||
// result that is visible in the view, if any, or the last one that is above the view if none is
|
||||
// visible.
|
||||
func (v *View) selectVisibleSearchResultAfterScrollDown() {
|
||||
v.refreshSearchPositions()
|
||||
|
||||
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
|
||||
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy {
|
||||
newSearchIndex := v.searcher.currentSearchIndex
|
||||
@@ -2558,11 +2293,6 @@ func (v *View) onMouseMove(x int, y int) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reading v.viewLines (here and in findHyperlinkAt) must hold writeMutex like
|
||||
// every other reader: this runs on the event-handling goroutine, and a
|
||||
// concurrent re-render on the task goroutine can rebuild or shrink viewLines
|
||||
// between the bounds check below and the indexing in findHyperlinkAt — which
|
||||
// panicked with an out-of-range index.
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
@@ -2584,6 +2314,19 @@ func (v *View) onMouseMove(x int, y int) {
|
||||
}
|
||||
}
|
||||
|
||||
// hyperlinkAt returns the hyperlink at the given position of the view's
|
||||
// content, or an empty string if there is none.
|
||||
func (v *View) hyperlinkAt(x, y int) string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) || x < 0 || x >= len(v.viewLines[y].line) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v.viewLines[y].line[x].hyperlink
|
||||
}
|
||||
|
||||
func (v *View) findHyperlinkAt(x, y int) *SearchPosition {
|
||||
linkStr := v.viewLines[y].line[x].hyperlink
|
||||
if linkStr == "" {
|
||||
|
||||
+50
-347
@@ -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"
|
||||
)
|
||||
|
||||
@@ -106,10 +107,8 @@ func TestWriteString(t *testing.T) {
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.buf.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)
|
||||
}
|
||||
}
|
||||
@@ -159,217 +158,6 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
func TestSelectedLineBgColorWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bgColorWidth int
|
||||
selected func(screenX int) bool
|
||||
}{
|
||||
{
|
||||
name: "zero uses full-width selection background",
|
||||
bgColorWidth: 0,
|
||||
selected: func(_ int) bool { return true },
|
||||
},
|
||||
{
|
||||
name: "non-zero uses left-edge selection background",
|
||||
bgColorWidth: 2,
|
||||
selected: func(screenX int) bool { return screenX <= 2 },
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 4)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 3, OutputNormal) // InnerWidth=10
|
||||
v.Highlight = true
|
||||
v.SelBgColor = ColorBlue
|
||||
v.SelectedLineBgColorWidth = test.bgColorWidth
|
||||
v.writeString("0123456789\n")
|
||||
v.draw()
|
||||
|
||||
selectedBg := getTcellColor(ColorBlue, OutputNormal)
|
||||
for screenX := 1; screenX <= 10; screenX++ {
|
||||
_, style, _ := Screen.Get(screenX, 1)
|
||||
if test.selected(screenX) {
|
||||
assert.Equal(t, selectedBg, style.GetBackground(), "cell %d should show the selection background", screenX)
|
||||
} else {
|
||||
assert.Equal(t, tcell.ColorDefault, style.GetBackground(), "cell %d should keep its original background", screenX)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineMetadata(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// Synthetic delta-style output: each content line is prefixed with an
|
||||
// OSC 1717 sequence carrying version;type;new;old;file (old empty unless a
|
||||
// deletion), and the OSC bytes themselves must not become visible cells. The
|
||||
// final line is a header with no OSC, to prove the metadata doesn't bleed.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
osc("1;c;1;;foo.txt") + "line1",
|
||||
osc("1;d;2;2;foo.txt") + "old2",
|
||||
osc("1;a;2;;foo.txt") + "new2",
|
||||
"@@ a header line with no metadata @@",
|
||||
}, "\n"))
|
||||
|
||||
type result struct {
|
||||
payload string
|
||||
ok bool
|
||||
}
|
||||
got := make([]result, len(v.buf.lines))
|
||||
for y := range v.buf.lines {
|
||||
payload, ok := v.DiffLineMetadataInLine(y)
|
||||
got[y] = result{payload, ok}
|
||||
}
|
||||
|
||||
assert.Equal(t, []result{
|
||||
{"1;c;1;;foo.txt", true},
|
||||
{"1;d;2;2;foo.txt", true},
|
||||
{"1;a;2;;foo.txt", true},
|
||||
{"", false}, // the header line carries no metadata (no bleed)
|
||||
}, got)
|
||||
|
||||
// The OSC sequence is consumed as an escape, so the visible text is intact.
|
||||
assert.Equal(t, "line1", v.BufferLines()[0])
|
||||
assert.Equal(t, "@@ a header line with no metadata @@", v.BufferLines()[3])
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataPayloads(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
// A single-column row: one payload tags the whole line.
|
||||
osc("1;c;1;;foo.txt") + "context",
|
||||
// A side-by-side change row: the deletion tags the left half and the
|
||||
// addition replacing it tags the right half of the same rendered line.
|
||||
osc("1;d;2;2;foo.txt") + "old2 " + osc("1;a;2;;foo.txt") + "new2",
|
||||
// A header line with no metadata.
|
||||
"@@ header @@",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"1;c;1;;foo.txt"},
|
||||
{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"},
|
||||
nil,
|
||||
}, v.DiffLineMetadataPayloads())
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataZeroWidthRecords(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A row can carry several records back-to-back with nothing rendered in
|
||||
// between, making all but the last record's region zero-width. Those
|
||||
// payloads must survive as payloads of the row (via content-less carrier
|
||||
// cells) rather than being clobbered by the record that follows.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
// difftastic's combined file+hunk banner: the f is immediately
|
||||
// followed by the h.
|
||||
osc("1;f;;;foo.txt") + osc("1;h;5;;foo.txt") + "foo.txt --- Go",
|
||||
// A modification row collapsed to a single column carries its
|
||||
// deletion and its addition back-to-back before the row's content.
|
||||
osc("1;d;5;5;foo.txt") + osc("1;a;5;;foo.txt") + "595 new content",
|
||||
// A record emitted right before the line end covers no cell either.
|
||||
"trailing" + osc("1;d;6;6;foo.txt"),
|
||||
}, "\n") + "\n")
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"1;f;;;foo.txt", "1;h;5;;foo.txt"},
|
||||
{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"},
|
||||
{"1;d;6;6;foo.txt"},
|
||||
}, v.DiffLineMetadataPayloads())
|
||||
|
||||
// The carrier cells are invisible: the rendered text is unchanged.
|
||||
assert.Equal(t, []string{
|
||||
"foo.txt --- Go",
|
||||
"595 new content",
|
||||
"trailing",
|
||||
}, v.BufferLines())
|
||||
|
||||
// A row's single-payload identity is its first record: the f of a banner,
|
||||
// the d of a collapsed modification row.
|
||||
payload, ok := v.DiffLineMetadataInLine(0)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "1;f;;;foo.txt", payload)
|
||||
payload, ok = v.DiffLineMetadataInLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "1;d;5;5;foo.txt", payload)
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataHandshakeSwallowed(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A metadata-aware pager emits a version-only handshake (an OSC 1717 with no
|
||||
// fields) as its first output, immediately before the diff, to announce it speaks
|
||||
// the protocol. It must be swallowed whole: no visible bytes, no phantom line, and
|
||||
// crucially it must not attach as metadata to the diff header that follows it.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(osc("1") + strings.Join([]string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
osc("1;a;1;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
// The handshake produced no phantom line and no visible bytes.
|
||||
assert.Equal(t, []string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
"added",
|
||||
}, v.BufferLines())
|
||||
|
||||
// The handshake didn't bleed onto the header line, and the real per-line metadata
|
||||
// after it still applies.
|
||||
type result struct {
|
||||
payload string
|
||||
ok bool
|
||||
}
|
||||
got := make([]result, len(v.buf.lines))
|
||||
for y := range v.buf.lines {
|
||||
payload, ok := v.DiffLineMetadataInLine(y)
|
||||
got[y] = result{payload, ok}
|
||||
}
|
||||
assert.Equal(t, []result{
|
||||
{"", false},
|
||||
{"1;a;1;;foo.txt", true},
|
||||
}, got)
|
||||
}
|
||||
|
||||
// When a re-render produces fewer view lines than the previous one,
|
||||
// refreshViewLinesIfNeeded must truncate viewLines to the new content. If it
|
||||
// didn't (it used to overwrite in place and keep the tail), a reader could map a
|
||||
// view line that no longer exists onto the wrong buffer line — and with wrapping
|
||||
// the stale entry's buffer index can still be in range of the new, shorter,
|
||||
// less-wrapped buffer, so an in-range guard alone wouldn't catch it. See
|
||||
// diff-line-metadata-notes.md §8.
|
||||
func TestBufferLineForViewLineStaleTail(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// First render: two lines that each wrap into three view lines, so the
|
||||
// buffer has 2 lines but there are 6 view lines.
|
||||
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
|
||||
// Re-render with shorter content (rewind, then overwrite from the top with
|
||||
// three short, unwrapped lines). There are now only 3 view lines.
|
||||
v.Reset()
|
||||
v.writeString("aaa\nbbb\nccc")
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
|
||||
// A real view line maps to its buffer line as usual.
|
||||
bufferLine, ok := v.BufferLineForViewLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, bufferLine)
|
||||
|
||||
// View line 4 no longer exists in the current buffer, so the mapping must
|
||||
// fail rather than land on a stale entry from the previous render.
|
||||
_, ok = v.BufferLineForViewLine(4)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -383,12 +171,8 @@ func TestOffscreenRender(t *testing.T) {
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("w\nx\ny\nz")
|
||||
|
||||
// The displayed buffer is untouched: readers still see the previous render,
|
||||
// and the view-line→buffer-line mapping stays consistent with it.
|
||||
// The displayed buffer is untouched: readers still see the previous render.
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
bufferLine, ok := v.BufferLineForViewLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, bufferLine)
|
||||
|
||||
// Swapping in reveals the new content in one step.
|
||||
v.SwapInOffscreenRender()
|
||||
@@ -399,54 +183,25 @@ func TestOffscreenRender(t *testing.T) {
|
||||
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// The escape restore scans the *incoming* content of a re-render as it loads,
|
||||
// before it is swapped in, so it can find the row matching a target identity and
|
||||
// decide when to swap. That means reading the off-screen buffer's loaded rows
|
||||
// (text, metadata, hyperlink) while the displayed buffer still shows the old
|
||||
// render. See View.offscreen / OffscreenDiffLineContents.
|
||||
func TestOffscreenDiffLineContents(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// No off-screen render in progress: nothing to scan.
|
||||
assert.Nil(t, v.OffscreenDiffLineContents())
|
||||
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Join([]string{
|
||||
osc("1;c;1;;foo.txt") + "context",
|
||||
osc("1;a;2;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
contents := v.OffscreenDiffLineContents()
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "context", Metadata: "1;c;1;;foo.txt"},
|
||||
{Text: "added", Metadata: "1;a;2;;foo.txt"},
|
||||
}, contents)
|
||||
|
||||
// The displayed buffer is still empty; the scan reads the off-screen render.
|
||||
assert.Empty(t, v.BufferLines())
|
||||
}
|
||||
|
||||
// The escape restore matches a target identity against a buffer line, then needs
|
||||
// the view line that renders it to scroll there and select it. ViewLineForBufferLine
|
||||
// is that inverse mapping, and it must point at the *first* of the (wrapped) view
|
||||
// lines a buffer line spans.
|
||||
func TestViewLineForBufferLine(t *testing.T) {
|
||||
// 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
|
||||
|
||||
// Buffer line 0 is short (one view line); buffer line 1 wraps into three view
|
||||
// lines (view lines 1, 2, 3); buffer line 2 is short again (view line 4).
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
// 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())
|
||||
|
||||
for bufferLine, wantViewLine := range map[int]int{0: 0, 1: 1, 2: 4} {
|
||||
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.ViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
// 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
|
||||
@@ -622,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
|
||||
@@ -684,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) {
|
||||
@@ -874,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.
|
||||
@@ -900,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++ {
|
||||
@@ -923,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++ {
|
||||
@@ -951,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
|
||||
@@ -985,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.
|
||||
@@ -1004,76 +780,3 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
||||
"trailing cell at (%d, 2) should have green bg", x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInclusionGutter verifies the on-demand inclusion gutter reserves a
|
||||
// left-hand column, draws the marker glyph on marked lines only, and shifts the
|
||||
// content right past it.
|
||||
func TestInclusionGutter(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// InnerWidth=10; the frame inset of 1 places view x=0 at screen x=1.
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
v.writeString("aaa\nbbb\nccc\n")
|
||||
|
||||
// The gutter is 2 columns wide (marker + separator); mark the middle line.
|
||||
v.SetInclusionGutter(true, []bool{false, true, false})
|
||||
v.draw()
|
||||
|
||||
// The marker appears at the gutter's first column (view x=0 → screen x=1) on
|
||||
// the marked line only.
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "marked line shows the gutter marker")
|
||||
chr, _, _ = Screen.Get(1, 3)
|
||||
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
|
||||
|
||||
// The content is shifted right past the 2-column gutter (view x=2 → screen x=3).
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "a", chr, "content is shifted past the gutter")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "b", chr)
|
||||
chr, _, _ = Screen.Get(3, 3)
|
||||
assert.Equal(t, "c", chr)
|
||||
|
||||
// Hiding the gutter again returns the content flush left (view x=0 → screen x=1).
|
||||
v.SetInclusionGutter(false, nil)
|
||||
v.draw()
|
||||
chr, _, _ = Screen.Get(1, 1)
|
||||
assert.Equal(t, "a", chr, "content is flush left with no gutter")
|
||||
}
|
||||
|
||||
// TestInclusionGutterMarkerOnEverySegment verifies that a marked buffer line that
|
||||
// wraps shows the marker on every wrapped segment, and that the gutter narrows the
|
||||
// content wrap width.
|
||||
func TestInclusionGutterMarkerOnEverySegment(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth=10
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
// 10 cells; with a 2-column gutter the content wrap width is 8, so this wraps
|
||||
// to "01234567" / "89".
|
||||
v.writeString("0123456789\n")
|
||||
v.SetInclusionGutter(true, []bool{true})
|
||||
v.draw()
|
||||
|
||||
// First segment: marker present, content starts at screen x=3 and the eighth
|
||||
// content cell ("7") sits at the right edge (screen x=10).
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, "✓", chr)
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "0", chr)
|
||||
chr, _, _ = Screen.Get(10, 1)
|
||||
assert.Equal(t, "7", chr)
|
||||
|
||||
// Continuation segment: marker too, content resumes at screen x=3.
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "continuation segment also shows the marker")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "8", chr)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+31
-17
@@ -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()
|
||||
@@ -373,15 +399,3 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
|
||||
|
||||
panic("context not in stack")
|
||||
}
|
||||
|
||||
// IsInStack reports whether the given context is somewhere in the context stack.
|
||||
// Unlike NextInStack it never panics, so it's safe to call before dereferencing
|
||||
// the stack from a caller that might run with the context off-stack.
|
||||
func (self *ContextMgr) IsInStack(c types.Context) bool {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
|
||||
return other.GetKey() == c.GetKey()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,29 +13,30 @@ type BaseContext struct {
|
||||
windowName string
|
||||
onGetOptionsMap func() map[string]string
|
||||
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
focusedMainViewActions types.FocusedMainViewActions
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onClickFocusedMainViewFn onClickFocusedMainViewFn
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
hasControlledBounds bool
|
||||
needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
|
||||
needsRerenderOnHeightChange bool
|
||||
highlightOnFocus bool
|
||||
hasSelectableContent bool
|
||||
|
||||
*ParentContextMgr
|
||||
}
|
||||
|
||||
type (
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
|
||||
)
|
||||
|
||||
var _ types.IBaseContext = &BaseContext{}
|
||||
@@ -48,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
|
||||
|
||||
@@ -69,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{},
|
||||
@@ -101,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
|
||||
}
|
||||
@@ -113,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
|
||||
@@ -144,7 +153,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.focusedMainViewActions = nil
|
||||
self.onClickFocusedMainViewFn = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -166,12 +175,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddFocusedMainViewActions(actions types.FocusedMainViewActions) {
|
||||
if actions != nil {
|
||||
if self.focusedMainViewActions != nil {
|
||||
panic("only one controller is allowed to set the focused main view actions")
|
||||
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
|
||||
if fn != nil {
|
||||
if self.onClickFocusedMainViewFn != nil {
|
||||
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
|
||||
}
|
||||
self.focusedMainViewActions = actions
|
||||
self.onClickFocusedMainViewFn = fn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +192,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
|
||||
return self.onClickFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self.focusedMainViewActions
|
||||
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
|
||||
return self.onClickFocusedMainViewFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
|
||||
@@ -215,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
|
||||
|
||||
@@ -19,16 +19,11 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
@@ -85,17 +80,10 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
|
||||
}
|
||||
|
||||
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
|
||||
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
|
||||
}
|
||||
|
||||
// FromAndToForDiff derives the diff endpoints for a ref (or a range of refs): a range
|
||||
// diffs its parent-of-from against to, a single ref its parent against itself. It's
|
||||
// shared by the commit files context and by patch building straight from the commits /
|
||||
// sub-commits / stash main views, which build a patch for the panel's selected ref.
|
||||
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
|
||||
if refRange != nil {
|
||||
return refRange.From.ParentRefName(), refRange.To.RefName()
|
||||
if refs := self.GetRefRange(); refs != nil {
|
||||
return refs.From.ParentRefName(), refs.To.RefName()
|
||||
}
|
||||
ref := self.GetRef()
|
||||
return ref.ParentRefName(), ref.RefName()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -31,16 +31,11 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -8,61 +8,10 @@ import (
|
||||
type MainContext struct {
|
||||
*SimpleContext
|
||||
*SearchTrait
|
||||
|
||||
diffSelect DiffSelectState
|
||||
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so a drag
|
||||
// that follows can anchor its range there. The click can show a whole hunk (a range
|
||||
// with a different anchor), so the clicked line can't be read back from the view.
|
||||
dragAnchorViewLine int
|
||||
}
|
||||
|
||||
var _ types.ISearchableContext = (*MainContext)(nil)
|
||||
|
||||
// DiffSelectMode is how the focused main view's diff selection extends from the
|
||||
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
|
||||
// around the cursor.
|
||||
type DiffSelectMode int
|
||||
|
||||
const (
|
||||
DiffSelectModeLine DiffSelectMode = iota
|
||||
DiffSelectModeRange
|
||||
DiffSelectModeHunk
|
||||
)
|
||||
|
||||
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
|
||||
// selected line and the range anchor themselves live in the gocui view (its
|
||||
// cursor and rangeSelectStartY, i.e. native range select); only the mode lives
|
||||
// here. It's on the context, not the controller, so that the main view controller
|
||||
// (which drives the selection), the focus controller (which resets it on focus),
|
||||
// and togglePanel (which sets it on the other pane) can all reach it via the
|
||||
// context they already hold.
|
||||
type DiffSelectState struct {
|
||||
Mode DiffSelectMode
|
||||
// When a range is sticky, moving the cursor without holding shift extends the
|
||||
// range; otherwise it collapses the range back to a single line.
|
||||
RangeIsSticky bool
|
||||
// Whether the user turned on hunk mode explicitly, as opposed to it being the
|
||||
// configured default; this decides whether escape leaves hunk mode.
|
||||
UserEnabledHunkMode bool
|
||||
}
|
||||
|
||||
// DiffSelectState returns the focused main view's selection mode state, for the
|
||||
// controllers to read and mutate directly.
|
||||
func (self *MainContext) DiffSelectState() *DiffSelectState {
|
||||
return &self.diffSelect
|
||||
}
|
||||
|
||||
// SetDragAnchorViewLine records the view line a mouse-down landed on, so a drag that
|
||||
// follows can anchor its range there (see dragAnchorViewLine).
|
||||
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
|
||||
self.dragAnchorViewLine = viewLine
|
||||
}
|
||||
|
||||
// DragAnchorViewLine returns the view line the last mouse-down landed on.
|
||||
func (self *MainContext) DragAnchorViewLine() int {
|
||||
return self.dragAnchorViewLine
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
windowName string,
|
||||
@@ -72,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,
|
||||
|
||||
@@ -20,11 +20,6 @@ type PatchExplorerContext struct {
|
||||
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
|
||||
// search result index.
|
||||
inOnSelectItemCallback bool
|
||||
|
||||
// Set when this patch explorer was entered from a focused main view, so that
|
||||
// escaping returns there; nil for the normal flow. See
|
||||
// types.FocusedMainViewSnapshot.
|
||||
focusedMainViewSnapshot *types.FocusedMainViewSnapshot
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -52,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),
|
||||
@@ -65,14 +60,6 @@ func NewPatchExplorerContext(
|
||||
|
||||
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
|
||||
|
||||
func (self *PatchExplorerContext) GetFocusedMainViewSnapshot() *types.FocusedMainViewSnapshot {
|
||||
return self.focusedMainViewSnapshot
|
||||
}
|
||||
|
||||
func (self *PatchExplorerContext) SetFocusedMainViewSnapshot(snapshot *types.FocusedMainViewSnapshot) {
|
||||
self.focusedMainViewSnapshot = snapshot
|
||||
}
|
||||
|
||||
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
|
||||
return self.state
|
||||
}
|
||||
|
||||
@@ -14,17 +14,10 @@ type ReflogCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
)
|
||||
|
||||
// Reflog shows a commit's diff but has no primary action (building a patch from it is
|
||||
// a deferred gap), so the focused main view has a selection but space does nothing.
|
||||
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Commit { return c.Model().FilteredReflogCommits },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,15 +12,10 @@ type StashContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.DiffMainViewContext = (*StashContext)(nil)
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
)
|
||||
|
||||
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewStashContext(
|
||||
c *ContextCommon,
|
||||
) *StashContext {
|
||||
|
||||
@@ -21,16 +21,11 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,15 +15,10 @@ type WorkingTreeContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
)
|
||||
|
||||
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeStaging
|
||||
}
|
||||
|
||||
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
viewModel := filetree.NewFileTreeViewModel(
|
||||
func() []*models.File { return c.Model().Files },
|
||||
|
||||
@@ -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}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+15
-6
@@ -52,9 +52,8 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
|
||||
gpgHelper := helpers.NewGpgHelper(helperCommon)
|
||||
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
|
||||
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
|
||||
stagingHelper := helpers.NewStagingHelper(helperCommon, windowHelper)
|
||||
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon, stagingHelper)
|
||||
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
|
||||
stagingHelper := helpers.NewStagingHelper(helperCommon)
|
||||
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
|
||||
searchHelper := helpers.NewSearchHelper(helperCommon)
|
||||
|
||||
@@ -74,6 +73,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
rebaseHelper,
|
||||
)
|
||||
bisectHelper := helpers.NewBisectHelper(helperCommon)
|
||||
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
|
||||
modeHelper := helpers.NewModeHelper(
|
||||
helperCommon,
|
||||
diffHelper,
|
||||
@@ -108,7 +108,6 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
FixupHelper: helpers.NewFixupHelper(helperCommon),
|
||||
Commits: commitsHelper,
|
||||
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
|
||||
CommitFiles: helpers.NewCommitFilesHelper(helperCommon, patchBuildingHelper),
|
||||
Snake: helpers.NewSnakeHelper(helperCommon),
|
||||
Diff: diffHelper,
|
||||
Repos: reposHelper,
|
||||
@@ -139,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,
|
||||
)
|
||||
@@ -157,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(
|
||||
@@ -174,6 +176,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
contextLinesController := controllers.NewContextLinesController(common)
|
||||
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
|
||||
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
|
||||
|
||||
branchesController := controllers.NewBranchesController(common)
|
||||
gitFlowController := controllers.NewGitFlowController(common)
|
||||
@@ -310,11 +313,13 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
controllers.AttachControllers(gui.State.Contexts.Normal,
|
||||
mainViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
|
||||
secondaryViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Files,
|
||||
@@ -357,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Menu,
|
||||
menuController,
|
||||
menuListController,
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.CommitMessage,
|
||||
@@ -410,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
|
||||
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
|
||||
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
|
||||
context.AddOnClickFn(controller.GetOnClick())
|
||||
context.AddFocusedMainViewActions(controller.GetFocusedMainViewActions())
|
||||
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
|
||||
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
|
||||
context.AddOnFocusFn(controller.GetOnFocus())
|
||||
context.AddOnFocusLostFn(controller.GetOnFocusLost())
|
||||
|
||||
@@ -19,7 +19,7 @@ func (self *baseController) GetOnDoubleClick() func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -176,19 +176,8 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
|
||||
paths := self.pathsForDiff(node)
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false, renderRaw)
|
||||
task := types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
|
||||
|
||||
// Keep the inclusion gutter in step with the content as this diff (re-)renders.
|
||||
// It's a no-op unless the main view is focused and a patch is being built (see
|
||||
// RefreshInclusionGutter); a patch toggle re-renders this same diff, so the marks
|
||||
// recomputed over the current content stay valid through the swap.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a commit rewrite. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
|
||||
task := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
@@ -206,7 +195,7 @@ func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMess
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true, false)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true)
|
||||
diff, err := cmdObj.RunWithOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -503,7 +492,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
})
|
||||
}
|
||||
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
@@ -514,7 +503,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -529,8 +518,68 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode)
|
||||
return self.toggleForPatch([]*filetree.CommitFileNode{root})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) startPatchBuilder() error {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
canRebase := commitFilesContext.GetCanRebase()
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
|
||||
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
from, to := commitFilesContext.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
return from, to, reverse
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
|
||||
if node.File == nil {
|
||||
return self.handleToggleCommitFileDirCollapsed(node)
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
Prompt: self.c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
|
||||
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
|
||||
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
|
||||
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
|
||||
@@ -557,94 +606,19 @@ func (self *CommitFilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
||||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
|
||||
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.getSelectedItem()
|
||||
if node == nil {
|
||||
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath = "./" + relativePath
|
||||
self.context().CommitFileTreeViewModel.ExpandToPath(relativePath)
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
|
||||
if ok {
|
||||
self.context().SetSelectedLineIdx(idx)
|
||||
self.context().GetViewTrait().FocusPoint(
|
||||
self.context().ModelIndexToViewIndex(idx), false)
|
||||
node = self.context().GetSelected()
|
||||
}
|
||||
}
|
||||
|
||||
// Entered from the focused main view, so escaping returns there.
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
|
||||
// PrimaryAction toggles the selected diff line(s) into or out of the custom patch when
|
||||
// space is pressed in the focused main view of a commit's files. The per-file diff's
|
||||
// patch target comes from the commit files context. It refreshes normally afterwards so
|
||||
// the file's patch-status indicator in the browser updates along with the secondary patch
|
||||
// view (the commits / sub-commits / stash panels, which build from the whole-commit diff,
|
||||
// have no such indicator and refresh more cheaply).
|
||||
func (self *CommitFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
canRebase := self.context().GetCanRebase()
|
||||
return primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
|
||||
from, to, reverse, canRebase,
|
||||
func() {
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardSelection removes the selected diff line(s) from the commit the focused main
|
||||
// view shows the files of, via a rebase. Same target derivation as the patch toggle.
|
||||
func (self *CommitFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
canRebase := self.context().GetCanRebase()
|
||||
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
||||
return discardFromCommitDisabledReason(self.c, mainViewName, self.context().GetCanRebase())
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
@@ -50,6 +51,10 @@ func (self *ContextLinesController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) Increase() error {
|
||||
if err := self.checkCanChangeContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 {
|
||||
self.c.UserConfig().Git.DiffContextSize++
|
||||
}
|
||||
@@ -57,6 +62,10 @@ func (self *ContextLinesController) Increase() error {
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) Decrease() error {
|
||||
if err := self.checkCanChangeContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize > 0 {
|
||||
self.c.UserConfig().Git.DiffContextSize--
|
||||
}
|
||||
@@ -74,13 +83,15 @@ func (self *ContextLinesController) applyChange() error {
|
||||
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
|
||||
default:
|
||||
// The new context size re-renders the diff with a different git command, which
|
||||
// would otherwise reset the main view to the top. Preserve where it was
|
||||
// scrolled (and its selection, if the focused main view is showing one)
|
||||
// instead. This covers both the focused main view and the side panels, since
|
||||
// they all render their diff into the same "main" view.
|
||||
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
currentContext.HandleRenderToMain()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) checkCanChangeContext() error {
|
||||
if self.c.Git().Patch.PatchBuilder.Active() {
|
||||
return errors.New(self.c.Tr.CantChangeContextSizeError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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+"/")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
@@ -131,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),
|
||||
@@ -250,11 +250,6 @@ func (self *FilesController) GetOnRenderToMain() func() {
|
||||
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
||||
node := self.context().GetSelected()
|
||||
|
||||
// Default the focused-main-view selection off; the real-diff branch below
|
||||
// turns it back on. Every other outcome (no file, merge conflict) renders
|
||||
// non-diff content with nothing to select.
|
||||
updateFocusedMainViewSelectionVisibility(self.c, false, false)
|
||||
|
||||
if node == nil {
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
|
||||
return
|
||||
@@ -353,7 +348,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
|
||||
|
||||
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}, false)
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
@@ -371,15 +366,11 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
split, mainShowsStaged := self.diffSplitState(node)
|
||||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
// When the focused main view needs to act on a diff the configured pager
|
||||
// can't resolve, render it raw (no pager) so it's stageable; browsing keeps
|
||||
// the pretty pager output. See StagingHelper.DiffMainViewShouldRenderRaw.
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, renderRaw, 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
|
||||
@@ -387,14 +378,14 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, renderRaw, pathOverrides)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
@@ -404,15 +395,10 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
// A real diff is being shown, so the focused main view has something to select:
|
||||
// restore the selection on whichever pane is focused (the secondary only when
|
||||
// the diff is split across both).
|
||||
updateFocusedMainViewSelectionVisibility(self.c, true, split)
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
}
|
||||
|
||||
@@ -422,256 +408,14 @@ func (self *FilesController) GetOnDoubleClick() func() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *FilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
||||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
|
||||
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath = "./" + relativePath
|
||||
self.context().FileTreeViewModel.ExpandToPath(relativePath)
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
|
||||
if ok {
|
||||
self.context().SetSelectedLineIdx(idx)
|
||||
self.context().GetViewTrait().FocusPoint(
|
||||
self.context().ModelIndexToViewIndex(idx), false)
|
||||
}
|
||||
}
|
||||
|
||||
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
|
||||
// diffSplitState reports, for the given file node, how the focused main view lays
|
||||
// out its diff: whether it's split into unstaged (Normal) and staged
|
||||
// (NormalSecondary) halves, and — when not split — whether the single Normal view
|
||||
// shows the staged diff (which happens when the file has only staged changes).
|
||||
// GetOnRenderToMain and PrimaryAction share this so the staging direction
|
||||
// can't drift from what's on screen.
|
||||
func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool, mainShowsStaged bool) {
|
||||
split = self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged = !split && node.GetHasStagedChanges()
|
||||
return split, mainShowsStaged
|
||||
}
|
||||
|
||||
// PrimaryAction stages (or unstages) the selected diff line(s) when space is pressed in
|
||||
// the focused main view of the working-tree files panel.
|
||||
func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The whole diff shown in the main view is on one side; space unstages when that's
|
||||
// the staged side (reverse), otherwise it stages. Either way it applies to the index.
|
||||
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason: discarding from the working tree is always available
|
||||
// (a zero-context diff is reported as an error from DiscardSelection itself, matching the
|
||||
// staging view).
|
||||
// DiscardSelectionDisabledReason ignores which pane the action came from: discarding from
|
||||
// the working tree is meaningful in both the unstaged and staged (secondary) panes (the
|
||||
// staged side just unstages), unlike the commit panels' custom-patch preview.
|
||||
func (self *FilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discarding always reverses the change. On the staged side that's just unstaging
|
||||
// (apply reverse to the index) — same as space there — so it follows the identical
|
||||
// path and isn't destructive, hence no confirm. On the unstaged side it removes the
|
||||
// change from the working tree (reverse, not cached), which is destructive.
|
||||
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
|
||||
types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardChangeTitle,
|
||||
Prompt: self.c.Tr.DiscardChangePrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// diffLineSelection resolves the change lines the user has selected in the focused main
|
||||
// view and which side of the diff they're on. onStagedSide is true when the diff shown
|
||||
// is the staged side — the secondary half of a split, or the main half of an only-staged
|
||||
// file; it's the same for every file in a multi-file (directory) diff. ok is false when
|
||||
// there's no file or the selection covers no change lines (the caller does nothing).
|
||||
func (self *FilesController) diffLineSelection(mainViewName string, firstLineIdx int, lastLineIdx int) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
infos = self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if len(infos) == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
_, mainShowsStaged := self.diffSplitState(node)
|
||||
onStagedSide = mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
|
||||
return infos, onStagedSide, true
|
||||
}
|
||||
|
||||
// applyDiffLineSelection applies the selected change lines (grouped per file for a
|
||||
// multi-file diff) with opts, then re-establishes the selection and focus the way the
|
||||
// staging view does — shared by the primary action (stage/unstage) and discard, since
|
||||
// discarding on the staged side is just unstaging and must follow the identical path.
|
||||
// onStagedSide names which diff each file's patch is read from and drives the focus-
|
||||
// follow; opts says how to apply it.
|
||||
func (self *FilesController) applyDiffLineSelection(mainViewName string, firstLineIdx int, infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts) error {
|
||||
// A directory diff spans several files; group the selected change lines by
|
||||
// file and apply one patch per file.
|
||||
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
|
||||
for path, fileInfos := range infosByFile {
|
||||
file := self.fileForDiffLinePath(path)
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
if err := self.applyDiffLines(file, fileInfos, onStagedSide, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
|
||||
|
||||
// Focus follows the side that was acted on. The unstaged side stays in the main half
|
||||
// (which always holds the unstaged side, or the staged side once the file has only
|
||||
// staged changes). The staged side lives in the secondary half once the file is split
|
||||
// into staged + unstaged, and moves back to the main half when the staged side empties
|
||||
// and the split collapses. The model is up to date now (Refresh above is synchronous),
|
||||
// so the post-op split is read from the freshly selected node.
|
||||
focusViewName := self.c.Contexts().Normal.GetViewName()
|
||||
if onStagedSide {
|
||||
if node := self.context().GetSelected(); node != nil {
|
||||
if split, _ := self.diffSplitState(node); split {
|
||||
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Refresh above queued the main-view re-render; re-establish the selection at its
|
||||
// change-line ordinal once that render lands, and focus the pane now holding the
|
||||
// acted-on side.
|
||||
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx, 0)
|
||||
if focusViewName != mainViewName {
|
||||
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the
|
||||
// diff-line metadata) to the working-tree file it belongs to, or nil if it isn't a
|
||||
// tracked working-tree file.
|
||||
func (self *FilesController) fileForDiffLinePath(path string) *models.File {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
|
||||
}
|
||||
|
||||
// applyDiffLines applies, to the diff lines identified by infos (a single line, a
|
||||
// range, or a hunk) — all belonging to file — a patch built from the file's staged or
|
||||
// unstaged diff (sourceCached: the side the selection was made on) and applied per opts:
|
||||
// - stage: read unstaged, apply forward to the index (sourceCached=false, Reverse=false, Cached=true)
|
||||
// - unstage: read staged, apply reverse to the index (sourceCached=true, Reverse=true, Cached=true)
|
||||
// - discard: read the shown side, apply reverse — not cached on the unstaged side
|
||||
// removes the change from the working tree, cached on the staged side just unstages it.
|
||||
//
|
||||
// The read side and the apply direction are independent (they only coincide for
|
||||
// staging/unstaging), so they're passed separately: the patch lines are matched in the
|
||||
// diff actually shown, then Transform reverses them to match opts. A selection covering
|
||||
// no change lines yields an empty patch and is a no-op. The caller logs the action and
|
||||
// refreshes, once, around the (possibly several) files it touches.
|
||||
//
|
||||
// Each selected change line is keyed by its (file line number, deletion?) identity,
|
||||
// and the freshly parsed patch is scanned for the body lines matching those
|
||||
// identities. Scanning the patch and matching identities — rather than looking each
|
||||
// line number up with PatchLineForLineNumber — is what makes a modified line work: a
|
||||
// deletion and the addition replacing it share a position but have distinct
|
||||
// identities, and the line-number lookup can't tell an addition at the start of a
|
||||
// hunk from the deletion above it.
|
||||
func (self *FilesController) applyDiffLines(file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts) error {
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, sourceCached))
|
||||
|
||||
type changeLineKey struct {
|
||||
lineNumber int
|
||||
isDeletion bool
|
||||
}
|
||||
selected := make(map[changeLineKey]bool, len(infos))
|
||||
for _, info := range infos {
|
||||
lineNumber, isDeletion := info.PatchSelectLine()
|
||||
selected[changeLineKey{lineNumber, isDeletion}] = true
|
||||
}
|
||||
|
||||
var patchLineIndices []int
|
||||
for idx, line := range parsedPatch.Lines() {
|
||||
var key changeLineKey
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
key = changeLineKey{parsedPatch.LineNumberOfLine(idx), false}
|
||||
case line.IsDeletion():
|
||||
key = changeLineKey{parsedPatch.OldLineNumberOfLine(idx), true}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if selected[key] {
|
||||
patchLineIndices = append(patchLineIndices, idx)
|
||||
}
|
||||
}
|
||||
|
||||
patchToApply := parsedPatch.
|
||||
Transform(patch.TransformOpts{
|
||||
Reverse: opts.Reverse,
|
||||
IncludedLineIndices: patchLineIndices,
|
||||
FileNameOverride: file.GetPath(),
|
||||
}).
|
||||
FormatPlain()
|
||||
if patchToApply == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.c.Git().Patch.ApplyPatch(patchToApply, opts)
|
||||
}
|
||||
|
||||
// if we are dealing with a status for which there is no key in this map,
|
||||
@@ -899,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
|
||||
@@ -958,7 +692,7 @@ func (self *FilesController) getSelectedFile() *models.File {
|
||||
}
|
||||
|
||||
func (self *FilesController) enter() error {
|
||||
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
|
||||
}
|
||||
|
||||
func (self *FilesController) collapseAll() error {
|
||||
@@ -977,11 +711,7 @@ func (self *FilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// focusedMainViewSnapshot records the focused main view to return to when
|
||||
// escaping the staging view, for the case where we're entering it straight from
|
||||
// there; it's nil for the normal flow that goes through the files panel. See
|
||||
// types.FocusedMainViewSnapshot.
|
||||
func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
|
||||
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return nil
|
||||
@@ -1008,14 +738,6 @@ func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMai
|
||||
}
|
||||
|
||||
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
||||
// Record the focused-main-view return on *both* staging halves, not just the
|
||||
// one we're entering: staging the last unstaged hunk (or tabbing) moves the
|
||||
// selection to the other half, and escaping from there must still know to
|
||||
// return to the focused main view rather than falling back to the files panel.
|
||||
// Set on every entry (nil for a normal entry through the files panel) so a
|
||||
// snapshot can't leak from a previous main-view entry into a subsequent normal one.
|
||||
self.c.Contexts().Staging.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
||||
self.c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
||||
self.c.Context().Push(context, opts)
|
||||
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
||||
|
||||
@@ -1185,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
|
||||
@@ -1799,7 +1532,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
|
||||
}
|
||||
|
||||
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
||||
}
|
||||
|
||||
func (self *FilesController) fetch() error {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -185,21 +185,11 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
|
||||
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
|
||||
// takes effect, and shows a toast naming it.
|
||||
func (self *GlobalController) onDiffRenderersChanged() {
|
||||
self.applyCurrentPagerSelectionStyle()
|
||||
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
// Switching pagers re-renders the diff. Keep it anchored on the same patch
|
||||
// line across the switch, restoring by patch identity rather than raw line
|
||||
// number: two pagers can structure the same diff very differently (side-by-
|
||||
// side vs inline), so the same screen line means something different after.
|
||||
// Without this a plain pager swap keeps the old origin (the git command is
|
||||
// unchanged) but now pointing at the wrong content, while an entry with its
|
||||
// own externalDiffCommand changes the git command and so resets to the top.
|
||||
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
|
||||
@@ -213,15 +203,6 @@ func (self *GlobalController) onDiffRenderersChanged() {
|
||||
}))
|
||||
}
|
||||
|
||||
func (self *GlobalController) applyCurrentPagerSelectionStyle() {
|
||||
bgColorWidth := 0
|
||||
if self.c.State().GetDiffRendererConfigManager().GetNarrowSelectionHighlight() {
|
||||
bgColorWidth = 2
|
||||
}
|
||||
self.c.Contexts().Normal.GetView().SelectedLineBgColorWidth = bgColorWidth
|
||||
self.c.Contexts().NormalSecondary.GetView().SelectedLineBgColorWidth = bgColorWidth
|
||||
}
|
||||
|
||||
func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
|
||||
_, total := self.c.State().GetDiffRendererConfigManager().CurrentDiffRendererIndex()
|
||||
if total <= 1 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user