mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
Merge branch 'master' into f/open-in-terminal
This commit is contained in:
commit
8de0f12199
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,3 +1,3 @@
|
|||
*.go text
|
||||
*.go text eol=lf
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
|
|
|
|||
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
|
|
@ -24,7 +24,7 @@
|
|||
{
|
||||
"label": "Run current file integration test",
|
||||
"type": "shell",
|
||||
"command": "go run cmd/integration_test/main.go cli ${relativeFile}",
|
||||
"command": "just e2e ${relativeFile}",
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
|
|
@ -61,18 +61,6 @@
|
|||
"focus": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Open deprecated test TUI",
|
||||
"type": "shell",
|
||||
"command": "go run pkg/integration/deprecated/cmd/tui/main.go",
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
},
|
||||
"presentation": {
|
||||
"focus": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Sync tests list",
|
||||
"type": "shell",
|
||||
|
|
|
|||
49
AGENTS.md
49
AGENTS.md
|
|
@ -21,8 +21,9 @@ Windows box has only `just`).
|
|||
- `just format` — `gofumpt -l -w .`. Run before every commit.
|
||||
- `just build` — build the binary.
|
||||
- `just unit-test` — `go test ./... -short`.
|
||||
- `just e2e-all` — run all integration tests headlessly (`just e2e <name>` runs a
|
||||
single one with a visible UI).
|
||||
- `just e2e` — run all integration tests headlessly; `just e2e <name>` runs a
|
||||
single one headlessly too. `just e2e-cli <name>` runs one with a visible UI
|
||||
(most useful with `--sandbox` or `--slow`).
|
||||
- `just lint` — run golangci-lint.
|
||||
|
||||
## When to commit
|
||||
|
|
@ -45,7 +46,7 @@ while still being meaningful and self-contained.
|
|||
|
||||
- **Every commit must compile and pass all tests.** No "WIP" commits, no
|
||||
commits that leave the tree broken and rely on a follow-up to fix it.
|
||||
- **Every commit must be `gofumpt`-formatted.** Run `make format` before
|
||||
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
|
||||
committing.
|
||||
- **Commit messages explain _why_, not _what_.** The diff already shows what
|
||||
changed; the message should capture the motivation, the constraint, or the
|
||||
|
|
@ -137,6 +138,22 @@ commit. If you have two independent refinements for the same target, make
|
|||
two separate fixups. Reviewability of the intermediate state matters even
|
||||
when the end state after autosquash would be identical.
|
||||
|
||||
## Surface mid-implementation decisions; decide them together
|
||||
|
||||
Planning can't anticipate everything. When a decision surfaces while you're
|
||||
implementing — a design choice, a tradeoff, a scope cut, a "this turned out
|
||||
harder than expected, so maybe X" — don't quietly make the call and keep
|
||||
going, even if you have a clear recommendation and even if the call seems
|
||||
small. Stop, lay out the options and your recommendation, and let me weigh in.
|
||||
I want to make these calls _with_ you, not discover them after the fact in the
|
||||
diff.
|
||||
|
||||
This isn't a request to stop and ask about every trivial detail; obvious
|
||||
mechanical choices with one sensible answer don't need a checkpoint. It's about
|
||||
genuine forks — the ones where a reasonable person might pick differently, or
|
||||
where you'd be trading away something the plan assumed (scope, UX, performance,
|
||||
reload behavior, …). When in doubt, surface it.
|
||||
|
||||
## Prefer the cleaner design over the smaller diff
|
||||
|
||||
When a task could be implemented either by tacking onto existing code or by
|
||||
|
|
@ -235,6 +252,30 @@ keep the call site fluent.
|
|||
Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure
|
||||
messages are more useful and the intent is clearer at a glance.
|
||||
|
||||
## Translatable strings use Go templates, not `%s`
|
||||
|
||||
Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable
|
||||
strings — the fields of `TranslationSet` and `Actions` in
|
||||
`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with
|
||||
`utils.ResolvePlaceholderString`:
|
||||
|
||||
```go
|
||||
// in english.go
|
||||
DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?",
|
||||
|
||||
// at the call site
|
||||
utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteBranchTitle,
|
||||
map[string]string{"selectedBranchName": branchName},
|
||||
)
|
||||
```
|
||||
|
||||
Named placeholders tell localizers what each value is (a bare `%s` says
|
||||
nothing, and translators can't safely reorder positional verbs across
|
||||
languages), and the map form extends cleanly when a string later needs more
|
||||
than one placeholder. This holds for every user-facing string, including short
|
||||
ones like disabled-action reasons and toasts.
|
||||
|
||||
## Code comments are for future readers, not development history
|
||||
|
||||
Comments in source code explain *why this code is shaped the way it is*. They
|
||||
|
|
@ -283,7 +324,7 @@ So:
|
|||
- For changes to `userConfig` fields specifically, don't edit
|
||||
`docs-master/Config.md` by hand either — the relevant section is
|
||||
auto-generated from the struct field doc comments. After editing the
|
||||
struct, run `make generate` and include the regenerated
|
||||
struct, run `just generate` and include the regenerated
|
||||
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
|
||||
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
|
||||
*only* to `userConfig`, because those comments are fed through the doc
|
||||
|
|
|
|||
|
|
@ -110,6 +110,21 @@ gui:
|
|||
# is true.
|
||||
expandedSidePanelWeight: 2
|
||||
|
||||
# The side panels, in the order they appear from top to bottom.
|
||||
# Each entry is a list of one or more names that share a single panel as tabs
|
||||
# (cycle through them with the next-tab/previous-tab keys).
|
||||
# Omit a name to hide it; give a name its own one-element list to promote a tab
|
||||
# to a top-level panel.
|
||||
# Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches',
|
||||
# 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and
|
||||
# 'commits' must always be included; they can't be hidden.
|
||||
sidePanels:
|
||||
- [status]
|
||||
- [files, worktrees, submodules]
|
||||
- [branches, remotes, tags]
|
||||
- [commits, reflog]
|
||||
- [stash]
|
||||
|
||||
# Sometimes the main window is split in two (e.g. when the selected file has
|
||||
# both staged and unstaged changes). This setting controls how the two sections
|
||||
# are split.
|
||||
|
|
@ -414,6 +429,11 @@ git:
|
|||
# If true, periodically refresh files and submodules
|
||||
autoRefresh: true
|
||||
|
||||
# If true, poll the repo periodically for external ref changes (commits, branch
|
||||
# updates, checkouts made outside lazygit) and refresh when one is detected.
|
||||
# Independent of autoRefresh, which only governs the files panel.
|
||||
autoDetectExternalChanges: true
|
||||
|
||||
# If not "none", lazygit will automatically fast-forward local branches to match
|
||||
# their upstream after fetching. Applies to branches that are not the currently
|
||||
# checked out branch, and only to those that are strictly behind their upstream
|
||||
|
|
@ -525,6 +545,11 @@ refresher:
|
|||
# Auto-fetch can be disabled via option 'git.autoFetch'.
|
||||
fetchInterval: 60
|
||||
|
||||
# Interval in seconds at which lazygit polls for external ref changes (commits,
|
||||
# branch updates, checkouts made outside lazygit).
|
||||
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
|
||||
externalChangeCheckInterval: 2
|
||||
|
||||
# If true, show a confirmation popup before quitting Lazygit
|
||||
confirmOnQuit: false
|
||||
|
||||
|
|
@ -693,6 +718,7 @@ keybinding:
|
|||
increaseRenameSimilarityThreshold: )
|
||||
decreaseRenameSimilarityThreshold: (
|
||||
openDiffTool: <ctrl+t>
|
||||
editConfig: <alt+shift+c>
|
||||
status:
|
||||
checkForUpdate: u
|
||||
recentRepos: <enter>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | Quit | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Edit config file | Open file in external editor. |
|
||||
| `` z `` | Undo | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
|
||||
|
|
@ -348,7 +349,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | Open config file | Open file in default application. |
|
||||
| `` e `` | Edit config file | Open file in external editor. |
|
||||
| `` u `` | Check for update | |
|
||||
| `` <enter> `` | Switch to a recent repo | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` 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'. |
|
||||
| `` <alt+shift+c> `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 |
|
||||
| `` Z `` | やり直す | 最後のgitコマンドをやり直すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 |
|
||||
|
||||
|
|
@ -179,7 +180,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | 設定ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
|
||||
| `` e `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` u `` | 更新を確認 | |
|
||||
| `` <enter> `` | 最近のリポジトリをチェックアウト | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | 종료 | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | 설정 파일 수정 | Open file in external editor. |
|
||||
| `` z `` | 되돌리기 (reflog) (실험적) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | 다시 실행 (reflog) (실험적) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
|
||||
|
|
@ -237,7 +238,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | 설정 파일 열기 | Open file in default application. |
|
||||
| `` e `` | 설정 파일 수정 | Open file in external editor. |
|
||||
| `` u `` | 업데이트 확인 | |
|
||||
| `` <enter> `` | 최근에 사용한 저장소로 전환 | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | Quit | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Verander config bestand | Open file in external editor. |
|
||||
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
|
||||
|
|
@ -348,7 +349,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | Open config bestand | Open file in default application. |
|
||||
| `` e `` | Verander config bestand | Open file in external editor. |
|
||||
| `` u `` | Check voor updates | |
|
||||
| `` <enter> `` | Wissel naar een recente repo | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | Wyjdź | |
|
||||
| `` <ctrl+z> `` | Suspend the application | |
|
||||
| `` <ctrl+w> `` | Przełącz białe znaki | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. |
|
||||
| `` Z `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. |
|
||||
|
||||
|
|
@ -327,7 +328,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | Otwórz plik konfiguracyjny | Otwórz plik w domyślnej aplikacji. |
|
||||
| `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` u `` | Sprawdź aktualizacje | |
|
||||
| `` <enter> `` | Przełącz na ostatnie repozytorium | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | Sair | |
|
||||
| `` <ctrl+z> `` | Suspender a aplicação | |
|
||||
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Editar arquivo de configuração | Abrir arquivo no editor externo. |
|
||||
| `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. |
|
||||
| `` Z `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. |
|
||||
|
||||
|
|
@ -357,7 +358,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | Abrir o ficheiro de config | Abrir arquivo no aplicativo padrão. |
|
||||
| `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. |
|
||||
| `` u `` | Verificar atualização | |
|
||||
| `` <enter> `` | Mudar para um repositório recente | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` 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'. |
|
||||
| `` <alt+shift+c> `` | Редактировать файл конфигурации | Open file in external editor. |
|
||||
| `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. |
|
||||
| `` Z `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. |
|
||||
|
||||
|
|
@ -314,7 +315,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | Открыть файл конфигурации | Open file in default application. |
|
||||
| `` e `` | Редактировать файл конфигурации | Open file in external editor. |
|
||||
| `` u `` | Проверить обновления | |
|
||||
| `` <enter> `` | Переключиться на последний репозиторий | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` q, <ctrl+c> `` | 退出 | |
|
||||
| `` <ctrl+z> `` | 挂起应用程序 | |
|
||||
| `` <ctrl+w> `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。<br><br>默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 |
|
||||
| `` <alt+shift+c> `` | 编辑配置文件 | 使用外部编辑器打开文件 |
|
||||
| `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 |
|
||||
| `` Z `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。 |
|
||||
|
||||
|
|
@ -340,7 +341,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | 打开配置文件 | 使用默认程序打开该文件 |
|
||||
| `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 |
|
||||
| `` u `` | 检查更新 | |
|
||||
| `` <enter> `` | 切换到最近的仓库 | |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
| `` 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'. |
|
||||
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
|
||||
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
|
||||
|
||||
|
|
@ -369,7 +370,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
|||
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` o `` | 開啟設定檔案 | 使用預設軟體開啟 |
|
||||
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
|
||||
| `` u `` | 檢查更新 | |
|
||||
| `` <enter> `` | 切換到最近使用的版本庫 | |
|
||||
|
|
|
|||
28
justfile
28
justfile
|
|
@ -22,7 +22,12 @@ unit-test:
|
|||
go test ./... -short
|
||||
|
||||
# Run both unit tests and integration tests.
|
||||
test: unit-test e2e-all
|
||||
[unix]
|
||||
test: unit-test e2e
|
||||
|
||||
# On Windows, integration tests are not supported right now
|
||||
[windows]
|
||||
test: unit-test
|
||||
|
||||
# Generate all our auto-generated files (test list, cheatsheets, json schema, maybe other things in the future)
|
||||
generate:
|
||||
|
|
@ -34,18 +39,29 @@ format:
|
|||
lint:
|
||||
./scripts/golangci-lint-shim.sh run
|
||||
|
||||
# Run integration tests with a visible UI. Most useful for running a single test; for running all tests, use `e2e-all` instead.
|
||||
e2e-test-command := "go test pkg/integration/clients/*.go"
|
||||
|
||||
# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI.
|
||||
e2e *args:
|
||||
{{ if args == "" { e2e-test-command } else { \
|
||||
e2e-test-command + " -run 'TestIntegration/" + \
|
||||
replace( \
|
||||
replace_regex( \
|
||||
replace_regex(args, '\S*pkg/integration/tests/', ''), \
|
||||
'\.go( |$)', '${1}' \
|
||||
), \
|
||||
" ", "$' && " + e2e-test-command + " -run 'TestIntegration/" \
|
||||
) + "$'" \
|
||||
} }}
|
||||
|
||||
# Run a single integration test with a visible UI; most useful with --sandbox or --slow.
|
||||
e2e-cli *args:
|
||||
go run cmd/integration_test/main.go cli {{ args }}
|
||||
|
||||
# Open the TUI for running integration tests.
|
||||
e2e-tui *args:
|
||||
go run cmd/integration_test/main.go tui {{ args }}
|
||||
|
||||
# Run all integration tests headlessly (without a visible UI).
|
||||
e2e-all:
|
||||
go test pkg/integration/clients/*.go
|
||||
|
||||
# Run some tests on the current commit, similar to what CI does.
|
||||
check:
|
||||
./scripts/check_commit.sh
|
||||
|
|
|
|||
|
|
@ -5,39 +5,16 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// all we're doing here is wrapping the default command object builder with
|
||||
// some git-specific stuff: e.g. adding a git-specific env var
|
||||
|
||||
type gitCmdObjBuilder struct {
|
||||
innerBuilder *oscommands.CmdObjBuilder
|
||||
}
|
||||
|
||||
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
|
||||
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder {
|
||||
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
|
||||
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
|
||||
// NewGitCmdObjBuilder returns a command object builder whose runner is wrapped
|
||||
// with our git-specific runner (logging, credential handling, etc.).
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *oscommands.CmdObjBuilder {
|
||||
// We decorate the runner rather than exposing the builder's runner field:
|
||||
// that field stays unexported so there's a single API for running commands
|
||||
// across the codebase.
|
||||
return innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
|
||||
return &gitCmdObjRunner{
|
||||
log: log,
|
||||
innerRunner: runner,
|
||||
}
|
||||
})
|
||||
|
||||
return &gitCmdObjBuilder{
|
||||
innerBuilder: updatedBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0"
|
||||
|
||||
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) Quote(str string) string {
|
||||
return self.innerBuilder.Quote(str)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ func buildBranchCommands(deps commonDeps) *BranchCommands {
|
|||
return NewBranchCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildStatusCommands(deps commonDeps) *StatusCommands {
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
return NewStatusCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildFlowCommands(deps commonDeps) *FlowCommands {
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ type GetStatusFileOptions struct {
|
|||
// This is useful for users with bare repos for dotfiles who default to hiding untracked files,
|
||||
// but want to occasionally see them to `git add` a new file.
|
||||
ForceShowUntracked bool
|
||||
// When true, this status is part of an unattended background refresh, so we
|
||||
// pass --no-optional-locks to avoid index.lock contention with git commands
|
||||
// the user runs in a terminal (at the cost of not persisting git's refreshed
|
||||
// stat-cache).
|
||||
Background bool
|
||||
}
|
||||
|
||||
func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File {
|
||||
|
|
@ -47,7 +52,7 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
|||
}
|
||||
untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
|
||||
|
||||
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg})
|
||||
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg, Background: opts.Background})
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
}
|
||||
|
|
@ -148,6 +153,7 @@ func (self *FileLoader) getFileDiffs() (map[string]FileDiff, error) {
|
|||
type GitStatusOptions struct {
|
||||
NoRenames bool
|
||||
UntrackedFilesArg string
|
||||
Background bool
|
||||
}
|
||||
|
||||
type FileStatus struct {
|
||||
|
|
@ -169,6 +175,7 @@ func (self *FileLoader) gitDiffNumStat() (string, error) {
|
|||
|
||||
func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) {
|
||||
cmdArgs := NewGitCmd("status").
|
||||
GlobalArgIf(opts.Background, "--no-optional-locks").
|
||||
Arg(opts.UntrackedFilesArg).
|
||||
Arg("--porcelain").
|
||||
Arg("-z").
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
type scenario struct {
|
||||
testName string
|
||||
similarityThreshold int
|
||||
background bool
|
||||
runner oscommands.ICmdObjRunner
|
||||
showNumstatInFilesView bool
|
||||
expectedFiles []*models.File
|
||||
|
|
@ -26,6 +27,14 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
|
||||
expectedFiles: []*models.File{},
|
||||
},
|
||||
{
|
||||
testName: "Background refresh passes --no-optional-locks",
|
||||
similarityThreshold: 50,
|
||||
background: true,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"--no-optional-locks", "status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
|
||||
expectedFiles: []*models.File{},
|
||||
},
|
||||
{
|
||||
testName: "Several files found",
|
||||
similarityThreshold: 50,
|
||||
|
|
@ -246,7 +255,7 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
getFileType: func(string) string { return "file" },
|
||||
}
|
||||
|
||||
assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{}))
|
||||
assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{Background: s.background}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ func (self *GitCommandBuilder) ArgIfElse(condition bool, ifTrue string, ifFalse
|
|||
return self.Arg(ifFalse)
|
||||
}
|
||||
|
||||
// GlobalArg adds top-level options for git itself (e.g. --no-optional-locks).
|
||||
// Unlike Arg, these are prepended before the command, where git expects them.
|
||||
func (self *GitCommandBuilder) GlobalArg(args ...string) *GitCommandBuilder {
|
||||
self.args = append(append([]string{}, args...), self.args...)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) GlobalArgIf(condition bool, args ...string) *GitCommandBuilder {
|
||||
if condition {
|
||||
self.GlobalArg(args...)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) Config(value string) *GitCommandBuilder {
|
||||
// config settings come before the command
|
||||
self.args = append([]string{"-c", value}, self.args...)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type StatusCommands struct {
|
||||
|
|
@ -82,6 +84,66 @@ func (self *StatusCommands) IsInRevert() (bool, error) {
|
|||
return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD"))
|
||||
}
|
||||
|
||||
// RefsSnapshot returns a string fingerprint of the current state of local
|
||||
// branches and HEAD. Comparing two snapshots byte-for-byte tells us whether
|
||||
// any local ref or HEAD has moved since the last snapshot.
|
||||
func (self *StatusCommands) RefsSnapshot() (string, error) {
|
||||
t := time.Now()
|
||||
defer func() { self.Log.Infof("RefsSnapshot took %s", time.Since(t)) }()
|
||||
|
||||
refsArgs := NewGitCmd("for-each-ref").
|
||||
Arg("--format=%(objectname) %(refname)").
|
||||
Arg("refs/heads").
|
||||
ToArgv()
|
||||
refs, err := self.cmd.New(refsArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
head, err := self.headSnapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return refs + head, nil
|
||||
}
|
||||
|
||||
// headSnapshot returns a fingerprint of HEAD that distinguishes "detached at
|
||||
// commit X" from "on a branch that points at X". The commit hash alone can't
|
||||
// tell those apart, which matters at the end of a rebase: HEAD reattaches to
|
||||
// the branch without the hash changing, and we'd otherwise miss that refresh.
|
||||
//
|
||||
// We read .git/HEAD directly rather than shelling out: it's faster (no child
|
||||
// process) and its content is exactly the symref-or-hash distinction we want
|
||||
// ("ref: refs/heads/foo" when attached, the raw hash when detached). The
|
||||
// reftable backend, however, doesn't keep a real .git/HEAD — it writes a fixed
|
||||
// stub ("ref: refs/heads/.invalid") that never reflects the actual HEAD. When
|
||||
// we see that stub (or the file is missing/unreadable) we fall back to
|
||||
// porcelain commands, which are backend-agnostic.
|
||||
func (self *StatusCommands) headSnapshot() (string, error) {
|
||||
headPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "HEAD")
|
||||
if content, err := afero.ReadFile(self.Fs, headPath); err == nil {
|
||||
head := strings.TrimSpace(string(content))
|
||||
if head != "" && head != "ref: refs/heads/.invalid" {
|
||||
return head, nil
|
||||
}
|
||||
}
|
||||
|
||||
// symbolic-ref gives the branch when HEAD is attached and fails when it's
|
||||
// detached, in which case rev-parse gives the commit HEAD points at.
|
||||
symbolicRefArgs := NewGitCmd("symbolic-ref").Arg("HEAD").ToArgv()
|
||||
if symref, err := self.cmd.New(symbolicRefArgs).DontLog().RunWithOutput(); err == nil {
|
||||
return strings.TrimSpace(symref), nil
|
||||
}
|
||||
|
||||
revParseArgs := NewGitCmd("rev-parse").Arg("HEAD").ToArgv()
|
||||
head, err := self.cmd.New(revParseArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(head), nil
|
||||
}
|
||||
|
||||
// Full ref (e.g. "refs/heads/mybranch") of the branch that is currently
|
||||
// being rebased, or empty string when we're not in a rebase
|
||||
func (self *StatusCommands) BranchBeingRebased() string {
|
||||
|
|
|
|||
91
pkg/commands/git_commands/status_test.go
Normal file
91
pkg/commands/git_commands/status_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStatusRefsSnapshot(t *testing.T) {
|
||||
const forEachRefOutput = "aaaa refs/heads/main\nbbbb refs/heads/topic\n"
|
||||
forEachRefArgs := []string{"for-each-ref", "--format=%(objectname) %(refname)", "refs/heads"}
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
headFile *string // nil means: don't create a .git/HEAD file (simulates it being unreadable).
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
expectedHead string
|
||||
}{
|
||||
{
|
||||
// files backend, on a branch: read straight from .git/HEAD, no
|
||||
// child process for HEAD.
|
||||
testName: "attached, read from HEAD file",
|
||||
headFile: lo.ToPtr("ref: refs/heads/main\n"),
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
|
||||
expectedHead: "ref: refs/heads/main",
|
||||
},
|
||||
{
|
||||
// files backend, detached: .git/HEAD holds the raw hash.
|
||||
testName: "detached, read from HEAD file",
|
||||
headFile: lo.ToPtr("aaaa\n"),
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
|
||||
expectedHead: "aaaa",
|
||||
},
|
||||
{
|
||||
// reftable backend (HEAD is a fixed stub), attached: fall back to
|
||||
// symbolic-ref, which succeeds.
|
||||
testName: "reftable stub, attached, fall back to symbolic-ref",
|
||||
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
|
||||
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
|
||||
expectedHead: "refs/heads/main",
|
||||
},
|
||||
{
|
||||
// reftable backend, detached: symbolic-ref fails, fall back to
|
||||
// rev-parse.
|
||||
testName: "reftable stub, detached, fall back to rev-parse",
|
||||
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
|
||||
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "", errors.New("fatal: ref HEAD is not a symbolic ref")).
|
||||
ExpectGitArgs([]string{"rev-parse", "HEAD"}, "aaaa\n", nil),
|
||||
expectedHead: "aaaa",
|
||||
},
|
||||
{
|
||||
// HEAD file missing/unreadable: same fallback as reftable.
|
||||
testName: "no HEAD file, fall back to symbolic-ref",
|
||||
headFile: nil,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
|
||||
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
|
||||
expectedHead: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
if s.headFile != nil {
|
||||
assert.NoError(t, afero.WriteFile(fs, "/repo/.git/HEAD", []byte(*s.headFile), 0o600))
|
||||
}
|
||||
|
||||
instance := buildStatusCommands(commonDeps{
|
||||
runner: s.runner,
|
||||
fs: fs,
|
||||
repoPaths: MockRepoPaths("/repo"),
|
||||
})
|
||||
|
||||
snapshot, err := instance.RefsSnapshot()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, forEachRefOutput+s.expectedHead, snapshot)
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,72 @@ func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, er
|
|||
}), nil
|
||||
}
|
||||
|
||||
// GetConflictCommits returns the three gitlink commits of a conflicted submodule
|
||||
// from the index: the merge base, our (current) commit, and their (incoming)
|
||||
// commit. Any of them can be empty if that stage is absent (e.g. a submodule
|
||||
// that was added on only one side). The path is relative to the repo root.
|
||||
func (self *SubmoduleCommands) GetConflictCommits(path string) (base string, ours string, theirs string, err error) {
|
||||
cmdArgs := NewGitCmd("ls-files").Arg("-u", "-z", "--", path).ToArgv()
|
||||
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
// Each NUL-terminated entry looks like "<mode> <sha> <stage>\t<path>".
|
||||
for _, entry := range strings.Split(output, "\x00") {
|
||||
// fields are split on the tab and the spaces, so the leading three are
|
||||
// always mode, sha, stage regardless of what the path contains.
|
||||
fields := strings.Fields(entry)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
switch fields[2] {
|
||||
case "1":
|
||||
base = fields[1]
|
||||
case "2":
|
||||
ours = fields[1]
|
||||
case "3":
|
||||
theirs = fields[1]
|
||||
}
|
||||
}
|
||||
|
||||
return base, ours, theirs, nil
|
||||
}
|
||||
|
||||
// GetCommitSummary returns "<short-sha> <subject>" for a commit inside the
|
||||
// submodule at the given path, for display in the conflict menu.
|
||||
func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string, error) {
|
||||
cmdArgs := NewGitCmd("log").
|
||||
Dir(path).
|
||||
Arg("--format=%h %s", "--max-count=1", sha).
|
||||
Config("log.showsignature=false").
|
||||
ToArgv()
|
||||
|
||||
summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
return strings.TrimSpace(summary), err
|
||||
}
|
||||
|
||||
// CheckoutConflictCommit resolves a submodule conflict by checking the submodule
|
||||
// out at the given commit. `git checkout --ours/--theirs` is a no-op on
|
||||
// gitlinks, so we check out the chosen commit in the submodule itself; the
|
||||
// caller then stages the submodule to record the resolution.
|
||||
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
|
||||
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
|
||||
// that `side` has but `otherSide` does not (i.e. `otherSide..side`) — the commits
|
||||
// unique to one side of a commit conflict, relative to their common ancestor. It
|
||||
// is empty if `side` is an ancestor of `otherSide` (e.g. that side was rewound).
|
||||
func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSide string) (string, error) {
|
||||
cmdArgs := NewGitCmd("log").Dir(path).
|
||||
Arg("--oneline", "--color=always", otherSide+".."+side).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
||||
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
|
||||
// because the intention here is to have no dirty worktree state
|
||||
|
|
|
|||
92
pkg/commands/git_commands/submodule_test.go
Normal file
92
pkg/commands/git_commands/submodule_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSubmoduleGetConflictCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
output string
|
||||
expectedBase string
|
||||
expectedOurs string
|
||||
expectedTheirs string
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "all three stages present (both modified)",
|
||||
output: "160000 aaaaaaa 1\tmysub\x00160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00",
|
||||
expectedBase: "aaaaaaa",
|
||||
expectedOurs: "bbbbbbb",
|
||||
expectedTheirs: "ccccccc",
|
||||
},
|
||||
{
|
||||
testName: "only our and their stages (added on both sides)",
|
||||
output: "160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00",
|
||||
expectedBase: "",
|
||||
expectedOurs: "bbbbbbb",
|
||||
expectedTheirs: "ccccccc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, s.output, nil)
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
base, ours, theirs, err := instance.GetConflictCommits("mysub")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expectedBase, base)
|
||||
assert.Equal(t, s.expectedOurs, ours)
|
||||
assert.Equal(t, s.expectedTheirs, theirs)
|
||||
runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmoduleGetConflictCommitsError(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, "", errors.New("error"))
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
_, _, _, err := instance.GetConflictCommits("mysub")
|
||||
assert.Error(t, err)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestSubmoduleGetCommitSummary(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-c", "log.showsignature=false", "-C", "mysub", "log", "--format=%h %s", "--max-count=1", "bbbbbbb"}, "bbbbbbb the subject\n", nil)
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
summary, err := instance.GetCommitSummary("mysub", "bbbbbbb")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "bbbbbbb the subject", summary)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "mysub", "checkout", "bbbbbbb"}, "", nil)
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb"))
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestSubmoduleConflictSideLog(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
output, err := instance.ConflictSideLog("mysub", "bbbbbbb", "ccccccc")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "bbbbbbb left\n", output)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
|
@ -160,3 +160,13 @@ func (c *Commit) IsTODO() bool {
|
|||
func IsHeadCommit(commits []*Commit, index int) bool {
|
||||
return !commits[index].IsTODO() && (index == 0 || commits[index-1].IsTODO())
|
||||
}
|
||||
|
||||
func HeadCommitIdx(commits []*Commit) int {
|
||||
for index, commit := range commits {
|
||||
if !commit.IsTODO() {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
81
pkg/commands/models/commit_test.go
Normal file
81
pkg/commands/models/commit_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHeadCommitIdx(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
commits []*Commit
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "first commit without rebase todos",
|
||||
commits: makeTestCommits("a", "b"),
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "first non-todo commit during an interactive rebase",
|
||||
commits: []*Commit{
|
||||
makeTestTodoCommit(todo.Pick),
|
||||
makeTestTodoCommit(todo.Reword),
|
||||
makeTestCommit("a"),
|
||||
makeTestCommit("b"),
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "no commits",
|
||||
commits: nil,
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "only rebase todos",
|
||||
commits: []*Commit{
|
||||
makeTestTodoCommit(todo.Pick),
|
||||
makeTestTodoCommit(todo.Reword),
|
||||
},
|
||||
expected: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.expected, HeadCommitIdx(testCase.commits))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHeadCommit(t *testing.T) {
|
||||
commits := []*Commit{
|
||||
makeTestTodoCommit(todo.Pick),
|
||||
makeTestCommit("a"),
|
||||
makeTestCommit("b"),
|
||||
}
|
||||
|
||||
assert.False(t, IsHeadCommit(commits, 0))
|
||||
assert.True(t, IsHeadCommit(commits, 1))
|
||||
assert.False(t, IsHeadCommit(commits, 2))
|
||||
}
|
||||
|
||||
func makeTestCommits(hashes ...string) []*Commit {
|
||||
commits := make([]*Commit, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
commits = append(commits, makeTestCommit(hash))
|
||||
}
|
||||
|
||||
return commits
|
||||
}
|
||||
|
||||
func makeTestCommit(hash string) *Commit {
|
||||
return NewCommit(&utils.StringPool{}, NewCommitOpts{Hash: hash})
|
||||
}
|
||||
|
||||
func makeTestTodoCommit(action todo.TodoCommand) *Commit {
|
||||
return NewCommit(&utils.StringPool{}, NewCommitOpts{Action: action})
|
||||
}
|
||||
|
|
@ -48,26 +48,34 @@ func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string
|
|||
if len(shellFunctionsFile) > 0 {
|
||||
commandStr = fmt.Sprintf("%ssource %s\n%s", self.platform.PrefixForShellFunctionsFile, shellFunctionsFile, commandStr)
|
||||
}
|
||||
quotedCommand := self.quotedCommandString(commandStr)
|
||||
|
||||
if self.platform.OS == "windows" {
|
||||
return self.newWindowsShell(commandStr)
|
||||
}
|
||||
|
||||
quotedCommand := self.Quote(commandStr)
|
||||
cmdArgs := str.ToArgv(fmt.Sprintf("%s %s %s", self.platform.Shell, self.platform.ShellArg, quotedCommand))
|
||||
|
||||
return self.New(cmdArgs)
|
||||
}
|
||||
|
||||
func (self *CmdObjBuilder) quotedCommandString(commandStr string) string {
|
||||
// Windows does not seem to like quotes around the command
|
||||
if self.platform.OS == "windows" {
|
||||
return strings.NewReplacer(
|
||||
"^", "^^",
|
||||
"&", "^&",
|
||||
"|", "^|",
|
||||
"<", "^<",
|
||||
">", "^>",
|
||||
"%", "^%",
|
||||
).Replace(commandStr)
|
||||
}
|
||||
// newWindowsShell wraps the command in `cmd.exe /s /c "<command>"`. The /s
|
||||
// flag tells cmd to strip exactly the outermost pair of quotes and pass the
|
||||
// rest through unchanged, which preserves any quoting the command itself
|
||||
// contains (e.g. `"C:\Program Files\my-editor.exe" file.txt`). Without /s,
|
||||
// cmd's default rules drop the wrong quotes once the command line contains
|
||||
// more than two of them.
|
||||
//
|
||||
// We bypass Go's standard arg quoting via SysProcAttr.CmdLine: it follows the
|
||||
// CommandLineToArgvW convention (`\"` for inner quotes), but cmd.exe doesn't.
|
||||
func (self *CmdObjBuilder) newWindowsShell(commandStr string) *CmdObj {
|
||||
args := []string{self.platform.Shell, "/s", self.platform.ShellArg, commandStr}
|
||||
cmdObj := self.New(args)
|
||||
|
||||
return self.Quote(commandStr)
|
||||
cmdLine := fmt.Sprintf(`%s /s %s "%s"`, self.platform.Shell, self.platform.ShellArg, commandStr)
|
||||
setRawCmdLine(cmdObj.GetCmd(), cmdLine)
|
||||
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdObjRunner) *CmdObjBuilder {
|
||||
|
|
@ -80,21 +88,47 @@ func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdO
|
|||
}
|
||||
|
||||
func (self *CmdObjBuilder) Quote(message string) string {
|
||||
var quote string
|
||||
if self.platform.OS == "windows" {
|
||||
quote = `\"`
|
||||
message = strings.NewReplacer(
|
||||
`"`, `"'"'"`,
|
||||
`\"`, `\\"`,
|
||||
).Replace(message)
|
||||
} else {
|
||||
quote = `"`
|
||||
message = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
`$`, `\$`,
|
||||
"`", "\\`",
|
||||
).Replace(message)
|
||||
return quoteForWindows(message)
|
||||
}
|
||||
return quote + message + quote
|
||||
message = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
`$`, `\$`,
|
||||
"`", "\\`",
|
||||
).Replace(message)
|
||||
return `"` + message + `"`
|
||||
}
|
||||
|
||||
// quoteForWindows encodes a value using the standard Windows command-line
|
||||
// convention (the algorithm behind syscall.EscapeArg, reimplemented here so
|
||||
// it's available on all platforms). The result is always wrapped in double
|
||||
// quotes so cmd.exe and CommandLineToArgvW treat it as a single argument
|
||||
// regardless of what shell metacharacters it contains.
|
||||
func quoteForWindows(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
slashes := 0
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
switch c {
|
||||
case '\\':
|
||||
slashes++
|
||||
b.WriteByte(c)
|
||||
case '"':
|
||||
for ; slashes > 0; slashes-- {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
slashes = 0
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
for ; slashes > 0; slashes-- {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,12 +105,18 @@ func (self *cmdObjRunner) RunWithOutputAux(cmdObj *CmdObj) (string, error) {
|
|||
}
|
||||
|
||||
t := time.Now()
|
||||
output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput())
|
||||
cmd := cmdObj.GetCmd()
|
||||
output, err := sanitisedCommandOutput(cmd.CombinedOutput())
|
||||
if err != nil {
|
||||
self.log.WithField("command", cmdObj.ToString()).Error(output)
|
||||
}
|
||||
|
||||
self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t))
|
||||
wall := time.Since(t)
|
||||
if ps := cmd.ProcessState; ps != nil {
|
||||
self.log.Infof("%s (wall %s, cpu %s)", cmdObj.ToString(), wall, ps.UserTime()+ps.SystemTime())
|
||||
} else {
|
||||
self.log.Infof("%s (wall %s)", cmdObj.ToString(), wall)
|
||||
}
|
||||
|
||||
return output, err
|
||||
}
|
||||
|
|
|
|||
389
pkg/commands/oscommands/new_shell_windows_test.go
Normal file
389
pkg/commands/oscommands/new_shell_windows_test.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
//go:build windows
|
||||
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// These tests run only on Windows because they exercise real cmd.exe
|
||||
// quote-parsing behaviour, which has only been a problem on Windows.
|
||||
|
||||
// makeWindowsShellBuilder returns a CmdObjBuilder configured for a real
|
||||
// Windows cmd shell, bypassing the test "dummy" platform (which is darwin).
|
||||
func makeWindowsShellBuilder() *CmdObjBuilder {
|
||||
log := utils.NewDummyLog()
|
||||
return &CmdObjBuilder{
|
||||
runner: &cmdObjRunner{log: log, guiIO: NewNullGuiIO(log)},
|
||||
platform: &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"},
|
||||
}
|
||||
}
|
||||
|
||||
// fakeEditorSrc is a minimal Go program that records the args it received,
|
||||
// one per line, to marker.txt in its own directory. Using a real .exe (not a
|
||||
// .bat) means args are parsed by Go's runtime via CommandLineToArgvW — the
|
||||
// same algorithm used by ~all real Windows GUI editors. A .bat would parse
|
||||
// args via cmd.exe's own rules, which can hide bugs that affect editors.
|
||||
const fakeEditorSrc = `package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
marker := filepath.Join(filepath.Dir(exe), "marker.txt")
|
||||
body := strings.Join(os.Args[1:], "\n")
|
||||
if err := os.WriteFile(marker, []byte(body), 0o644); err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var (
|
||||
fakeEditorOnce sync.Once
|
||||
fakeEditorBytes []byte
|
||||
fakeEditorErr error
|
||||
)
|
||||
|
||||
// loadFakeEditorBytes builds the fake editor exactly once per test process
|
||||
// and returns its bytes. Tests then drop a copy at a path containing spaces.
|
||||
func loadFakeEditorBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
fakeEditorOnce.Do(func() {
|
||||
buildDir, err := os.MkdirTemp("", "lazygit-fake-editor-build-*")
|
||||
if err != nil {
|
||||
fakeEditorErr = err
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(buildDir)
|
||||
|
||||
srcPath := filepath.Join(buildDir, "main.go")
|
||||
binPath := filepath.Join(buildDir, "fake-editor.exe")
|
||||
if err := os.WriteFile(srcPath, []byte(fakeEditorSrc), 0o644); err != nil {
|
||||
fakeEditorErr = err
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("go", "build", "-o", binPath, srcPath)
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fakeEditorErr = err
|
||||
return
|
||||
}
|
||||
fakeEditorBytes, fakeEditorErr = os.ReadFile(binPath)
|
||||
})
|
||||
if fakeEditorErr != nil {
|
||||
t.Fatalf("failed to build fake editor helper: %v", fakeEditorErr)
|
||||
}
|
||||
return fakeEditorBytes
|
||||
}
|
||||
|
||||
// placeFakeEditor builds the fake editor and places it at a path containing
|
||||
// a space (mirroring `C:\Program Files\...`). The marker the editor writes
|
||||
// lives next to the exe.
|
||||
func placeFakeEditor(t *testing.T) (exe, markerFile string) {
|
||||
t.Helper()
|
||||
bin := loadFakeEditorBytes(t)
|
||||
exeDir := filepath.Join(t.TempDir(), "Program Files", "FakeEditor")
|
||||
if err := os.MkdirAll(exeDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir exeDir: %v", err)
|
||||
}
|
||||
exe = filepath.Join(exeDir, "fake-editor.exe")
|
||||
markerFile = filepath.Join(exeDir, "marker.txt")
|
||||
if err := os.WriteFile(exe, bin, 0o755); err != nil {
|
||||
t.Fatalf("write fake editor: %v", err)
|
||||
}
|
||||
return exe, markerFile
|
||||
}
|
||||
|
||||
// placeTargetFile creates a file at <freshTempDir>/<dirName>/<basename> with
|
||||
// a trivial body. Use a dirName containing a space (e.g. "my repo") to put
|
||||
// the file at a path with spaces.
|
||||
func placeTargetFile(t *testing.T, dirName, basename string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), dirName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir target dir: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, basename)
|
||||
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// setupFakeEditor is a convenience wrapper for the common case: editor at a
|
||||
// spacey path AND target file at a spacey path — the conditions that
|
||||
// trigger the cmd.exe quote-stripping bug.
|
||||
func setupFakeEditor(t *testing.T) (fakeExe, targetFile, markerFile string) {
|
||||
t.Helper()
|
||||
fakeExe, markerFile = placeFakeEditor(t)
|
||||
targetFile = placeTargetFile(t, "my repo", "file.txt")
|
||||
return fakeExe, targetFile, markerFile
|
||||
}
|
||||
|
||||
// resolveTemplate mirrors what pkg/commands/git_commands/file.go does: it
|
||||
// substitutes {{filename}} with the Windows-quoted filename and {{line}}
|
||||
// with a line number.
|
||||
func resolveTemplate(builder *CmdObjBuilder, template, filename, line string) string {
|
||||
out := strings.ReplaceAll(template, "{{filename}}", builder.Quote(filename))
|
||||
out = strings.ReplaceAll(out, "{{line}}", line)
|
||||
return out
|
||||
}
|
||||
|
||||
// readMarkerArgs reads the args the fake editor recorded. Each arg is on its
|
||||
// own line (so an arg that itself contains a space stays one element). An
|
||||
// empty file means the editor ran with zero args.
|
||||
func readMarkerArgs(t *testing.T, markerFile string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(markerFile)
|
||||
if err != nil {
|
||||
t.Fatalf("marker file was not written; the fake editor never ran: %v", err)
|
||||
}
|
||||
s := strings.TrimRight(string(data), "\r\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLineAndWait(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, targetFile, markerFile := setupFakeEditor(t)
|
||||
|
||||
template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}`
|
||||
cmdStr := resolveTemplate(builder, template, targetFile, "42")
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-multiInst", "-nosession", "-noPlugin", "-n42", targetFile},
|
||||
readMarkerArgs(t, markerFile),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLine(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, targetFile, markerFile := setupFakeEditor(t)
|
||||
|
||||
template := `"` + fakeExe + `" -n{{line}} {{filename}}`
|
||||
cmdStr := resolveTemplate(builder, template, targetFile, "42")
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-n42", targetFile},
|
||||
readMarkerArgs(t, markerFile),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNewShell_QuotedExePath_FilenameWithSpaces_Edit(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, targetFile, markerFile := setupFakeEditor(t)
|
||||
|
||||
template := `"` + fakeExe + `" {{filename}}`
|
||||
cmdStr := resolveTemplate(builder, template, targetFile, "")
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{targetFile},
|
||||
readMarkerArgs(t, markerFile),
|
||||
)
|
||||
}
|
||||
|
||||
// Sanity check: for a filename WITHOUT spaces the same templates already work,
|
||||
// because the resulting cmd.exe line has exactly two quote characters and
|
||||
// cmd /c keeps them. This pins the difference down to filename quoting and
|
||||
// guards against a regression where the no-spaces case starts failing too.
|
||||
func TestNewShell_QuotedExePath_FilenameWithoutSpaces_StillWorks(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, markerFile := placeFakeEditor(t)
|
||||
plainTarget := placeTargetFile(t, "repo", "plain.txt") // no-space dir
|
||||
|
||||
template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}`
|
||||
cmdStr := resolveTemplate(builder, template, plainTarget, "42")
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-multiInst", "-nosession", "-noPlugin", "-n42", plainTarget},
|
||||
readMarkerArgs(t, markerFile),
|
||||
)
|
||||
}
|
||||
|
||||
// TestNewShell_VarietyOfEditorTemplates exercises NewShell with a range of
|
||||
// realistic editor templates, all with the trigger conditions of the bug
|
||||
// (quoted exe at a spacey path + filename at a spacey path). Each subtest
|
||||
// asserts the editor receives the exact args lazygit intended.
|
||||
//
|
||||
// Args in `wantArgs` may use the literal "<file>" placeholder; it gets
|
||||
// substituted with the resolved target file path before comparison.
|
||||
func TestNewShell_VarietyOfEditorTemplates(t *testing.T) {
|
||||
const filePlaceholder = "<file>"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
template string // <exe> stands for the fake editor's full path
|
||||
line string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "vim/nvim style: +line filename",
|
||||
template: `"<exe>" +{{line}} {{filename}}`,
|
||||
line: "42",
|
||||
wantArgs: []string{"+42", filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "emacs-like with explicit +N",
|
||||
template: `"<exe>" +{{line}} -nw {{filename}}`,
|
||||
line: "7",
|
||||
wantArgs: []string{"+7", "-nw", filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "long flag with =value",
|
||||
template: `"<exe>" --line={{line}} --tab-size=4 {{filename}}`,
|
||||
line: "42",
|
||||
wantArgs: []string{"--line=42", "--tab-size=4", filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "many short and long flags before filename",
|
||||
template: `"<exe>" -a -b -c --foo --bar -n{{line}} {{filename}}`,
|
||||
line: "42",
|
||||
wantArgs: []string{"-a", "-b", "-c", "--foo", "--bar", "-n42", filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "flag after filename",
|
||||
template: `"<exe>" {{filename}} --readonly`,
|
||||
line: "",
|
||||
wantArgs: []string{filePlaceholder, "--readonly"},
|
||||
},
|
||||
{
|
||||
name: "single short flag attached to value",
|
||||
template: `"<exe>" -n{{line}} {{filename}}`,
|
||||
line: "1",
|
||||
wantArgs: []string{"-n1", filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "no flags, just filename",
|
||||
template: `"<exe>" {{filename}}`,
|
||||
line: "",
|
||||
wantArgs: []string{filePlaceholder},
|
||||
},
|
||||
{
|
||||
name: "flag with separate value (space-separated)",
|
||||
template: `"<exe>" --goto {{line}} {{filename}}`,
|
||||
line: "42",
|
||||
wantArgs: []string{"--goto", "42", filePlaceholder},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, targetFile, markerFile := setupFakeEditor(t)
|
||||
|
||||
template := strings.ReplaceAll(tc.template, "<exe>", fakeExe)
|
||||
cmdStr := resolveTemplate(builder, template, targetFile, tc.line)
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
want := make([]string, len(tc.wantArgs))
|
||||
for i, a := range tc.wantArgs {
|
||||
want[i] = strings.ReplaceAll(a, filePlaceholder, targetFile)
|
||||
}
|
||||
assert.Equal(t, want, readMarkerArgs(t, markerFile))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewShell_FilenameSpecialCharacters varies the basename of the target
|
||||
// file across characters that are legal in Windows filenames but might
|
||||
// interact badly with cmd.exe / Quote(): parentheses, brackets, single
|
||||
// quote, comma, semicolon, equals, etc. The exe is at a spacey path and
|
||||
// the target dir has spaces, so the bug-trigger conditions are still met.
|
||||
func TestNewShell_FilenameSpecialCharacters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
basename string
|
||||
}{
|
||||
{"parens", "file (1).txt"},
|
||||
{"brackets", "file[v2].txt"},
|
||||
{"single quote", "it's a file.txt"},
|
||||
{"comma", "a,b,c.txt"},
|
||||
{"semicolon", "a;b.txt"},
|
||||
{"equals", "key=value.txt"},
|
||||
{"plus", "a+b.txt"},
|
||||
{"hash", "issue#42.txt"},
|
||||
{"at sign", "user@host.txt"},
|
||||
{"tilde", "~backup.txt"},
|
||||
{"dot leading", ".gitignore.txt"},
|
||||
{"multiple dots", "v1.2.3.txt"},
|
||||
{"dash leading", "-flag-looking.txt"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
fakeExe, markerFile := placeFakeEditor(t)
|
||||
targetFile := placeTargetFile(t, "my repo", tc.basename)
|
||||
|
||||
template := `"` + fakeExe + `" -n{{line}} {{filename}}`
|
||||
cmdStr := resolveTemplate(builder, template, targetFile, "42")
|
||||
|
||||
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-n42", targetFile},
|
||||
readMarkerArgs(t, markerFile),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Command chaining with && must work: cmd /s /c runs the assembled line verbatim,
|
||||
// so cmd treats && as a separator and runs both commands. The two echoes
|
||||
// therefore produce two separate output lines.
|
||||
func TestNewShell_CommandChaining(t *testing.T) {
|
||||
builder := makeWindowsShellBuilder()
|
||||
|
||||
out, err := builder.NewShell("echo first&&echo second", "").GetCmd().CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(string(out), "\r\n", "\n")
|
||||
lines := strings.Split(strings.TrimSpace(normalized), "\n")
|
||||
assert.Equal(t, []string{"first", "second"}, lines)
|
||||
}
|
||||
|
|
@ -40,6 +40,11 @@ func (c *OSCommand) UpdateWindowTitle() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// setRawCmdLine is the non-Windows no-op counterpart of the Windows shim
|
||||
// (see the comment there). NewShell's shell-building logic is portable, so
|
||||
// this call is reached on every host; only the Windows build does anything.
|
||||
func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {}
|
||||
|
||||
func TerminateProcessGracefully(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -75,11 +75,26 @@ func TestOSCommandQuoteWindows(t *testing.T) {
|
|||
|
||||
actual := osCommand.Quote(`hello "test" 'test2'`)
|
||||
|
||||
expected := `\"hello "'"'"test"'"'" 'test2'\"`
|
||||
expected := `"hello \"test\" 'test2'"`
|
||||
|
||||
assert.EqualValues(t, expected, actual)
|
||||
}
|
||||
|
||||
// On Windows, NewShell must hand the command to cmd.exe verbatim.
|
||||
func TestNewShellWindowsPassesMetacharactersVerbatim(t *testing.T) {
|
||||
osCommand := NewDummyOSCommand()
|
||||
platform := &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"}
|
||||
osCommand.Platform = platform
|
||||
osCommand.Cmd.platform = platform
|
||||
|
||||
command := `echo a && echo b | sort > out.txt < in.txt %PATH%`
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"cmd", "/s", "/c", command},
|
||||
osCommand.Cmd.NewShell(command, "").Args(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestOSCommandFileType(t *testing.T) {
|
||||
type scenario struct {
|
||||
path string
|
||||
|
|
|
|||
|
|
@ -5,8 +5,25 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setRawCmdLine hands cmd.exe the exact command line we built, bypassing
|
||||
// os/exec's default composition (which quotes args with the
|
||||
// CommandLineToArgvW `\"` convention that cmd.exe doesn't understand).
|
||||
//
|
||||
// The shell-building logic in NewShell is portable and dispatches on
|
||||
// platform.OS, which keeps it (and its quoting) unit-testable on any host.
|
||||
// Assigning SysProcAttr.CmdLine is the only step that needs a Windows-only
|
||||
// field, so it's the single piece split out behind a build tag; every other
|
||||
// platform gets the no-op in os_default_platform.go.
|
||||
func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.CmdLine = cmdLine
|
||||
}
|
||||
|
||||
func GetPlatform() *Platform {
|
||||
return &Platform{
|
||||
OS: "windows",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
|
|||
{
|
||||
filename: "test",
|
||||
runner: NewFakeRunner(t).
|
||||
ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", errors.New("error")),
|
||||
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", errors.New("error")),
|
||||
test: func(err error) {
|
||||
assert.Error(t, err)
|
||||
},
|
||||
|
|
@ -28,7 +28,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
|
|||
{
|
||||
filename: "test",
|
||||
runner: NewFakeRunner(t).
|
||||
ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", nil),
|
||||
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", nil),
|
||||
test: func(err error) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
|
|
@ -36,7 +36,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
|
|||
{
|
||||
filename: "filename with spaces",
|
||||
runner: NewFakeRunner(t).
|
||||
ExpectArgs([]string{"cmd", "/c", "start", "", "filename with spaces"}, "", nil),
|
||||
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "filename with spaces"`}, "", nil),
|
||||
test: func(err error) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
|
|
@ -44,7 +44,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
|
|||
{
|
||||
filename: "let's_test_with_single_quote",
|
||||
runner: NewFakeRunner(t).
|
||||
ExpectArgs([]string{"cmd", "/c", "start", "", "let's_test_with_single_quote"}, "", nil),
|
||||
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "let's_test_with_single_quote"`}, "", nil),
|
||||
test: func(err error) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
|
|
@ -52,7 +52,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
|
|||
{
|
||||
filename: "$USER.txt",
|
||||
runner: NewFakeRunner(t).
|
||||
ExpectArgs([]string{"cmd", "/c", "start", "", "$USER.txt"}, "", nil),
|
||||
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "$USER.txt"`}, "", nil),
|
||||
test: func(err error) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -654,6 +654,12 @@ git:
|
|||
# If true, periodically refresh files and submodules
|
||||
autoRefresh: true
|
||||
|
||||
# If true, poll the repo periodically for external ref changes (commits,
|
||||
# branch updates, checkouts made outside lazygit) and refresh when one
|
||||
# is detected. Independent of autoRefresh, which only governs the files
|
||||
# panel.
|
||||
autoDetectExternalChanges: true
|
||||
|
||||
# If true, pass the --all arg to git fetch
|
||||
fetchAll: true
|
||||
|
||||
|
|
@ -723,6 +729,11 @@ refresher:
|
|||
# Auto-fetch can be disabled via option 'git.autoFetch'.
|
||||
fetchInterval: 60
|
||||
|
||||
# Interval in seconds at which lazygit polls for external ref changes
|
||||
# (commits, branch updates, checkouts made outside lazygit).
|
||||
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
|
||||
externalChangeCheckInterval: 2
|
||||
|
||||
# If true, show a confirmation popup before quitting Lazygit
|
||||
confirmOnQuit: false
|
||||
|
||||
|
|
|
|||
54
pkg/config/side_panel.go
Normal file
54
pkg/config/side_panel.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/karimkhaleel/jsonschema"
|
||||
"github.com/samber/lo"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SidePanel is one entry in gui.sidePanels: a side panel made up of one or more
|
||||
// tabs, written in YAML as a list of tab names (e.g. [files, worktrees]).
|
||||
type SidePanel []string
|
||||
|
||||
// ValidSidePanelTabs lists every name that may appear in gui.sidePanels. Each
|
||||
// names a list that can stand alone as a panel or be grouped with others as the
|
||||
// tabs of one panel. The resolver in the gui package must handle every entry
|
||||
// here; a test enforces that the two stay in sync.
|
||||
var ValidSidePanelTabs = []string{
|
||||
"status",
|
||||
"files",
|
||||
"worktrees",
|
||||
"submodules",
|
||||
"branches",
|
||||
"remotes",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflog",
|
||||
"stash",
|
||||
}
|
||||
|
||||
func (p SidePanel) MarshalYAML() (any, error) {
|
||||
// Render in flow style (`[a, b]`) rather than the default block style, which
|
||||
// is more compact and reads better in the generated docs.
|
||||
node := &yaml.Node{
|
||||
Kind: yaml.SequenceNode,
|
||||
Style: yaml.FlowStyle,
|
||||
}
|
||||
for _, s := range p {
|
||||
node.Content = append(node.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: s,
|
||||
})
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// JSONSchema describes a side panel as a list of tab names, restricted to the
|
||||
// known names.
|
||||
func (SidePanel) JSONSchema() *jsonschema.Schema {
|
||||
names := lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name })
|
||||
return &jsonschema.Schema{
|
||||
Type: "array",
|
||||
Items: &jsonschema.Schema{Type: "string", Enum: names},
|
||||
}
|
||||
}
|
||||
|
|
@ -44,10 +44,13 @@ type UserConfig struct {
|
|||
type RefresherConfig struct {
|
||||
// File/submodule refresh interval in seconds.
|
||||
// Auto-refresh can be disabled via option 'git.autoRefresh'.
|
||||
RefreshInterval int `yaml:"refreshInterval" jsonschema:"minimum=0"`
|
||||
RefreshInterval int `yaml:"refreshInterval" jsonschema:"exclusiveMinimum=0"`
|
||||
// Re-fetch interval in seconds.
|
||||
// Auto-fetch can be disabled via option 'git.autoFetch'.
|
||||
FetchInterval int `yaml:"fetchInterval" jsonschema:"minimum=0"`
|
||||
FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"`
|
||||
// Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).
|
||||
// Detection can be disabled via option 'git.autoDetectExternalChanges'.
|
||||
ExternalChangeCheckInterval int `yaml:"externalChangeCheckInterval" jsonschema:"exclusiveMinimum=0"`
|
||||
}
|
||||
|
||||
func (c *RefresherConfig) RefreshIntervalDuration() time.Duration {
|
||||
|
|
@ -58,6 +61,10 @@ func (c *RefresherConfig) FetchIntervalDuration() time.Duration {
|
|||
return time.Second * time.Duration(c.FetchInterval)
|
||||
}
|
||||
|
||||
func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
|
||||
return time.Second * time.Duration(c.ExternalChangeCheckInterval)
|
||||
}
|
||||
|
||||
type GuiConfig struct {
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
|
|
@ -102,6 +109,11 @@ type GuiConfig struct {
|
|||
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
|
||||
// The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.
|
||||
ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"`
|
||||
// The side panels, in the order they appear from top to bottom.
|
||||
// Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).
|
||||
// Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.
|
||||
// Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.
|
||||
SidePanels []SidePanel `yaml:"sidePanels"`
|
||||
// Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
|
||||
// Options are:
|
||||
// - 'horizontal': split the window horizontally
|
||||
|
|
@ -296,6 +308,8 @@ type GitConfig struct {
|
|||
AutoFetch bool `yaml:"autoFetch"`
|
||||
// If true, periodically refresh files and submodules
|
||||
AutoRefresh bool `yaml:"autoRefresh"`
|
||||
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
|
||||
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
|
||||
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
|
||||
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
|
||||
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
|
||||
|
|
@ -528,6 +542,7 @@ type KeybindingUniversalConfig struct {
|
|||
IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"`
|
||||
DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"`
|
||||
OpenDiffTool Keybinding `yaml:"openDiffTool"`
|
||||
EditConfig Keybinding `yaml:"editConfig"`
|
||||
}
|
||||
|
||||
type KeybindingStatusConfig struct {
|
||||
|
|
@ -844,6 +859,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
|||
SidePanelWidth: 0.3333,
|
||||
ExpandFocusedSidePanel: false,
|
||||
ExpandedSidePanelWeight: 2,
|
||||
SidePanels: []SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
{"stash"},
|
||||
},
|
||||
MainPanelSplitMode: "flexible",
|
||||
EnlargedSideViewLocation: "left",
|
||||
WrapLinesInStagingView: true,
|
||||
|
|
@ -927,6 +949,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
|||
MainBranches: []string{"master", "main"},
|
||||
AutoFetch: true,
|
||||
AutoRefresh: true,
|
||||
AutoDetectExternalChanges: true,
|
||||
AutoForwardBranches: "onlyMainBranches",
|
||||
FetchAll: true,
|
||||
AutoStageResolvedConflicts: true,
|
||||
|
|
@ -942,8 +965,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
|||
TruncateCopiedCommitHashesTo: 12,
|
||||
},
|
||||
Refresher: RefresherConfig{
|
||||
RefreshInterval: 10,
|
||||
FetchInterval: 60,
|
||||
RefreshInterval: 10,
|
||||
FetchInterval: 60,
|
||||
ExternalChangeCheckInterval: 2,
|
||||
},
|
||||
Update: UpdateConfig{
|
||||
Method: "prompt",
|
||||
|
|
@ -1040,6 +1064,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
|||
IncreaseRenameSimilarityThreshold: Keybinding{")"},
|
||||
DecreaseRenameSimilarityThreshold: Keybinding{"("},
|
||||
OpenDiffTool: Keybinding{"<ctrl+t>"},
|
||||
EditConfig: Keybinding{"<alt+shift+c>"},
|
||||
},
|
||||
Status: KeybindingStatusConfig{
|
||||
CheckForUpdate: Keybinding{"u"},
|
||||
|
|
|
|||
|
|
@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error {
|
|||
if err := validateSpinner(config.Gui.Spinner); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSidePanels(config.Gui.SidePanels); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSidePanels(panels []SidePanel) error {
|
||||
seen := map[string]bool{}
|
||||
total := 0
|
||||
for _, panel := range panels {
|
||||
if len(panel) == 0 {
|
||||
return errors.New("gui.sidePanels: a side panel must have at least one tab.")
|
||||
}
|
||||
for _, name := range panel {
|
||||
if !slices.Contains(ValidSidePanelTabs, name) {
|
||||
return fmt.Errorf("gui.sidePanels: unknown side panel '%s'. Allowed values: %s",
|
||||
name, strings.Join(ValidSidePanelTabs, ", "))
|
||||
}
|
||||
if seen[name] {
|
||||
return fmt.Errorf("gui.sidePanels: '%s' is listed more than once; each side panel may appear only once.", name)
|
||||
}
|
||||
seen[name] = true
|
||||
total++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return errors.New("gui.sidePanels must not be empty.")
|
||||
}
|
||||
// A lot of code focuses these panels directly (e.g. after resolving a
|
||||
// conflict or popping a stash), so they must always be present; otherwise
|
||||
// that code would focus a hidden panel.
|
||||
for _, required := range []string{"files", "branches", "commits"} {
|
||||
if !seen[required] {
|
||||
return fmt.Errorf("gui.sidePanels: '%s' must be included; it can't be hidden.", required)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -141,16 +177,7 @@ func validateKeybindingsRecurse(path string, node any) error {
|
|||
}
|
||||
|
||||
func validateKeybindings(keybindingConfig KeybindingConfig) error {
|
||||
if err := validateKeybindingsRecurse("", keybindingConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(keybindingConfig.Universal.JumpToBlock) != 5 {
|
||||
return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.",
|
||||
len(keybindingConfig.Universal.JumpToBlock))
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateKeybindingsRecurse("", keybindingConfig)
|
||||
}
|
||||
|
||||
func validateCustomCommandKey(key Keybinding) error {
|
||||
|
|
|
|||
|
|
@ -134,11 +134,12 @@ func TestUserConfigValidate_enums(t *testing.T) {
|
|||
})
|
||||
},
|
||||
testCases: []testCase{
|
||||
{value: "", valid: false},
|
||||
{value: "1,2,3", valid: false},
|
||||
// The number of entries no longer has to match the number of side
|
||||
// panels, so only the validity of the individual keys matters.
|
||||
{value: "1,2,3", valid: true},
|
||||
{value: "1,2,3,4,5", valid: true},
|
||||
{value: "1,2,3,4,5,6", valid: true},
|
||||
{value: "1,2,3,4,invalid", valid: false},
|
||||
{value: "1,2,3,4,5,6", valid: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -324,6 +325,42 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUserConfigValidate_sidePanels(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
panels []SidePanel
|
||||
valid bool
|
||||
}{
|
||||
{name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true},
|
||||
{name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true},
|
||||
{name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true},
|
||||
{name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true},
|
||||
{name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true},
|
||||
{name: "empty", panels: []SidePanel{}, valid: false},
|
||||
{name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false},
|
||||
{name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false},
|
||||
{name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false},
|
||||
{name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false},
|
||||
{name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false},
|
||||
{name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false},
|
||||
{name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
config := GetDefaultConfig()
|
||||
config.Gui.SidePanels = s.panels
|
||||
err := config.Validate()
|
||||
|
||||
if s.valid {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfigValidate_pagers(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ type replayedEvents struct {
|
|||
Keys chan *TcellKeyEventWrapper
|
||||
Resizes chan *TcellResizeEventWrapper
|
||||
MouseEvents chan *TcellMouseEventWrapper
|
||||
FocusEvents chan *TcellFocusEventWrapper
|
||||
}
|
||||
|
||||
type RecordingConfig struct {
|
||||
|
|
@ -245,6 +246,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
|||
Keys: make(chan *TcellKeyEventWrapper),
|
||||
Resizes: make(chan *TcellResizeEventWrapper),
|
||||
MouseEvents: make(chan *TcellMouseEventWrapper),
|
||||
FocusEvents: make(chan *TcellFocusEventWrapper),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1345,7 +1347,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
}
|
||||
|
||||
visibleLineWidth := 0
|
||||
for _, c := range v.lines[newY] {
|
||||
for _, c := range v.lines[newY].cells {
|
||||
visibleLineWidth += c.width
|
||||
}
|
||||
if visibleLineWidth < newX {
|
||||
|
|
|
|||
|
|
@ -266,6 +266,22 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
|
|||
return tcell.NewEventResize(wrapper.Width, wrapper.Height)
|
||||
}
|
||||
|
||||
type TcellFocusEventWrapper struct {
|
||||
Timestamp int64
|
||||
Focused bool
|
||||
}
|
||||
|
||||
func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
|
||||
return &TcellFocusEventWrapper{
|
||||
Timestamp: timestamp,
|
||||
Focused: event.Focused,
|
||||
}
|
||||
}
|
||||
|
||||
func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
|
||||
return tcell.NewEventFocus(wrapper.Focused)
|
||||
}
|
||||
|
||||
// pollEvent get tcell.Event and transform it into gocuiEvent
|
||||
func (g *Gui) pollEvent() GocuiEvent {
|
||||
var tev tcell.Event
|
||||
|
|
@ -277,6 +293,8 @@ func (g *Gui) pollEvent() GocuiEvent {
|
|||
tev = (ev).toTcellEvent()
|
||||
case ev := <-g.ReplayedEvents.MouseEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
case ev := <-g.ReplayedEvents.FocusEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
}
|
||||
} else {
|
||||
tev = <-Screen.EventQ()
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ const (
|
|||
// position.
|
||||
type View struct {
|
||||
name string
|
||||
x0, y0, x1, y1 int // left top right bottom
|
||||
ox, oy int // view offsets
|
||||
cx, cy int // cursor position
|
||||
rx, ry int // Read() offsets
|
||||
wx, wy int // Write() offsets
|
||||
lines [][]cell // All the data
|
||||
x0, y0, x1, y1 int // left top right bottom
|
||||
ox, oy int // view offsets
|
||||
cx, cy int // cursor position
|
||||
rx, ry int // Read() offsets
|
||||
wx, wy int // Write() offsets
|
||||
lines []lineType // All the data
|
||||
outMode OutputMode
|
||||
// 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
|
||||
|
|
@ -444,6 +444,28 @@ type SearchPosition struct {
|
|||
type viewLine struct {
|
||||
linesX, linesY int // coordinates relative to v.lines
|
||||
line []cell
|
||||
|
||||
// Colors used to extend the bg past this wrapped segment's content.
|
||||
// Derived at wrap time from the source line — see refreshViewLinesIfNeeded
|
||||
// for the per-segment rule.
|
||||
trailingFillAttributes *trailingFillAttributes
|
||||
}
|
||||
|
||||
// lineType is one of v.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 {
|
||||
cells cells
|
||||
trailingFillAttributes *trailingFillAttributes
|
||||
}
|
||||
|
||||
// trailingFillAttributes describes the fg/bg colors that draw() should
|
||||
// use for cells past the end of a wrapped segment's content. On a source
|
||||
// line this records what the writer asked for via '\x1b[K' (and so opts
|
||||
// the line in to trailing fill at all); the per-segment values on each
|
||||
// viewLine are derived from it at wrap time.
|
||||
type trailingFillAttributes struct {
|
||||
fg, bg Attribute
|
||||
}
|
||||
|
||||
type cell struct {
|
||||
|
|
@ -453,7 +475,7 @@ type cell struct {
|
|||
hyperlink string
|
||||
}
|
||||
|
||||
type lineType []cell
|
||||
type cells []cell
|
||||
|
||||
func characterEquals(chr []byte, b byte) bool {
|
||||
return len(chr) == 1 && chr[0] == b
|
||||
|
|
@ -464,7 +486,7 @@ func isCRLF(chr []byte) bool {
|
|||
}
|
||||
|
||||
// String returns a string from a given cell slice.
|
||||
func (l lineType) String() string {
|
||||
func (l cells) String() string {
|
||||
var str strings.Builder
|
||||
for _, c := range l {
|
||||
str.WriteString(c.chr)
|
||||
|
|
@ -738,20 +760,20 @@ func (v *View) makeWriteable(x, y int) {
|
|||
}
|
||||
v.lines = v.lines[:newLen]
|
||||
} else {
|
||||
v.lines = append(v.lines, nil)
|
||||
v.lines = append(v.lines, lineType{})
|
||||
}
|
||||
}
|
||||
// cell `x` need not be index-able (that's why `<`)
|
||||
// append should be used by `lines[y]` user if he wants to write beyond `x`
|
||||
for len(v.lines[y]) < x {
|
||||
if cap(v.lines[y]) > len(v.lines[y]) {
|
||||
newLen := cap(v.lines[y])
|
||||
for len(v.lines[y].cells) < x {
|
||||
if cap(v.lines[y].cells) > len(v.lines[y].cells) {
|
||||
newLen := cap(v.lines[y].cells)
|
||||
if newLen > x {
|
||||
newLen = x
|
||||
}
|
||||
v.lines[y] = v.lines[y][:newLen]
|
||||
v.lines[y].cells = v.lines[y].cells[:newLen]
|
||||
} else {
|
||||
v.lines[y] = append(v.lines[y], cell{})
|
||||
v.lines[y].cells = append(v.lines[y].cells, cell{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -761,7 +783,7 @@ func (v *View) makeWriteable(x, y int) {
|
|||
func (v *View) writeCells(cells []cell) {
|
||||
var newLen int
|
||||
// use maximum len available
|
||||
line := v.lines[v.wy][:cap(v.lines[v.wy])]
|
||||
line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)]
|
||||
maxCopy := len(line) - v.wx
|
||||
if maxCopy < len(cells) {
|
||||
copy(line[v.wx:], cells[:maxCopy])
|
||||
|
|
@ -770,11 +792,11 @@ func (v *View) writeCells(cells []cell) {
|
|||
} else { // maxCopy >= len(cells)
|
||||
copy(line[v.wx:], cells)
|
||||
newLen = v.wx + len(cells)
|
||||
if newLen < len(v.lines[v.wy]) {
|
||||
newLen = len(v.lines[v.wy])
|
||||
if newLen < len(v.lines[v.wy].cells) {
|
||||
newLen = len(v.lines[v.wy].cells)
|
||||
}
|
||||
}
|
||||
v.lines[v.wy] = line[:newLen]
|
||||
v.lines[v.wy].cells = line[:newLen]
|
||||
v.wx += len(cells)
|
||||
}
|
||||
|
||||
|
|
@ -800,21 +822,13 @@ func (v *View) write(p []byte) {
|
|||
|
||||
finishLine := func() {
|
||||
v.autoRenderHyperlinksInCurrentLine()
|
||||
if v.wx >= len(v.lines[v.wy]) {
|
||||
v.writeCells([]cell{{
|
||||
chr: "",
|
||||
width: 0,
|
||||
fgColor: 0,
|
||||
bgColor: 0,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
advanceToNextLine := func() {
|
||||
v.wx = 0
|
||||
v.wy++
|
||||
if v.wy >= len(v.lines) {
|
||||
v.lines = append(v.lines, nil)
|
||||
v.lines = append(v.lines, lineType{})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -851,7 +865,7 @@ func (v *View) write(p []byte) {
|
|||
}
|
||||
v.writeCells(cells)
|
||||
if truncateLine {
|
||||
v.lines[v.wy] = v.lines[v.wy][:v.wx]
|
||||
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -910,7 +924,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
|
|||
return
|
||||
}
|
||||
|
||||
line := v.lines[v.wy]
|
||||
line := v.lines[v.wy].cells
|
||||
start := 0
|
||||
for {
|
||||
linkStart := findLinkStart(line[start:])
|
||||
|
|
@ -927,7 +941,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
|
|||
link.WriteString(line[linkEnd].chr)
|
||||
}
|
||||
for i := linkStart; i < linkEnd; i++ {
|
||||
v.lines[v.wy][i].hyperlink = link.String()
|
||||
v.lines[v.wy].cells[i].hyperlink = link.String()
|
||||
}
|
||||
start = linkEnd
|
||||
}
|
||||
|
|
@ -955,16 +969,19 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
|
|||
} else {
|
||||
repeatCount := 1
|
||||
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
|
||||
// fill rest of line
|
||||
// Discard any old content past the cursor and record the
|
||||
// fill colors so draw() paints the trailing area with them.
|
||||
// This extends the bg to the right edge in both the
|
||||
// content-fits and content-wraps cases — for the latter,
|
||||
// the metadata is what reaches every wrapped segment past
|
||||
// the last word.
|
||||
v.ei.instructionRead()
|
||||
cx := 0
|
||||
for _, cell := range v.lines[v.wy][0:v.wx] {
|
||||
cx += cell.width
|
||||
}
|
||||
repeatCount = v.InnerWidth() - cx
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
truncateLine = true
|
||||
v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{
|
||||
fg: v.ei.curFgColor,
|
||||
bg: v.ei.curBgColor,
|
||||
}
|
||||
return truncateLine, []cell{}
|
||||
} else if isEscape {
|
||||
// do not output anything
|
||||
return truncateLine, nil
|
||||
|
|
@ -1010,8 +1027,8 @@ func (v *View) Read(p []byte) (n int, err error) {
|
|||
v.readBuffer = nil
|
||||
}
|
||||
for v.ry < len(v.lines) {
|
||||
for v.rx < len(v.lines[v.ry]) {
|
||||
s := v.lines[v.ry][v.rx].chr
|
||||
for v.rx < len(v.lines[v.ry].cells) {
|
||||
s := v.lines[v.ry].cells[v.rx].chr
|
||||
count := len(s)
|
||||
copy(p[offset:], s)
|
||||
v.rx++
|
||||
|
|
@ -1175,9 +1192,9 @@ func (v *View) updateSearchPositions() {
|
|||
}
|
||||
|
||||
// If a view line exists for this line index:
|
||||
if v.lines[result.Y] != nil {
|
||||
if v.lines[result.Y].cells != nil {
|
||||
// search this view line for the search string
|
||||
positions := searchPositionsForLine(v.lines[result.Y], result.Y)
|
||||
positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y)
|
||||
if len(positions) > 0 {
|
||||
// If we found any occurrences, add them
|
||||
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
|
||||
|
|
@ -1248,13 +1265,21 @@ func (v *View) draw() {
|
|||
}
|
||||
|
||||
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
|
||||
var prevFgColor Attribute
|
||||
|
||||
for y, vline := range v.viewLines[start:] {
|
||||
if y >= maxY {
|
||||
break
|
||||
}
|
||||
|
||||
// Decide the colors used for cells past the end of vline.line:
|
||||
// the source line's trailingFillAttributes (set by '\x1b[K') if
|
||||
// any, otherwise plain defaults.
|
||||
trailingCell := emptyCell
|
||||
if attrs := vline.trailingFillAttributes; attrs != nil {
|
||||
trailingCell.fgColor = attrs.fg
|
||||
trailingCell.bgColor = attrs.bg
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -1277,14 +1302,9 @@ func (v *View) draw() {
|
|||
|
||||
// if we're out of cells to write, we'll just print empty cells.
|
||||
if cellIdx > len(vline.line)-1 {
|
||||
c = emptyCell
|
||||
c.fgColor = prevFgColor
|
||||
c = trailingCell
|
||||
} else {
|
||||
c = vline.line[cellIdx]
|
||||
// capturing previous foreground colour so that if we're using the reverse
|
||||
// attribute we honour the final character's colour and don't awkwardly switch
|
||||
// to a new background colour for the remainder of the line
|
||||
prevFgColor = c.fgColor
|
||||
}
|
||||
|
||||
fgColor := c.fgColor
|
||||
|
|
@ -1318,9 +1338,27 @@ func (v *View) refreshViewLinesIfNeeded() {
|
|||
wrap = maxX
|
||||
}
|
||||
|
||||
ls := lineWrap(line, wrap)
|
||||
ls := lineWrap(line.cells, wrap)
|
||||
for j := range ls {
|
||||
vline := viewLine{linesX: j, linesY: i, line: ls[j]}
|
||||
// Per-segment trailing fill. When the source line opted in
|
||||
// via '\x1b[K', the LAST wrapped segment uses those colors
|
||||
// directly; earlier segments use the colors of their own
|
||||
// last cell, so the trailing area matches the bg active
|
||||
// where that segment ended rather than bleeding the
|
||||
// '\x1b[K' bg back across color changes in the line.
|
||||
var attrs *trailingFillAttributes
|
||||
if line.trailingFillAttributes != nil {
|
||||
if j == len(ls)-1 {
|
||||
attrs = line.trailingFillAttributes
|
||||
} else if len(ls[j]) > 0 {
|
||||
last := ls[j][len(ls[j])-1]
|
||||
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
|
||||
}
|
||||
}
|
||||
vline := viewLine{
|
||||
linesX: j, linesY: i, line: ls[j],
|
||||
trailingFillAttributes: attrs,
|
||||
}
|
||||
|
||||
if lineIdx > len(v.viewLines)-1 {
|
||||
v.viewLines = append(v.viewLines, vline)
|
||||
|
|
@ -1411,9 +1449,7 @@ func (v *View) BufferLines() []string {
|
|||
|
||||
lines := make([]string, len(v.lines))
|
||||
for i, l := range v.lines {
|
||||
str := lineType(l).String()
|
||||
str = strings.ReplaceAll(str, "\x00", "")
|
||||
lines[i] = str
|
||||
lines[i] = l.cells.String()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
|
@ -1434,9 +1470,7 @@ func (v *View) ViewBufferLines() []string {
|
|||
|
||||
lines := make([]string, len(v.viewLines))
|
||||
for i, l := range v.viewLines {
|
||||
str := lineType(l.line).String()
|
||||
str = strings.ReplaceAll(str, "\x00", "")
|
||||
lines[i] = str
|
||||
lines[i] = cells(l.line).String()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
|
@ -1458,12 +1492,12 @@ func (v *View) ViewLinesHeight() int {
|
|||
// ViewBuffer returns a string with the contents of the view's buffer that is
|
||||
// shown to the user.
|
||||
func (v *View) ViewBuffer() string {
|
||||
lines := make([][]cell, len(v.viewLines))
|
||||
strs := make([]string, len(v.viewLines))
|
||||
for i := range v.viewLines {
|
||||
lines[i] = v.viewLines[i].line
|
||||
strs[i] = cells(v.viewLines[i].line).String()
|
||||
}
|
||||
|
||||
return linesToString(lines)
|
||||
return strings.Join(strs, "\n")
|
||||
}
|
||||
|
||||
// Line returns a string with the line of the view's internal buffer
|
||||
|
|
@ -1478,7 +1512,7 @@ func (v *View) Line(y int) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
return lineType(v.lines[y]).String(), true
|
||||
return v.lines[y].cells.String(), true
|
||||
}
|
||||
|
||||
// Word returns a string with the word of the view's internal buffer
|
||||
|
|
@ -1489,11 +1523,11 @@ func (v *View) Word(x, y int) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y]) {
|
||||
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
str := lineType(v.lines[y]).String()
|
||||
str := v.lines[y].cells.String()
|
||||
|
||||
nl := strings.LastIndexFunc(str[:x], indexFunc)
|
||||
if nl == -1 {
|
||||
|
|
@ -1523,9 +1557,8 @@ func (v *View) SetHighlight(y int, on bool) {
|
|||
return
|
||||
}
|
||||
|
||||
line := v.lines[y]
|
||||
cells := make([]cell, 0)
|
||||
for _, c := range line {
|
||||
cells := make([]cell, 0, len(v.lines[y].cells))
|
||||
for _, c := range v.lines[y].cells {
|
||||
if on {
|
||||
c.bgColor = v.SelBgColor
|
||||
c.fgColor = v.SelFgColor
|
||||
|
|
@ -1536,7 +1569,7 @@ func (v *View) SetHighlight(y int, on bool) {
|
|||
cells = append(cells, c)
|
||||
}
|
||||
v.tainted = true
|
||||
v.lines[y] = cells
|
||||
v.lines[y].cells = cells
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
|
|
@ -1602,17 +1635,10 @@ func lineWrap(line []cell, columns int) [][]cell {
|
|||
return lines
|
||||
}
|
||||
|
||||
func linesToString(lines [][]cell) string {
|
||||
func linesToString(lines []lineType) string {
|
||||
str := make([]string, len(lines))
|
||||
for i := range lines {
|
||||
rns := make([]rune, 0, len(lines[i]))
|
||||
line := lineType(lines[i]).String()
|
||||
for _, c := range line {
|
||||
if c != '\x00' {
|
||||
rns = append(rns, c)
|
||||
}
|
||||
}
|
||||
str[i] = string(rns)
|
||||
str[i] = lines[i].cells.String()
|
||||
}
|
||||
|
||||
return strings.Join(str, "\n")
|
||||
|
|
@ -1682,9 +1708,7 @@ func (v *View) SelectedLines() []string {
|
|||
}
|
||||
|
||||
func (v *View) lineContentAtIdx(idx int) string {
|
||||
line := v.lines[idx]
|
||||
str := lineType(line).String()
|
||||
return strings.ReplaceAll(str, "\x00", "")
|
||||
return v.lines[idx].cells.String()
|
||||
}
|
||||
|
||||
func (v *View) SelectedPoint() (int, int) {
|
||||
|
|
@ -1788,11 +1812,11 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
|
|||
v.overwriteLines(y, content)
|
||||
|
||||
for i := range y {
|
||||
v.lines[i] = nil
|
||||
v.lines[i] = lineType{}
|
||||
}
|
||||
|
||||
for i := v.wy + 1; i < len(v.lines); i += 1 {
|
||||
v.lines[i] = nil
|
||||
v.lines[i] = lineType{}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1935,7 +1959,7 @@ func (v *View) scrollMargin() int {
|
|||
// foreground color
|
||||
func (v *View) ContainsColoredText(fgColor string, text string) bool {
|
||||
for _, line := range v.lines {
|
||||
if containsColoredTextInLine(fgColor, text, line) {
|
||||
if containsColoredTextInLine(fgColor, text, line.cells) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,28 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/gdamore/tcell/v3/color"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// WithSimulationScreen swaps the package-level Screen for a tcell
|
||||
// terminfo-backed mock terminal so tests can call view.draw() and
|
||||
// inspect rendered cells via Screen.Get(). The previous Screen is
|
||||
// restored on test cleanup.
|
||||
func WithSimulationScreen(t *testing.T, width, height int) {
|
||||
t.Helper()
|
||||
saved := Screen
|
||||
if err := (&Gui{}).tcellInitSimulation(width, height); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
Screen.Fini()
|
||||
Screen = saved
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteString(t *testing.T) {
|
||||
tests := []struct {
|
||||
existingLines []string
|
||||
|
|
@ -26,17 +44,17 @@ func TestWriteString(t *testing.T) {
|
|||
{
|
||||
[]string{},
|
||||
[]string{"1\n"},
|
||||
[][]string{{"1", ""}},
|
||||
[][]string{{"1"}},
|
||||
},
|
||||
{
|
||||
[]string{},
|
||||
[]string{"1\n", "2\n"},
|
||||
[][]string{{"1", ""}, {"2", ""}},
|
||||
[][]string{{"1"}, {"2"}},
|
||||
},
|
||||
{
|
||||
[]string{"a"},
|
||||
[]string{"1\n"},
|
||||
[][]string{{"1", ""}},
|
||||
[][]string{{"1"}},
|
||||
},
|
||||
{
|
||||
[]string{"a\x00"},
|
||||
|
|
@ -56,12 +74,12 @@ func TestWriteString(t *testing.T) {
|
|||
{
|
||||
[]string{},
|
||||
[]string{"1\r"},
|
||||
[][]string{{"1", ""}},
|
||||
[][]string{{"1"}},
|
||||
},
|
||||
{
|
||||
[]string{"a"},
|
||||
[]string{"1\r"},
|
||||
[][]string{{"1", ""}},
|
||||
[][]string{{"1"}},
|
||||
},
|
||||
{
|
||||
[]string{"a\x00"},
|
||||
|
|
@ -83,14 +101,14 @@ func TestWriteString(t *testing.T) {
|
|||
for _, test := range tests {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal)
|
||||
for _, l := range test.existingLines {
|
||||
v.lines = append(v.lines, stringToCells(l))
|
||||
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
|
||||
}
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.lines {
|
||||
resultingLines = append(resultingLines, cellsToStrings(l))
|
||||
resultingLines = append(resultingLines, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, test.expectedLines, resultingLines)
|
||||
}
|
||||
|
|
@ -126,19 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
|||
|
||||
v.writeString("htt")
|
||||
// No hyperlinks are generated for incomplete URLs
|
||||
assert.Equal(t, "", v.lines[0][0].hyperlink)
|
||||
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same line makes the link complete (even
|
||||
// though we didn't see a newline yet)
|
||||
v.writeString("ps://example.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
|
||||
v.Clear()
|
||||
// Valid but incomplete URL
|
||||
v.writeString("https://exa")
|
||||
assert.Equal(t, "https://exa", v.lines[0][0].hyperlink)
|
||||
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same fixes the link
|
||||
v.writeString("mple.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
|
|
@ -211,7 +229,11 @@ func TestContainsColoredText(t *testing.T) {
|
|||
}
|
||||
|
||||
for i, test := range tests {
|
||||
v := &View{lines: test.lines}
|
||||
lines := make([]lineType, len(test.lines))
|
||||
for j, cells := range test.lines {
|
||||
lines[j] = lineType{cells: cells}
|
||||
}
|
||||
v := &View{lines: lines}
|
||||
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
|
||||
}
|
||||
}
|
||||
|
|
@ -413,3 +435,147 @@ func TestLineWrap(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewlineTerminatedLineClearsTrailingBg verifies that a '\n' resets
|
||||
// any attributes (e.g. AttrReverse-driven background) past the line's
|
||||
// content, so a reversed cell at the end doesn't bleed into the empty
|
||||
// area to the right.
|
||||
func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 5)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 4, OutputNormal)
|
||||
|
||||
// \x1b[7m sets reverse; \x1b[31m sets fg=red. With reverse the cell
|
||||
// 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()
|
||||
|
||||
// First row: cells 1..3 are "foo" (render with red bg via reverse),
|
||||
// cells 4..10 are trailing and should be plain default.
|
||||
for x := 4; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, 1)
|
||||
assert.Equal(t, tcell.ColorDefault, style.GetForeground(),
|
||||
"trailing cell at (%d, 1) should have default fg", x)
|
||||
assert.False(t, style.HasReverse(),
|
||||
"trailing cell at (%d, 1) should not have reverse attribute", x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnterminatedReverseLineDoesNotExtend verifies that an unterminated
|
||||
// line ending with an AttrReverse cell does NOT propagate the reversed
|
||||
// background past the line's content — matching real terminal behavior
|
||||
// (try `print '\x1b[7m\x1b[31mfoo'` in a shell). The trailing area
|
||||
// is rendered as plain default.
|
||||
func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 5)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 4, OutputNormal)
|
||||
|
||||
// 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()
|
||||
|
||||
// Cells 4..10 are trailing and should be default with no reverse.
|
||||
for x := 4; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, 1)
|
||||
assert.Equal(t, tcell.ColorDefault, style.GetForeground(),
|
||||
"trailing cell at (%d, 1) should have default fg", x)
|
||||
assert.False(t, style.HasReverse(),
|
||||
"trailing cell at (%d, 1) should not have reverse attribute", x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortFilledLineExtendsBgWithoutWrap verifies that '\x1b[K' fills
|
||||
// the rest of the line with the current bg color for a line that's
|
||||
// short enough to fit within the view's inner width.
|
||||
func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 5)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 4, OutputNormal)
|
||||
|
||||
// \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()
|
||||
|
||||
// All ten cells at (1..10, 1) should have red bg.
|
||||
for x := 1; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, 1)
|
||||
assert.Equal(t, color.Maroon, style.GetBackground(),
|
||||
"cell at (%d, 1) should have red bg", x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrappedFilledLineExtendsBgToEdge verifies that when a line is
|
||||
// filled to the edge with \x1b[K (the pattern used by `delta` for diff
|
||||
// lines) but exceeds the view's inner width, every wrapped segment
|
||||
// extends the fill background past its content to the right edge.
|
||||
func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10,
|
||||
// InnerHeight=4. Frame inset of 1 places content cells at screen
|
||||
// (1..10, 1..4).
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Wrap = true
|
||||
|
||||
// Content with spaces so word wrap ends each segment before the
|
||||
// right edge: "aaa bbb ccc ddd eee" wraps at InnerWidth=10 to three
|
||||
// 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()
|
||||
|
||||
// All three wrapped rows should have the red fill background across
|
||||
// the full InnerWidth, including the trailing cells past each row's
|
||||
// last word.
|
||||
for y := 1; y <= 3; y++ {
|
||||
for x := 1; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, y)
|
||||
assert.Equal(t, color.Maroon, style.GetBackground(),
|
||||
"cell at (%d, %d) should have red bg", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMulticolorWrappedFillUsesLastCellOfEachSegment demonstrates that
|
||||
// when a wrapped line switches bg color part-way through and ends with
|
||||
// \x1b[K, the trailing area on each wrapped row should match the bg
|
||||
// that was active where that row's content ended — not the \x1b[K bg,
|
||||
// which would bleed the color from the end of the logical line back
|
||||
// into the earlier wrapped rows.
|
||||
func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10,
|
||||
// InnerHeight=4. Frame inset of 1 places content cells at screen
|
||||
// (1..10, 1..4).
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Wrap = true
|
||||
|
||||
// Content "aaa bbb ccc" is 11 cells; lineWrap breaks at the space
|
||||
// between "bbb" and "ccc" (index 7) so segment 1 is "aaa bbb" (red,
|
||||
// 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()
|
||||
|
||||
// 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.
|
||||
for x := 8; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, 1)
|
||||
assert.Equal(t, color.Maroon, style.GetBackground(),
|
||||
"trailing cell at (%d, 1) should have red bg (matching segment's last cell)", x)
|
||||
}
|
||||
|
||||
// Row 2's content ends with a green cell at x=3, so trailing
|
||||
// columns 4..10 should pick up green (matching both the segment's
|
||||
// last cell and the \x1b[K bg — these happen to agree here).
|
||||
for x := 4; x <= 10; x++ {
|
||||
_, style, _ := Screen.Get(x, 2)
|
||||
assert.Equal(t, color.Green, style.GetBackground(),
|
||||
"trailing cell at (%d, 2) should have green bg", x)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package gui
|
|||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
|
|
@ -13,17 +14,27 @@ import (
|
|||
type BackgroundRoutineMgr struct {
|
||||
gui *Gui
|
||||
|
||||
// if we've suspended the gui (e.g. because we've switched to a subprocess)
|
||||
// we typically want to pause some things that are running like background
|
||||
// file refreshes
|
||||
pauseBackgroundRefreshes bool
|
||||
// When this is greater than zero, the background routines (e.g. file refresh)
|
||||
// skip their work. We pause them while the gui is suspended (e.g. for a
|
||||
// subprocess) and while lazygit is itself driving a git operation that would
|
||||
// otherwise be caught mid-flight (see the waiting-status helpers). It's a
|
||||
// count rather than a bool because these pause scopes can overlap.
|
||||
pauseRefreshesCount atomic.Int32
|
||||
|
||||
// a channel to trigger an immediate background fetch; we use this when switching repos
|
||||
triggerFetch chan struct{}
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) PauseBackgroundRefreshes(pause bool) {
|
||||
self.pauseBackgroundRefreshes = pause
|
||||
if pause {
|
||||
self.pauseRefreshesCount.Add(1)
|
||||
} else {
|
||||
self.pauseRefreshesCount.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) backgroundRefreshesPaused() bool {
|
||||
return self.pauseRefreshesCount.Load() > 0
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
|
||||
|
|
@ -51,6 +62,17 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
|
|||
}
|
||||
}
|
||||
|
||||
if userConfig.Git.AutoDetectExternalChanges {
|
||||
interval := userConfig.Refresher.ExternalChangeCheckInterval
|
||||
if interval > 0 {
|
||||
go utils.Safe(self.startBackgroundExternalChangeDetection)
|
||||
} else {
|
||||
self.gui.c.Log.Errorf(
|
||||
"Value of config option 'refresher.externalChangeCheckInterval' (%d) is invalid, disabling external change detection",
|
||||
interval)
|
||||
}
|
||||
}
|
||||
|
||||
if self.gui.Config.GetDebug() {
|
||||
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
|
||||
formatBytes := func(b uint64) string {
|
||||
|
|
@ -111,11 +133,60 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
|
|||
|
||||
userConfig := self.gui.UserConfig()
|
||||
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error {
|
||||
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
|
||||
self.gui.waitForIntro.Wait()
|
||||
|
||||
// We don't seed the snapshot here. The startup refresh captures one on
|
||||
// entry (like every refs-touching refresh), and until one has been
|
||||
// captured RefsSnapshotChangedSince treats the empty baseline as
|
||||
// "unchanged", so we never fire a spurious refresh before a baseline
|
||||
// exists — no need to depend on the timing of that startup refresh.
|
||||
|
||||
userConfig := self.gui.UserConfig()
|
||||
self.goEvery(
|
||||
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
|
||||
self.gui.stopChan,
|
||||
func(_ bool) error {
|
||||
self.checkForExternalChanges()
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
||||
current, err := self.gui.git.Status.RefsSnapshot()
|
||||
if err != nil {
|
||||
// Transient error (e.g. git process couldn't start). Don't update the
|
||||
// stored snapshot; we'll retry next tick.
|
||||
self.gui.c.Log.Warnf("RefsSnapshot failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) {
|
||||
return
|
||||
}
|
||||
|
||||
// goEvery checks the pause count before starting us, but a git operation
|
||||
// may have begun (and paused refreshes) after that check, while we were
|
||||
// reading the snapshot above. In that case the change we detected is the
|
||||
// operation's own intermediate state, so back off: the operation will
|
||||
// refresh and re-snapshot when it finishes, and if the change was really
|
||||
// external we'll catch it on the next tick after the pause lifts. We don't
|
||||
// update the stored snapshot, so nothing is swallowed.
|
||||
if self.backgroundRefreshesPaused() {
|
||||
return
|
||||
}
|
||||
|
||||
// No need to update the stored snapshot here; Refresh does that.
|
||||
self.gui.c.Log.Info("External ref change detected — refreshing")
|
||||
self.gui.c.Refresh(types.RefreshOptions{Background: true})
|
||||
}
|
||||
|
||||
// returns a channel that can be used to trigger the callback immediately
|
||||
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
|
||||
done := make(chan struct{})
|
||||
|
|
@ -124,7 +195,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
|
|||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
doit := func(retriggered bool) {
|
||||
if self.pauseBackgroundRefreshes {
|
||||
if self.backgroundRefreshesPaused() {
|
||||
return
|
||||
}
|
||||
self.gui.c.OnWorker(func(gocui.Task) error {
|
||||
|
|
@ -155,7 +226,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
|
|||
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
|
||||
err = self.gui.git.Sync.FetchBackground()
|
||||
|
||||
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err)
|
||||
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true)
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) triggerImmediateFetch() {
|
||||
|
|
|
|||
|
|
@ -594,7 +594,11 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
|
|||
}
|
||||
|
||||
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
36
pkg/gui/controllers/edit_config_action.go
Normal file
36
pkg/gui/controllers/edit_config_action.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type EditConfigAction struct {
|
||||
c *ControllerCommon
|
||||
}
|
||||
|
||||
func (self *EditConfigAction) Call() error {
|
||||
confPaths := self.c.GetConfig().GetUserConfigPaths()
|
||||
switch len(confPaths) {
|
||||
case 0:
|
||||
return errors.New(self.c.Tr.NoConfigFileFoundErr)
|
||||
case 1:
|
||||
return self.c.Helpers().Files.EditFiles(confPaths)
|
||||
default:
|
||||
menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem {
|
||||
return &types.MenuItem{
|
||||
Label: path,
|
||||
OnPress: func() error {
|
||||
return self.c.Helpers().Files.EditFiles([]string{path})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.SelectConfigFile,
|
||||
Items: menuItems,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
|||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.withItems(self.press),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
|
||||
Description: self.c.Tr.Stage,
|
||||
Tooltip: self.c.Tr.StageTooltip,
|
||||
DisplayOnScreen: true,
|
||||
|
|
@ -259,100 +259,157 @@ func (self *FilesController) GetOnRenderToMain() func() {
|
|||
node := self.context().GetSelected()
|
||||
|
||||
if node == nil {
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Title: self.c.Tr.DiffTitle,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles),
|
||||
},
|
||||
})
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
|
||||
return
|
||||
}
|
||||
|
||||
if self.isSubmoduleCommitConflict(node.File) {
|
||||
self.renderSubmoduleConflict(node)
|
||||
return
|
||||
}
|
||||
|
||||
if node.File != nil && node.File.HasInlineMergeConflicts {
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if hasConflicts {
|
||||
self.c.Helpers().MergeConflicts.Render()
|
||||
if self.renderInlineMergeConflict(node) {
|
||||
return
|
||||
}
|
||||
// The file is marked as conflicted but has no conflict markers (it
|
||||
// was resolved in an editor), so fall through to show its diff.
|
||||
} else if node.File != nil && node.File.HasMergeConflicts {
|
||||
opts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Title: self.c.Tr.DiffTitle,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
},
|
||||
}
|
||||
message := node.File.GetMergeStateDescription(self.c.Tr)
|
||||
message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
|
||||
self.c.UserConfig().Keybinding.Universal.GoInto)
|
||||
if self.c.Views().Main.InnerWidth() > 70 {
|
||||
// If the main view is very wide, wrap the message to increase readability
|
||||
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
|
||||
message = strings.Join(lines, "\n")
|
||||
}
|
||||
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
} else {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
} else {
|
||||
opts.Main.Task = types.NewRenderStringTask(message)
|
||||
}
|
||||
self.c.RenderToMainViews(opts)
|
||||
self.renderNonTextualConflict(node)
|
||||
return
|
||||
}
|
||||
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.UnstagedChanges
|
||||
}
|
||||
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
self.renderWorkingTreeDiff(node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// renderToMainWithTask renders the given task to the main view with the standard
|
||||
// diff title and subtitle.
|
||||
func (self *FilesController) renderToMainWithTask(task types.UpdateTask) {
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Title: self.c.Tr.DiffTitle,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus
|
||||
// the commits each side added relative to their common ancestor as two separate,
|
||||
// indented logs. If a side added nothing of its own (e.g. it was rewound to an
|
||||
// ancestor of the other), the commit it points at is shown instead.
|
||||
func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
path := node.GetPath()
|
||||
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sideBlock := func(header string, side string, otherSide string) string {
|
||||
log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide)
|
||||
if err != nil {
|
||||
return header
|
||||
}
|
||||
if log = strings.TrimRight(log, "\n"); log == "" {
|
||||
if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil {
|
||||
return header
|
||||
}
|
||||
}
|
||||
return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ")
|
||||
}
|
||||
|
||||
message := strings.Join([]string{
|
||||
self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})),
|
||||
sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs),
|
||||
sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours),
|
||||
}, "\n\n")
|
||||
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
||||
}
|
||||
|
||||
// renderInlineMergeConflict renders the merge-conflict view for a file with
|
||||
// inline conflict markers. It returns false if the file has no actual markers
|
||||
// (it was resolved in an editor), in which case the caller should fall back to
|
||||
// showing the file's diff.
|
||||
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !hasConflicts {
|
||||
return false
|
||||
}
|
||||
|
||||
self.c.Helpers().MergeConflicts.Render()
|
||||
return true
|
||||
}
|
||||
|
||||
// renderNonTextualConflict shows the resolution hint for a non-textual text-file
|
||||
// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases.
|
||||
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()})
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
} else {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
return
|
||||
}
|
||||
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
||||
}
|
||||
|
||||
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.UnstagedChanges
|
||||
}
|
||||
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
}
|
||||
|
||||
func (self *FilesController) GetOnDoubleClick() func() error {
|
||||
return self.withItemGraceful(func(node *filetree.FileNode) error {
|
||||
return self.press([]*filetree.FileNode{node})
|
||||
|
|
@ -583,6 +640,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
|
|||
}
|
||||
|
||||
func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
||||
// A single file with a conflict that can only be resolved through a dialog
|
||||
// can't be staged; route it to the same picker that `enter` uses instead.
|
||||
if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) {
|
||||
return self.openConflictResolutionMenu(nodes[0].File)
|
||||
}
|
||||
|
||||
if err := self.pressWithLock(nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -683,6 +746,10 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
|
||||
file := node.File
|
||||
|
||||
if self.conflictNeedsResolutionDialog(file) {
|
||||
return self.openConflictResolutionMenu(file)
|
||||
}
|
||||
|
||||
submoduleConfigs := self.c.Model().Submodules
|
||||
if file.IsSubmodule(submoduleConfigs) {
|
||||
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
||||
|
|
@ -692,9 +759,6 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
if file.HasInlineMergeConflicts {
|
||||
return self.switchToMerge()
|
||||
}
|
||||
if file.HasMergeConflicts {
|
||||
return self.handleNonInlineConflict(file)
|
||||
}
|
||||
|
||||
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
||||
self.c.Context().Push(context, opts)
|
||||
|
|
@ -703,7 +767,77 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) handleNonInlineConflict(file *models.File) error {
|
||||
// conflictResolutionHint formats a conflict description for the main view,
|
||||
// appending the "press <enter> to resolve" hint and wrapping it when the view is
|
||||
// wide enough that long lines would otherwise hurt readability.
|
||||
func (self *FilesController) conflictResolutionHint(description string) string {
|
||||
message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
|
||||
self.c.UserConfig().Keybinding.Universal.GoInto)
|
||||
if self.c.Views().Main.InnerWidth() > 70 {
|
||||
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
|
||||
message = strings.Join(lines, "\n")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// conflictNeedsResolutionDialog reports whether a file's merge conflict can only
|
||||
// be resolved through a dialog that picks one side, as opposed to editing
|
||||
// conflict markers in the merge view. These are the "non-textual" conflicts:
|
||||
// text files where one side modified and the other deleted/renamed the file
|
||||
// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU).
|
||||
func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool {
|
||||
if file == nil || !file.HasMergeConflicts {
|
||||
return false
|
||||
}
|
||||
|
||||
// A conflicted submodule has no conflict markers to edit; it's resolved by
|
||||
// picking which commit to point at.
|
||||
if file.IsSubmodule(self.c.Model().Submodules) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !file.HasInlineMergeConflicts
|
||||
}
|
||||
|
||||
// canStageSelection disables staging when a multiple selection includes a file
|
||||
// with a conflict that must be resolved through a dialog; those have to be
|
||||
// resolved one at a time.
|
||||
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
|
||||
if len(nodes) > 1 {
|
||||
for _, node := range nodes {
|
||||
if node.SomeFile(self.conflictNeedsResolutionDialog) {
|
||||
return &types.DisabledReason{
|
||||
Text: utils.ResolvePlaceholderString(
|
||||
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
|
||||
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSubmoduleCommitConflict reports whether the file is a submodule whose commit
|
||||
// pointer conflicts (status UU or AA): both sides recorded a different commit,
|
||||
// with no base content to merge. These are resolved by picking one side's
|
||||
// commit. Other submodule conflicts (e.g. modify/delete) are handled like
|
||||
// ordinary non-textual conflicts, with the keep/delete picker.
|
||||
func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool {
|
||||
return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules)
|
||||
}
|
||||
|
||||
func (self *FilesController) openConflictResolutionMenu(file *models.File) error {
|
||||
if self.isSubmoduleCommitConflict(file) {
|
||||
return self.openSubmoduleConflictMenu(file)
|
||||
}
|
||||
|
||||
return self.openFileConflictMenu(file)
|
||||
}
|
||||
|
||||
func (self *FilesController) openFileConflictMenu(file *models.File) error {
|
||||
handle := func(command func(command string) error, logText string) error {
|
||||
self.c.LogAction(logText)
|
||||
if err := command(file.GetPath()); err != nil {
|
||||
|
|
@ -750,6 +884,52 @@ func (self *FilesController) handleNonInlineConflict(file *models.File) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error {
|
||||
path := file.GetPath()
|
||||
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolve := func(sha string, logAction string) error {
|
||||
self.c.LogAction(logAction)
|
||||
if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := self.c.Git().WorkingTree.StageFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append the commit summary to the label so the user can tell the two
|
||||
// candidates apart, falling back to the bare label if we can't read it.
|
||||
label := func(text string, sha string) string {
|
||||
if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" {
|
||||
return fmt.Sprintf("%s (%s)", text, summary)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.MergeConflictsTitle,
|
||||
Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}),
|
||||
Items: []*types.MenuItem{
|
||||
{
|
||||
Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours),
|
||||
OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) },
|
||||
Keys: menuKey('c'),
|
||||
},
|
||||
{
|
||||
Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs),
|
||||
OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) },
|
||||
Keys: menuKey('i'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) toggleStagedAll() error {
|
||||
if err := self.toggleStagedAllWithLock(); err != nil {
|
||||
return err
|
||||
|
|
@ -1372,7 +1552,7 @@ func (self *FilesController) fetch() error {
|
|||
return errors.New(self.c.Tr.PassUnameWrong)
|
||||
}
|
||||
|
||||
return self.c.Helpers().BranchesHelper.PostFetchRefresh(err)
|
||||
return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
|
|||
Description: self.c.Tr.ToggleWhitespaceInDiffView,
|
||||
Tooltip: self.c.Tr.ToggleWhitespaceInDiffViewTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.EditConfig),
|
||||
Handler: self.editConfig,
|
||||
Description: self.c.Tr.EditConfig,
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +273,10 @@ func (self *GlobalController) toggleWhitespace() error {
|
|||
return (&ToggleWhitespaceAction{c: self.c}).Call()
|
||||
}
|
||||
|
||||
func (self *GlobalController) editConfig() error {
|
||||
return (&EditConfigAction{c: self.c}).Call()
|
||||
}
|
||||
|
||||
func (self *GlobalController) canShowRebaseOptions() *types.DisabledReason {
|
||||
if self.c.Model().WorkingTreeStateAtLastCommitRefresh.None() {
|
||||
return &types.DisabledReason{
|
||||
|
|
|
|||
|
|
@ -66,12 +66,22 @@ func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task
|
|||
}
|
||||
|
||||
func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error {
|
||||
// A waiting status means lazygit is driving a git operation itself (often
|
||||
// one that internally runs a rebase and continues it). Pause the background
|
||||
// routines for its duration so they don't refresh from an intermediate
|
||||
// state and reveal, say, the half-finished history of a reword.
|
||||
self.c.PauseBackgroundRefreshes(true)
|
||||
defer self.c.PauseBackgroundRefreshes(false)
|
||||
|
||||
return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error {
|
||||
return f(appStatusHelperTask{task, waitingStatusHandle})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error {
|
||||
self.c.PauseBackgroundRefreshes(true)
|
||||
defer self.c.PauseBackgroundRefreshes(false)
|
||||
|
||||
return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error {
|
||||
stop := make(chan struct{})
|
||||
defer func() { close(stop) }()
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error {
|
||||
func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) error {
|
||||
scope := []types.RefreshableView{
|
||||
types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS,
|
||||
}
|
||||
|
|
@ -293,7 +293,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error {
|
|||
if self.c.UserConfig().Git.AutoForwardBranches != "none" {
|
||||
scope = append(scope, types.WORKTREES)
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC})
|
||||
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background})
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,16 +100,6 @@ func (self *CherryPickHelper) Paste() error {
|
|||
return result
|
||||
}
|
||||
|
||||
// Move the selection down by the number of commits we just
|
||||
// cherry-picked, to keep the same commit selected as before.
|
||||
// Don't do this if a rebase todo is selected, because in this
|
||||
// case we are in a rebase and the cherry-picked commits end up
|
||||
// below the selection.
|
||||
if commit := self.c.Contexts().LocalCommits.GetSelected(); commit != nil && !commit.IsTODO() {
|
||||
self.c.Contexts().LocalCommits.MoveSelection(len(cherryPickedCommits))
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
}
|
||||
|
||||
// If we're in the cherry-picking state at this point, it must
|
||||
// be because there were conflicts. Don't clear the copied
|
||||
// commits in this case, since we might want to abort and try
|
||||
|
|
|
|||
|
|
@ -19,11 +19,45 @@ func NewGpgHelper(c *HelperCommon) *GpgHelper {
|
|||
}
|
||||
}
|
||||
|
||||
func (self *GpgHelper) WithGpgHandling(
|
||||
cmdObj *oscommands.CmdObj,
|
||||
configKey git_commands.GpgConfigKey,
|
||||
waitingStatus string,
|
||||
onSuccess func() error,
|
||||
refreshScope []types.RefreshableView,
|
||||
) error {
|
||||
refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}
|
||||
return self.withGpgHandling(
|
||||
cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions)
|
||||
}
|
||||
|
||||
// WithGpgHandlingAndSelectHeadCommit is like WithGpgHandling, but on success it
|
||||
// selects the new HEAD commit rather than restoring the previous selection. For
|
||||
// committing, where the commit we just created is the one we want selected.
|
||||
func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit(
|
||||
cmdObj *oscommands.CmdObj,
|
||||
configKey git_commands.GpgConfigKey,
|
||||
waitingStatus string,
|
||||
onSuccess func() error,
|
||||
) error {
|
||||
failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC}
|
||||
successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit}
|
||||
return self.withGpgHandling(
|
||||
cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
|
||||
}
|
||||
|
||||
// Currently there is a bug where if we switch to a subprocess from within
|
||||
// WithWaitingStatus we get stuck there and can't return to lazygit. We could
|
||||
// fix this bug, or just stop running subprocesses from within there, given that
|
||||
// we don't need to see a loading status if we're in a subprocess.
|
||||
func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
|
||||
func (self *GpgHelper) withGpgHandling(
|
||||
cmdObj *oscommands.CmdObj,
|
||||
configKey git_commands.GpgConfigKey,
|
||||
waitingStatus string,
|
||||
onSuccess func() error,
|
||||
failureRefreshOptions types.RefreshOptions,
|
||||
successRefreshOptions types.RefreshOptions,
|
||||
) error {
|
||||
useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey)
|
||||
if useSubprocess {
|
||||
success, err := self.c.RunSubprocess(cmdObj)
|
||||
|
|
@ -32,18 +66,29 @@ func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_
|
|||
return err
|
||||
}
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
|
||||
if success {
|
||||
self.c.Refresh(successRefreshOptions)
|
||||
} else {
|
||||
self.c.Refresh(failureRefreshOptions)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return self.runAndStream(cmdObj, waitingStatus, onSuccess, refreshScope)
|
||||
return self.runAndStream(
|
||||
cmdObj, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
|
||||
}
|
||||
|
||||
func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
|
||||
func (self *GpgHelper) runAndStream(
|
||||
cmdObj *oscommands.CmdObj,
|
||||
waitingStatus string,
|
||||
onSuccess func() error,
|
||||
failureRefreshOptions types.RefreshOptions,
|
||||
successRefreshOptions types.RefreshOptions,
|
||||
) error {
|
||||
return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error {
|
||||
if err := cmdObj.StreamOutput().Run(); err != nil {
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
|
||||
self.c.Refresh(failureRefreshOptions)
|
||||
return fmt.Errorf(
|
||||
self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu,
|
||||
)
|
||||
|
|
@ -55,7 +100,7 @@ func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus str
|
|||
}
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
|
||||
self.c.Refresh(successRefreshOptions)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g
|
|||
visible := view.Visible && self.windowHelper.TopViewInWindow(context.GetWindowName(), false) == view
|
||||
if visible && context.IsItemVisible(opts.Item) {
|
||||
self.c.OnWorker(func(task gocui.Task) error {
|
||||
// An inline status is just a waiting status rendered on the item
|
||||
// rather than in the bottom line, so it gets the same treatment:
|
||||
// pause the background routines while we drive the operation. (The
|
||||
// off-screen branch below goes through WithWaitingStatus, which
|
||||
// already does this.)
|
||||
self.c.PauseBackgroundRefreshes(true)
|
||||
defer self.c.PauseBackgroundRefreshes(false)
|
||||
|
||||
self.start(opts)
|
||||
defer self.stop(opts)
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
|||
}
|
||||
|
||||
commandType := status.CommandName()
|
||||
selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE &&
|
||||
effectiveStatus == models.WORKING_TREE_STATE_MERGING
|
||||
|
||||
// we should end up with a command like 'git merge --continue'
|
||||
|
||||
|
|
@ -106,15 +108,29 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
|||
|
||||
if needsSubprocess {
|
||||
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
|
||||
return self.c.RunSubprocessAndRefresh(
|
||||
self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command),
|
||||
)
|
||||
}
|
||||
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
|
||||
if err := self.CheckMergeOrRebase(result); err != nil {
|
||||
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command))
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
|
||||
return self.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
|
||||
})
|
||||
}
|
||||
|
||||
// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new
|
||||
// commit at HEAD to the corresponding commit-selection behavior: select that
|
||||
// new commit, or otherwise keep the previous selection by hash.
|
||||
func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehavior {
|
||||
if createdNewCommit {
|
||||
return types.SelectHeadCommit
|
||||
}
|
||||
return types.KeepCommitSelectionByHash
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
|
||||
|
|
@ -169,6 +185,15 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
|
|||
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC})
|
||||
}
|
||||
|
||||
// Like CheckMergeOrRebase, but for operations that create a new commit at HEAD
|
||||
// (a merge, or a pull that merges): on success it selects that new commit,
|
||||
// which the keep-selection-by-hash logic can't do since the commit didn't exist
|
||||
// before the refresh.
|
||||
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error {
|
||||
return self.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)})
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
|
||||
if result == nil {
|
||||
return nil
|
||||
|
|
@ -492,7 +517,7 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_comma
|
|||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.Merge)
|
||||
err := self.c.Git().Branch.Merge(refName, variant)
|
||||
return self.CheckMergeOrRebase(err)
|
||||
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
|
|
@ -19,6 +20,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
)
|
||||
|
||||
type RefreshHelper struct {
|
||||
|
|
@ -36,6 +38,12 @@ type RefreshHelper struct {
|
|||
// Keyed by repo path so that switching to a different repo while lazygit is running
|
||||
// still triggers the prompt there.
|
||||
githubBaseRemotePromptDismissed map[string]bool
|
||||
|
||||
// Last observed refs+HEAD fingerprint, used by the background poller to
|
||||
// decide whether a real refresh is needed. Written at the end of every
|
||||
// refresh that re-read refs/commits, read by the poller.
|
||||
refsSnapshotMutex deadlock.Mutex
|
||||
refsSnapshot string
|
||||
}
|
||||
|
||||
func NewRefreshHelper(
|
||||
|
|
@ -106,6 +114,30 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
|
|||
scopeSet = set.NewFromSlice(options.Scope)
|
||||
}
|
||||
|
||||
// Expand co-refreshing scopes up front so downstream conditions can be
|
||||
// simple single-scope checks. The relationships are:
|
||||
// - whenever the reflog or bisect info changes, commits and branches
|
||||
// can change too (e.g. switching branches updates the reflog and
|
||||
// can move HEAD), so refresh commits + branches alongside
|
||||
// - submodules are refreshed as part of the files refresh
|
||||
// - merge conflicts are part of what the files refresh produces
|
||||
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
|
||||
scopeSet.Add(types.COMMITS, types.BRANCHES)
|
||||
}
|
||||
if scopeSet.Includes(types.SUBMODULES) {
|
||||
scopeSet.Add(types.FILES)
|
||||
}
|
||||
if scopeSet.Includes(types.FILES) {
|
||||
scopeSet.Add(types.MERGE_CONFLICTS)
|
||||
}
|
||||
|
||||
// Capture the refs snapshot now, before we start reading git's state
|
||||
// below, rather than after. This is important to guard against the race
|
||||
// of git's state changing externally while (or right after) we are
|
||||
// refreshing; the risk is one potential extra refresh, but capturing the
|
||||
// snapshot at the end would risk missing one, which is worse.
|
||||
self.updateRefsSnapshotIfRelevant(scopeSet)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
refresh := func(name string, f func()) {
|
||||
// if we're in a demo we don't want any async refreshes because
|
||||
|
|
@ -129,11 +161,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
|
|||
|
||||
branchesAndRemotesWg := sync.WaitGroup{}
|
||||
includeWorktreesWithBranches := false
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
|
||||
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
|
||||
// whenever we change commits, we should update branches because the upstream/downstream
|
||||
// counts can change. Whenever we change branches we should also change commits
|
||||
// e.g. in the case of switching branches.
|
||||
refresh("commits and commit files", self.refreshCommitsAndCommitFiles)
|
||||
refresh("commits and commit files", func() {
|
||||
self.refreshCommitsAndCommitFiles(options.CommitSelection)
|
||||
})
|
||||
|
||||
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
|
||||
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
|
||||
|
|
@ -166,10 +200,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
|
|||
}
|
||||
|
||||
fileWg := sync.WaitGroup{}
|
||||
if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) {
|
||||
if scopeSet.Includes(types.FILES) {
|
||||
fileWg.Add(1)
|
||||
refresh("files", func() {
|
||||
_ = self.refreshFilesAndSubmodules()
|
||||
_ = self.refreshFilesAndSubmodules(options.Background)
|
||||
fileWg.Done()
|
||||
})
|
||||
}
|
||||
|
|
@ -212,7 +246,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
|
|||
refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) })
|
||||
}
|
||||
|
||||
if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) {
|
||||
if scopeSet.Includes(types.MERGE_CONFLICTS) {
|
||||
refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() })
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +270,57 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
|
|||
f()
|
||||
}
|
||||
|
||||
// SetRefsSnapshot stores the given snapshot as the last observed refs state.
|
||||
// Called externally by the background poller at startup to seed the snapshot,
|
||||
// and internally by Refresh at the end of a refs-touching refresh.
|
||||
func (self *RefreshHelper) SetRefsSnapshot(snapshot string) {
|
||||
self.refsSnapshotMutex.Lock()
|
||||
defer self.refsSnapshotMutex.Unlock()
|
||||
self.refsSnapshot = snapshot
|
||||
}
|
||||
|
||||
// RefsSnapshotChangedSince reports whether the given snapshot differs from
|
||||
// the last observed one. Pure read; does not update internal state.
|
||||
func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool {
|
||||
self.refsSnapshotMutex.Lock()
|
||||
defer self.refsSnapshotMutex.Unlock()
|
||||
|
||||
// An empty stored snapshot means no refresh has captured one yet, so we
|
||||
// have no baseline to compare against and report "unchanged" rather than
|
||||
// firing a spurious refresh. This can only be the unset zero value: a
|
||||
// snapshot we actually computed is never empty, because its HEAD component
|
||||
// is always non-empty (a branch ref when attached, a hash when detached —
|
||||
// even a repo with no commits yields "ref: refs/heads/main").
|
||||
if self.refsSnapshot == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return snapshot != self.refsSnapshot
|
||||
}
|
||||
|
||||
// updateRefsSnapshotIfRelevant captures a fresh refs snapshot from disk at the
|
||||
// start of a refresh that re-reads refs/commits (see the call site for why we
|
||||
// capture before reading the model rather than after). This keeps the
|
||||
// background poller's stored snapshot in sync with what's been observed by the
|
||||
// UI, so in-app commands and focus-in refreshes don't cause the next poll to
|
||||
// spuriously re-trigger.
|
||||
//
|
||||
// We check just COMMITS and BRANCHES because the scope-expansion step at the
|
||||
// top of Refresh has already added these whenever REFLOG or BISECT_INFO are
|
||||
// in scope, and whenever a nil scope was passed.
|
||||
func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) {
|
||||
if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := self.c.Git().Status.RefsSnapshot()
|
||||
if err != nil {
|
||||
self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err)
|
||||
return
|
||||
}
|
||||
self.SetRefsSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func getScopeNames(scopes []types.RefreshableView) []string {
|
||||
scopeNameMap := map[types.RefreshableView]string{
|
||||
types.COMMITS: "commits",
|
||||
|
|
@ -303,8 +388,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepB
|
|||
self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts)
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshCommitsAndCommitFiles() {
|
||||
_ = self.refreshCommitsWithLimit()
|
||||
func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) {
|
||||
_ = self.refreshCommitsWithLimit(commitSelection)
|
||||
ctx := self.c.Contexts().CommitFiles.GetParentContext()
|
||||
if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY {
|
||||
// This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position.
|
||||
|
|
@ -348,10 +433,16 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshCommitsWithLimit() error {
|
||||
func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error {
|
||||
self.c.Mutexes().LocalCommitsMutex.Lock()
|
||||
defer self.c.Mutexes().LocalCommitsMutex.Unlock()
|
||||
|
||||
var selectionRange *localCommitSelectionRange
|
||||
if commitSelection == types.KeepCommitSelectionByHash {
|
||||
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
|
||||
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
|
||||
}
|
||||
|
||||
checkedOutRef := self.determineCheckedOutRef()
|
||||
commits, err := self.c.Git().Loaders.CommitLoader.GetCommits(
|
||||
git_commands.GetCommitsOptions{
|
||||
|
|
@ -378,10 +469,110 @@ func (self *RefreshHelper) refreshCommitsWithLimit() error {
|
|||
self.c.Model().CheckedOutBranch = ""
|
||||
}
|
||||
|
||||
scrollSelectionIntoView := false
|
||||
switch commitSelection {
|
||||
case types.SelectHeadCommit:
|
||||
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
|
||||
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
|
||||
scrollSelectionIntoView = true
|
||||
}
|
||||
case types.KeepCommitSelectionByHash:
|
||||
if selectionRange != nil {
|
||||
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
|
||||
if found {
|
||||
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
|
||||
scrollSelectionIntoView = didMove
|
||||
}
|
||||
}
|
||||
case types.KeepCommitSelectionIndex:
|
||||
// The caller set the selection index deliberately; leave it untouched.
|
||||
}
|
||||
|
||||
self.refreshView(self.c.Contexts().LocalCommits)
|
||||
if scrollSelectionIntoView {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().LocalCommits.FocusLine(true)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type localCommitSelectionRange struct {
|
||||
selectedHash string
|
||||
selectedIsTODO bool
|
||||
rangeStartHash string
|
||||
rangeStartIsTODO bool
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
mode traits.RangeSelectMode
|
||||
}
|
||||
|
||||
func captureLocalCommitSelectionRange(
|
||||
commits []*models.Commit,
|
||||
selectedIdx int,
|
||||
rangeStartIdx int,
|
||||
mode traits.RangeSelectMode,
|
||||
) *localCommitSelectionRange {
|
||||
if !hasRestorableCommitHash(commits, selectedIdx) || !hasRestorableCommitHash(commits, rangeStartIdx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &localCommitSelectionRange{
|
||||
selectedHash: commits[selectedIdx].Hash(),
|
||||
selectedIsTODO: commits[selectedIdx].IsTODO(),
|
||||
rangeStartHash: commits[rangeStartIdx].Hash(),
|
||||
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
func findLocalCommitSelectionRange(
|
||||
commits []*models.Commit,
|
||||
selectionRange *localCommitSelectionRange,
|
||||
) (int, int, bool, bool) {
|
||||
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
|
||||
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
|
||||
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
|
||||
if !foundSelected || !foundRangeStart {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
|
||||
return selectedIdx, rangeStartIdx, didMove, true
|
||||
}
|
||||
|
||||
// findCommitByHashPreferringTODOStatus finds the commit with the given hash.
|
||||
// When both a TODO and a non-TODO commit share that hash - which happens while
|
||||
// reverting or cherry-picking, where the rebase TODO entry has the same hash as
|
||||
// the real commit - it returns the one whose TODO status matches isTODO. When
|
||||
// only one commit has the hash, it is returned regardless of its TODO status,
|
||||
// so that a selected commit which turned into a TODO entry across the refresh is
|
||||
// still found (e.g. when starting an interactive rebase that stops to edit it).
|
||||
func findCommitByHashPreferringTODOStatus(commits []*models.Commit, hash string, isTODO bool) (int, bool) {
|
||||
fallbackIdx := -1
|
||||
for idx, commit := range commits {
|
||||
if commit.Hash() != hash {
|
||||
continue
|
||||
}
|
||||
if commit.IsTODO() == isTODO {
|
||||
return idx, true
|
||||
}
|
||||
if fallbackIdx == -1 {
|
||||
fallbackIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackIdx, fallbackIdx != -1
|
||||
}
|
||||
|
||||
func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
|
||||
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshSubCommitsWithLimit() error {
|
||||
if self.c.Contexts().SubCommits.GetRef() == nil {
|
||||
return nil
|
||||
|
|
@ -542,7 +733,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
|
|||
self.refreshStatus()
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshFilesAndSubmodules() error {
|
||||
func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error {
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
self.c.State().SetIsRefreshingFiles(true)
|
||||
defer func() {
|
||||
|
|
@ -554,7 +745,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := self.refreshStateFiles(); err != nil {
|
||||
if err := self.refreshStateFiles(background); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -567,7 +758,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshStateFiles() error {
|
||||
func (self *RefreshHelper) refreshStateFiles(background bool) error {
|
||||
fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel
|
||||
|
||||
prevConflictFileCount := 0
|
||||
|
|
@ -605,6 +796,7 @@ func (self *RefreshHelper) refreshStateFiles() error {
|
|||
files := self.c.Git().Loaders.FileLoader.
|
||||
GetStatusFiles(git_commands.GetStatusFileOptions{
|
||||
ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(),
|
||||
Background: background,
|
||||
})
|
||||
|
||||
conflictFileCount := 0
|
||||
|
|
|
|||
|
|
@ -5,10 +5,161 @@ import (
|
|||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCaptureLocalCommitSelectionRange(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
commits []*models.Commit
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
expected *localCommitSelectionRange
|
||||
}{
|
||||
{
|
||||
name: "captures selected commit and range start",
|
||||
commits: makeCommits("a", "b"),
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 0,
|
||||
expected: &localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "a",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 0,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores invalid range start index",
|
||||
commits: makeCommits("a"),
|
||||
selectedIdx: 0,
|
||||
rangeStartIdx: 1,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "ignores empty selected hash",
|
||||
commits: append(makeCommits("a"), makeTodoCommit(todo.UpdateRef)),
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 0,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "ignores empty range start hash",
|
||||
commits: append(makeCommits("a"), makeTodoCommit(todo.Exec)),
|
||||
selectedIdx: 0,
|
||||
rangeStartIdx: 1,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
selectionRange := captureLocalCommitSelectionRange(
|
||||
testCase.commits,
|
||||
testCase.selectedIdx,
|
||||
testCase.rangeStartIdx,
|
||||
traits.RangeSelectModeSticky,
|
||||
)
|
||||
|
||||
assert.Equal(t, testCase.expected, selectionRange)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLocalCommitSelectionRange(t *testing.T) {
|
||||
type expectation struct {
|
||||
selectedIdx int
|
||||
rangeStartIdx int
|
||||
moved bool
|
||||
found bool
|
||||
}
|
||||
|
||||
selectionRange := localCommitSelectionRange{
|
||||
selectedHash: "b",
|
||||
rangeStartHash: "c",
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 2,
|
||||
mode: traits.RangeSelectModeSticky,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
commits []*models.Commit
|
||||
expected expectation
|
||||
}{
|
||||
{
|
||||
name: "finds selection after commits are inserted above it",
|
||||
commits: makeCommits("new", "a", "b", "c"),
|
||||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "finds selection that did not move",
|
||||
commits: makeCommits("a", "b", "c"),
|
||||
expected: expectation{
|
||||
selectedIdx: 1,
|
||||
rangeStartIdx: 2,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reports not found when a hash is missing",
|
||||
commits: makeCommits("a", "b"),
|
||||
expected: expectation{},
|
||||
},
|
||||
{
|
||||
name: "skips todo entries with the same hash as a selected commit",
|
||||
commits: []*models.Commit{
|
||||
makeTodoCommitWithHash("b", todo.Revert),
|
||||
makeCommits("a")[0],
|
||||
makeCommits("b")[0],
|
||||
makeCommits("c")[0],
|
||||
},
|
||||
expected: expectation{
|
||||
selectedIdx: 2,
|
||||
rangeStartIdx: 3,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "falls back to a todo entry when the selected commit became one",
|
||||
commits: []*models.Commit{
|
||||
makeTodoCommitWithHash("b", todo.Pick),
|
||||
makeCommits("c")[0],
|
||||
},
|
||||
expected: expectation{
|
||||
selectedIdx: 0,
|
||||
rangeStartIdx: 1,
|
||||
moved: true,
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
|
||||
actual := expectation{
|
||||
selectedIdx: selectedIdx,
|
||||
rangeStartIdx: rangeStartIdx,
|
||||
moved: moved,
|
||||
found: found,
|
||||
}
|
||||
|
||||
assert.Equal(t, testCase.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGithubBaseRemote(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
@ -122,3 +273,18 @@ func makeAuthenticatedGithubRemoteInfo(name string, webDomain string, authToken
|
|||
info.authToken = authToken
|
||||
return info
|
||||
}
|
||||
|
||||
func makeCommits(hashes ...string) []*models.Commit {
|
||||
hashPool := &utils.StringPool{}
|
||||
return lo.Map(hashes, func(hash string, _ int) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash})
|
||||
})
|
||||
}
|
||||
|
||||
func makeTodoCommit(action todo.TodoCommand) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Action: action})
|
||||
}
|
||||
|
||||
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
|
||||
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,12 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
|
|||
if options.RefreshPullRequests {
|
||||
scope = append(scope, types.PULL_REQUESTS)
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, Scope: scope, KeepBranchSelectionIndex: true})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
Scope: scope,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
}
|
||||
|
||||
localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
|
||||
|
|
@ -209,7 +214,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
|
|||
// loading a heap of commits is slow so we limit them whenever doing a reset
|
||||
self.c.Contexts().LocalCommits.SetLimitCommits(true)
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}})
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -370,7 +375,11 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
|
|||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
}
|
||||
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
|
|
@ -525,7 +534,11 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa
|
|||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -563,7 +576,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
|
|||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ type WindowArrangementArgs struct {
|
|||
// Name of the current side window (i.e. the current window in the left
|
||||
// section of the UI)
|
||||
CurrentSideWindow string
|
||||
// Returns the view currently shown in the given window. When a window holds
|
||||
// several tabbed views this is the selected tab, which is what the status and
|
||||
// stash height special-cases key off (rather than the window itself, whose
|
||||
// name is just its first tab).
|
||||
ActiveViewForWindow func(window string) string
|
||||
// Whether the main panel is split (as is the case e.g. when a file has both
|
||||
// staged and unstaged changes)
|
||||
SplitMainPanel bool
|
||||
|
|
@ -86,20 +91,21 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
|
|||
}
|
||||
|
||||
args := WindowArrangementArgs{
|
||||
Width: width,
|
||||
Height: height,
|
||||
UserConfig: self.c.UserConfig(),
|
||||
CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(),
|
||||
CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(),
|
||||
SplitMainPanel: repoState.GetSplitMainPanel(),
|
||||
ScreenMode: repoState.GetScreenMode(),
|
||||
AppStatus: appStatus,
|
||||
InformationStr: informationStr,
|
||||
ShowExtrasWindow: self.c.State().GetShowExtrasWindow(),
|
||||
InDemo: self.c.InDemo(),
|
||||
IsAnyModeActive: self.modeHelper.IsAnyModeActive(),
|
||||
InSearchPrompt: repoState.InSearchPrompt(),
|
||||
SearchPrefix: searchPrefix,
|
||||
Width: width,
|
||||
Height: height,
|
||||
UserConfig: self.c.UserConfig(),
|
||||
CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(),
|
||||
CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(),
|
||||
ActiveViewForWindow: self.windowHelper.GetViewNameForWindow,
|
||||
SplitMainPanel: repoState.GetSplitMainPanel(),
|
||||
ScreenMode: repoState.GetScreenMode(),
|
||||
AppStatus: appStatus,
|
||||
InformationStr: informationStr,
|
||||
ShowExtrasWindow: self.c.State().GetShowExtrasWindow(),
|
||||
InDemo: self.c.InDemo(),
|
||||
IsAnyModeActive: self.modeHelper.IsAnyModeActive(),
|
||||
InSearchPrompt: repoState.InSearchPrompt(),
|
||||
SearchPrefix: searchPrefix,
|
||||
}
|
||||
|
||||
return GetWindowDimensions(args)
|
||||
|
|
@ -403,14 +409,15 @@ func getExtrasWindowSize(args WindowArrangementArgs) int {
|
|||
return baseSize + frameSize
|
||||
}
|
||||
|
||||
// The stash window by default only contains one line so that it's not hogging
|
||||
// The stash view by default only contains one line so that it's not hogging
|
||||
// too much space, but if you access it it should take up some space. This is
|
||||
// the default behaviour when accordion mode is NOT in effect. If it is in effect
|
||||
// then when it's accessed it will have weight 2, not 1.
|
||||
func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
|
||||
box := &boxlayout.Box{Window: "stash"}
|
||||
// if the stash window is anywhere in our stack we should enlargen it
|
||||
if args.CurrentSideWindow == "stash" {
|
||||
// then when it's accessed it will have weight 2, not 1. The window is passed in
|
||||
// because stash may be a tab of a window named after a different first tab.
|
||||
func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlayout.Box {
|
||||
box := &boxlayout.Box{Window: window}
|
||||
// if the window showing stash is focused we should enlargen it
|
||||
if args.CurrentSideWindow == window {
|
||||
box.Weight = 1
|
||||
} else {
|
||||
box.Size = 3
|
||||
|
|
@ -421,6 +428,16 @@ func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
|
|||
|
||||
func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box {
|
||||
return func(width int, height int) []*boxlayout.Box {
|
||||
windows := sideWindowNames(args.UserConfig)
|
||||
|
||||
boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box {
|
||||
boxes := make([]*boxlayout.Box, 0, len(windows))
|
||||
for _, window := range windows {
|
||||
boxes = append(boxes, boxForWindow(window))
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
|
||||
if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF {
|
||||
fullHeightBox := func(window string) *boxlayout.Box {
|
||||
if window == args.CurrentSideWindow {
|
||||
|
|
@ -436,13 +453,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
|
|||
}
|
||||
}
|
||||
|
||||
return []*boxlayout.Box{
|
||||
fullHeightBox("status"),
|
||||
fullHeightBox("files"),
|
||||
fullHeightBox("branches"),
|
||||
fullHeightBox("commits"),
|
||||
fullHeightBox("stash"),
|
||||
}
|
||||
return boxForEachWindow(fullHeightBox)
|
||||
} else if height >= 28 {
|
||||
accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel
|
||||
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
|
||||
|
|
@ -456,16 +467,23 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
|
|||
return defaultBox
|
||||
}
|
||||
|
||||
return []*boxlayout.Box{
|
||||
{
|
||||
Window: "status",
|
||||
Size: 3,
|
||||
},
|
||||
accordionBox(&boxlayout.Box{Window: "files", Weight: 1}),
|
||||
accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}),
|
||||
accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}),
|
||||
accordionBox(getDefaultStashWindowBox(args)),
|
||||
normalBox := func(window string) *boxlayout.Box {
|
||||
// The status and stash sizing is a property of those views, so we key
|
||||
// off the tab the window is currently showing, not the window's name
|
||||
// (its first tab): otherwise grouping other tabs behind status or
|
||||
// stash would wrongly impose their compact height on those tabs.
|
||||
switch args.ActiveViewForWindow(window) {
|
||||
case "status":
|
||||
// The status view has a fixed height and is not expanded by accordion mode.
|
||||
return &boxlayout.Box{Window: window, Size: 3}
|
||||
case "stash":
|
||||
return accordionBox(getDefaultStashWindowBox(args, window))
|
||||
default:
|
||||
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
|
||||
}
|
||||
}
|
||||
|
||||
return boxForEachWindow(normalBox)
|
||||
}
|
||||
|
||||
squashedHeight := 1
|
||||
|
|
@ -487,12 +505,6 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
|
|||
}
|
||||
}
|
||||
|
||||
return []*boxlayout.Box{
|
||||
squashedSidePanelBox("status"),
|
||||
squashedSidePanelBox("files"),
|
||||
squashedSidePanelBox("branches"),
|
||||
squashedSidePanelBox("commits"),
|
||||
squashedSidePanelBox("stash"),
|
||||
}
|
||||
return boxForEachWindow(squashedSidePanelBox)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,18 @@ func TestGetWindowDimensions(t *testing.T) {
|
|||
UserConfig: config.GetDefaultConfig(),
|
||||
CurrentWindow: "files",
|
||||
CurrentSideWindow: "files",
|
||||
SplitMainPanel: false,
|
||||
ScreenMode: types.SCREEN_NORMAL,
|
||||
AppStatus: "",
|
||||
InformationStr: "information",
|
||||
ShowExtrasWindow: false,
|
||||
InDemo: false,
|
||||
IsAnyModeActive: false,
|
||||
InSearchPrompt: false,
|
||||
SearchPrefix: "",
|
||||
// Each panel shows its first tab by default; for the special-cased
|
||||
// panels (status, stash) the view name matches the window name.
|
||||
ActiveViewForWindow: func(window string) string { return window },
|
||||
SplitMainPanel: false,
|
||||
ScreenMode: types.SCREEN_NORMAL,
|
||||
AppStatus: "",
|
||||
InformationStr: "information",
|
||||
ShowExtrasWindow: false,
|
||||
InDemo: false,
|
||||
IsAnyModeActive: false,
|
||||
InSearchPrompt: false,
|
||||
SearchPrefix: "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +124,152 @@ func TestGetWindowDimensions(t *testing.T) {
|
|||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "worktrees promoted to its own side panel",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "submodules"},
|
||||
{"worktrees"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭worktrees──────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭branches───────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭stash──────────────────╮│ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "stash side panel hidden",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭branches───────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "stash leading a grouped panel doesn't squash its other tabs",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"stash", "branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
}
|
||||
// The third panel is named after its first tab, stash, but is
|
||||
// currently showing the branches tab, which must get full height
|
||||
// rather than stash's compact height.
|
||||
args.ActiveViewForWindow = func(window string) string {
|
||||
if window == "stash" {
|
||||
return "branches"
|
||||
}
|
||||
return window
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭stash──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "expandFocusedSidePanel",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package helpers
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
|
|
@ -135,5 +136,13 @@ func (self *WindowHelper) WindowForView(viewName string) string {
|
|||
}
|
||||
|
||||
func (self *WindowHelper) SideWindows() []string {
|
||||
return []string{"status", "files", "branches", "commits", "stash"}
|
||||
return sideWindowNames(self.c.UserConfig())
|
||||
}
|
||||
|
||||
// sideWindowNames returns the side panel window names in order, derived from the
|
||||
// gui.sidePanels config. A panel's window name is the name of its first tab.
|
||||
func sideWindowNames(userConfig *config.UserConfig) []string {
|
||||
return lo.Map(userConfig.Gui.SidePanels, func(panel config.SidePanel, _ int) string {
|
||||
return panel[0]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,11 @@ func (self *WorkingTreeHelper) HandleCommitPressWithMessage(initialMessage strin
|
|||
func (self *WorkingTreeHelper) handleCommit(summary string, description string, forceSkipHooks bool) error {
|
||||
cmdObj := self.c.Git().Commit.CommitCmdObj(summary, description, forceSkipHooks)
|
||||
self.c.LogAction(self.c.Tr.Actions.Commit)
|
||||
return self.gpgHelper.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
|
||||
return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
|
||||
func() error {
|
||||
self.commitsHelper.ClearPreservedCommitMessage()
|
||||
return nil
|
||||
}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath string, forceSkipHooks bool) error {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type JumpToSideWindowController struct {
|
||||
|
|
@ -30,19 +27,23 @@ func (self *JumpToSideWindowController) Context() types.Context {
|
|||
|
||||
func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
windows := self.c.Helpers().Window.SideWindows()
|
||||
jumpKeys := opts.Config.Universal.JumpToBlock
|
||||
|
||||
if len(opts.Config.Universal.JumpToBlock) != len(windows) {
|
||||
log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.")
|
||||
}
|
||||
|
||||
return lo.Map(windows, func(window string, index int) *types.Binding {
|
||||
return &types.Binding{
|
||||
// Assign jump keys to panels positionally (by default 1 to the first panel,
|
||||
// 2 to the second, etc.), for as many panels as there are keys. If there are
|
||||
// more panels than keys the extra panels just have no jump key, and if there
|
||||
// are more keys than panels the extra keys are unused; either way panels stay
|
||||
// reachable via the next/previous-panel keys.
|
||||
count := min(len(windows), len(jumpKeys))
|
||||
bindings := make([]*types.Binding, 0, count)
|
||||
for i := range count {
|
||||
bindings = append(bindings, &types.Binding{
|
||||
ViewName: "",
|
||||
// by default the keys are 1, 2, 3, etc
|
||||
Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]),
|
||||
Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)),
|
||||
}
|
||||
})
|
||||
Keys: opts.GetKeys(jumpKeys[i]),
|
||||
Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(windows[i])),
|
||||
})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (self *JumpToSideWindowController) goToSideWindow(window string) func() error {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
|
|
@ -590,15 +589,9 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
|
|||
|
||||
commits := self.c.Model().Commits
|
||||
if !commits[endIdx].IsMerge() {
|
||||
selectionRangeAndMode := self.getSelectionRangeAndMode()
|
||||
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err,
|
||||
types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI, Then: func() {
|
||||
self.restoreSelectionRangeAndMode(selectionRangeAndMode)
|
||||
},
|
||||
})
|
||||
err, types.RefreshOptions{Mode: types.BLOCK_UI})
|
||||
}
|
||||
|
||||
return self.startInteractiveRebaseWithEdit(selectedCommits)
|
||||
|
|
@ -618,7 +611,6 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
|
|||
) error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.EditCommit)
|
||||
selectionRangeAndMode := self.getSelectionRangeAndMode()
|
||||
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err,
|
||||
|
|
@ -636,42 +628,10 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
|
|||
self.c.Log.Errorf("error when updating todos: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
self.restoreSelectionRangeAndMode(selectionRangeAndMode)
|
||||
}})
|
||||
})
|
||||
}
|
||||
|
||||
type SelectionRangeAndMode struct {
|
||||
selectedHash string
|
||||
rangeStartHash string
|
||||
mode traits.RangeSelectMode
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) getSelectionRangeAndMode() SelectionRangeAndMode {
|
||||
selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode()
|
||||
commits := self.c.Model().Commits
|
||||
selectedHash := commits[selectedIdx].Hash()
|
||||
rangeStartHash := commits[rangeStartIdx].Hash()
|
||||
return SelectionRangeAndMode{selectedHash, rangeStartHash, rangeSelectMode}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) restoreSelectionRangeAndMode(selectionRangeAndMode SelectionRangeAndMode) {
|
||||
// We need to select the same commit range again because after starting a rebase,
|
||||
// new lines can be added for update-ref commands in the TODO file, due to
|
||||
// stacked branches. So the selected commits may be in different positions in the list.
|
||||
_, newSelectedIdx, ok1 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
|
||||
return c.Hash() == selectionRangeAndMode.selectedHash
|
||||
})
|
||||
_, newRangeStartIdx, ok2 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
|
||||
return c.Hash() == selectionRangeAndMode.rangeStartHash
|
||||
})
|
||||
if ok1 && ok2 {
|
||||
self.context().SetSelectionRangeAndMode(newSelectedIdx, newRangeStartIdx, selectionRangeAndMode.mode)
|
||||
self.context().HandleFocus(types.OnFocusOpts{})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) {
|
||||
commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
|
||||
return c.IsMerge() || c.Status == models.StatusMerged
|
||||
|
|
@ -767,7 +727,9 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
|
|||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
Mode: types.SYNC,
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -780,7 +742,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
|
|||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
}
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.SYNC})
|
||||
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -793,7 +755,9 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
|
|||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
Mode: types.SYNC,
|
||||
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -806,7 +770,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
|
|||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
}
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.SYNC})
|
||||
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -966,8 +930,6 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
|
|||
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil {
|
||||
return err
|
||||
}
|
||||
self.context().MoveSelection(len(commits))
|
||||
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
|
||||
|
||||
if mustStash {
|
||||
if err := self.c.Git().Stash.Pop(0); err != nil {
|
||||
|
|
@ -1013,7 +975,6 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
|
|||
return err
|
||||
}
|
||||
|
||||
self.context().MoveSelectedLine(1)
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
|
||||
return nil
|
||||
})
|
||||
|
|
@ -1114,7 +1075,6 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
|
|||
return err
|
||||
}
|
||||
|
||||
self.context().MoveSelectedLine(1)
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
|
||||
return nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ func (self *RemotesController) enter(remote *models.Remote) error {
|
|||
remoteBranchesContext.SetSelection(newSelectedLine)
|
||||
remoteBranchesContext.SetTitleRef(remote.Name)
|
||||
remoteBranchesContext.SetParentContext(self.Context())
|
||||
remoteBranchesContext.SetWindowName(self.Context().GetWindowName())
|
||||
remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix
|
||||
|
||||
self.c.PostRefreshUpdate(remoteBranchesContext)
|
||||
|
|
@ -374,6 +375,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam
|
|||
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
|
||||
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
|
||||
refreshOptions.KeepBranchSelectionIndex = true
|
||||
refreshOptions.CommitSelection = types.KeepCommitSelectionIndex
|
||||
}
|
||||
}
|
||||
self.c.Refresh(refreshOptions)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -12,7 +11,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type StatusController struct {
|
||||
|
|
@ -33,12 +31,6 @@ func NewStatusController(
|
|||
|
||||
func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
|
||||
Handler: self.openConfig,
|
||||
Description: self.c.Tr.OpenConfig,
|
||||
Tooltip: self.c.Tr.OpenFileTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
||||
Handler: self.editConfig,
|
||||
|
|
@ -148,38 +140,8 @@ func lazygitTitle() string {
|
|||
|___/ |___/ `
|
||||
}
|
||||
|
||||
func (self *StatusController) askForConfigFile(action func(file string) error) error {
|
||||
confPaths := self.c.GetConfig().GetUserConfigPaths()
|
||||
switch len(confPaths) {
|
||||
case 0:
|
||||
return errors.New(self.c.Tr.NoConfigFileFoundErr)
|
||||
case 1:
|
||||
return action(confPaths[0])
|
||||
default:
|
||||
menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem {
|
||||
return &types.MenuItem{
|
||||
Label: path,
|
||||
OnPress: func() error {
|
||||
return action(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.SelectConfigFile,
|
||||
Items: menuItems,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *StatusController) openConfig() error {
|
||||
return self.askForConfigFile(self.c.Helpers().Files.OpenFile)
|
||||
}
|
||||
|
||||
func (self *StatusController) editConfig() error {
|
||||
return self.askForConfigFile(func(file string) error {
|
||||
return self.c.Helpers().Files.EditFiles([]string{file})
|
||||
})
|
||||
return (&EditConfigAction{c: self.c}).Call()
|
||||
}
|
||||
|
||||
func (self *StatusController) showAllBranchLogs() {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions)
|
|||
},
|
||||
)
|
||||
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseAndSelectHeadCommit(err)
|
||||
}
|
||||
|
||||
type pushOpts struct {
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
|
|||
if didChange && reloadErr == nil {
|
||||
gui.c.Log.Info("User config changed - reloading")
|
||||
reloadErr = gui.onUserConfigLoaded()
|
||||
gui.reloadSidePanels()
|
||||
if err := gui.resetKeybindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -515,8 +516,10 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC
|
|||
configsThatDontAutoReload := []string{
|
||||
"Git.AutoFetch",
|
||||
"Git.AutoRefresh",
|
||||
"Git.AutoDetectExternalChanges",
|
||||
"Refresher.RefreshInterval",
|
||||
"Refresher.FetchInterval",
|
||||
"Refresher.ExternalChangeCheckInterval",
|
||||
"Update.Method",
|
||||
"Update.Days",
|
||||
}
|
||||
|
|
@ -579,8 +582,9 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
|||
gui.State = state
|
||||
gui.State.ViewsSetup = false
|
||||
|
||||
contextTree := gui.State.Contexts
|
||||
gui.State.WindowViewNameMap = initialWindowViewNameMap(contextTree)
|
||||
// The repo we're switching to may have a per-repo config with a different
|
||||
// side panel layout, so re-apply it to this repo's contexts.
|
||||
gui.applySidePanelConfig()
|
||||
|
||||
// setting this to nil so we don't get stuck based on a popup that was
|
||||
// previously opened
|
||||
|
|
@ -620,14 +624,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
|||
},
|
||||
ScreenMode: initialScreenMode,
|
||||
// TODO: only use contexts from context manager
|
||||
ContextMgr: NewContextMgr(gui, contextTree),
|
||||
Contexts: contextTree,
|
||||
WindowViewNameMap: initialWindowViewNameMap(contextTree),
|
||||
SearchState: types.NewSearchState(),
|
||||
ContextMgr: NewContextMgr(gui, contextTree),
|
||||
Contexts: contextTree,
|
||||
SearchState: types.NewSearchState(),
|
||||
}
|
||||
|
||||
gui.RepoStateMap[Repo(worktreePath)] = gui.State
|
||||
|
||||
gui.applySidePanelConfig()
|
||||
|
||||
return initialContext(contextTree, startArgs)
|
||||
}
|
||||
|
||||
|
|
@ -658,13 +663,19 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
|
|||
return manager
|
||||
}
|
||||
|
||||
func initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
|
||||
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
|
||||
result := utils.NewThreadSafeMap[string, string]()
|
||||
|
||||
for _, context := range contextTree.Flatten() {
|
||||
result.Set(context.GetWindowName(), context.GetViewName())
|
||||
}
|
||||
|
||||
// A side panel's window shows its first configured tab by default, which is
|
||||
// not necessarily the context that won the loop above.
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
result.Set(panel[0], sidePanelViewNames[panel[0]])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -834,45 +845,19 @@ func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest)
|
|||
}
|
||||
|
||||
func (gui *Gui) viewTabMap() map[string][]context.TabView {
|
||||
result := map[string][]context.TabView{
|
||||
"branches": {
|
||||
{
|
||||
Tab: gui.c.Tr.LocalBranchesTitle,
|
||||
ViewName: "localBranches",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.RemotesTitle,
|
||||
ViewName: "remotes",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.TagsTitle,
|
||||
ViewName: "tags",
|
||||
},
|
||||
},
|
||||
"commits": {
|
||||
{
|
||||
Tab: gui.c.Tr.CommitsTitle,
|
||||
ViewName: "commits",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.ReflogCommitsTitle,
|
||||
ViewName: "reflogCommits",
|
||||
},
|
||||
},
|
||||
"files": {
|
||||
{
|
||||
Tab: gui.c.Tr.FilesTitle,
|
||||
ViewName: "files",
|
||||
},
|
||||
context.TabView{
|
||||
Tab: gui.c.Tr.WorktreesTitle,
|
||||
ViewName: "worktrees",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.SubmodulesTitle,
|
||||
ViewName: "submodules",
|
||||
},
|
||||
},
|
||||
titles := gui.sidePanelTabTitles()
|
||||
result := map[string][]context.TabView{}
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
if len(panel) < 2 {
|
||||
// A single-tab panel shows its view's own title, not a tab strip.
|
||||
continue
|
||||
}
|
||||
result[panel[0]] = lo.Map(panel, func(name string, _ int) context.TabView {
|
||||
return context.TabView{
|
||||
Tab: titles[name],
|
||||
ViewName: sidePanelViewNames[name],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ func (self *guiCommon) Resume() error {
|
|||
return self.gui.resume()
|
||||
}
|
||||
|
||||
func (self *guiCommon) PauseBackgroundRefreshes(pause bool) {
|
||||
self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause)
|
||||
}
|
||||
|
||||
func (self *guiCommon) Context() types.IContextMgr {
|
||||
return self.gui.State.ContextMgr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,18 @@ func (self *GuiDriver) Click(x, y int) {
|
|||
self.waitTillIdle()
|
||||
}
|
||||
|
||||
// FocusIn simulates the terminal window regaining focus, which is how lazygit
|
||||
// learns to reload changed config files. Tests use it to exercise the live
|
||||
// config-reload path.
|
||||
func (self *GuiDriver) FocusIn() {
|
||||
self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper(
|
||||
tcell.NewEventFocus(true),
|
||||
0,
|
||||
)
|
||||
|
||||
self.waitTillIdle()
|
||||
}
|
||||
|
||||
// wait until lazygit is idle (i.e. all processing is done) before continuing
|
||||
func (self *GuiDriver) waitTillIdle() {
|
||||
<-self.isIdleChan
|
||||
|
|
@ -141,6 +153,12 @@ func (self *GuiDriver) View(viewName string) *gocui.View {
|
|||
return view
|
||||
}
|
||||
|
||||
// TopViewInWindow returns the frontmost visible view in the given window, i.e.
|
||||
// the tab that is currently shown when a window holds several tabbed views.
|
||||
func (self *GuiDriver) TopViewInWindow(windowName string) *gocui.View {
|
||||
return self.gui.helpers.Window.TopViewInWindow(windowName, false)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) SetCaption(caption string) {
|
||||
self.gui.setCaption(caption)
|
||||
self.waitTillIdle()
|
||||
|
|
|
|||
|
|
@ -133,7 +133,12 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
|||
}
|
||||
}
|
||||
|
||||
minimumHeight := 9
|
||||
// When the screen is too short the side panels are squashed, with the
|
||||
// unfocused ones taking one row each and the focused one taking the rest. The
|
||||
// more panels there are, the more rows the unfocused ones reserve, so the
|
||||
// floor below which there's no room left for the focused panel grows with the
|
||||
// panel count. Keep the historical floor of 9 for the default five panels.
|
||||
minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4)
|
||||
minimumWidth := 10
|
||||
gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
|
||||
|
||||
|
|
@ -249,6 +254,10 @@ func (gui *Gui) onRepoViewReset() error {
|
|||
}
|
||||
}
|
||||
|
||||
// The loop above orders views by a fixed list, which doesn't necessarily put
|
||||
// each panel's first configured tab on top.
|
||||
gui.moveDefaultTabsToTop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
128
pkg/gui/side_panels.go
Normal file
128
pkg/gui/side_panels.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls.
|
||||
// A panel's window name is the name of its first tab, so for a panel's first tab
|
||||
// this also gives the default view of its window. The keys must match
|
||||
// config.ValidSidePanelTabs (enforced by a test).
|
||||
var sidePanelViewNames = map[string]string{
|
||||
"status": "status",
|
||||
"files": "files",
|
||||
"worktrees": "worktrees",
|
||||
"submodules": "submodules",
|
||||
"branches": "localBranches",
|
||||
"remotes": "remotes",
|
||||
"tags": "tags",
|
||||
"commits": "commits",
|
||||
"reflog": "reflogCommits",
|
||||
"stash": "stash",
|
||||
}
|
||||
|
||||
// sidePanelTabTitles maps each gui.sidePanels name to the title shown on its tab.
|
||||
func (gui *Gui) sidePanelTabTitles() map[string]string {
|
||||
tr := gui.c.Tr
|
||||
return map[string]string{
|
||||
"status": tr.StatusTitle,
|
||||
"files": tr.FilesTitle,
|
||||
"worktrees": tr.WorktreesTitle,
|
||||
"submodules": tr.SubmodulesTitle,
|
||||
"branches": tr.LocalBranchesTitle,
|
||||
"remotes": tr.RemotesTitle,
|
||||
"tags": tr.TagsTitle,
|
||||
"commits": tr.CommitsTitle,
|
||||
"reflog": tr.ReflogCommitsTitle,
|
||||
"stash": tr.StashTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// sidePanelContexts maps each gui.sidePanels name to the context it controls.
|
||||
func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Context {
|
||||
return map[string]types.Context{
|
||||
"status": contextTree.Status,
|
||||
"files": contextTree.Files,
|
||||
"worktrees": contextTree.Worktrees,
|
||||
"submodules": contextTree.Submodules,
|
||||
"branches": contextTree.Branches,
|
||||
"remotes": contextTree.Remotes,
|
||||
"tags": contextTree.Tags,
|
||||
"commits": contextTree.LocalCommits,
|
||||
"reflog": contextTree.ReflogCommits,
|
||||
"stash": contextTree.Stash,
|
||||
}
|
||||
}
|
||||
|
||||
// applySidePanelConfig (re)assigns each side context's window and resets each
|
||||
// window's default view from the current gui.sidePanels config. It runs against
|
||||
// the current repo's contexts, so gui.State must already be set. We call it on
|
||||
// every repo entry (a repo's per-repo config can differ from the previous one's)
|
||||
// and on a live config reload.
|
||||
func (gui *Gui) applySidePanelConfig() {
|
||||
contextTree := gui.State.Contexts
|
||||
gui.assignSidePanelWindows(contextTree)
|
||||
gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree)
|
||||
}
|
||||
|
||||
// moveDefaultTabsToTop brings each panel's first configured tab to the top of
|
||||
// its window, so the configured default tab is the one shown when a panel hasn't
|
||||
// been focused yet (the view z-order is otherwise set from a fixed list that
|
||||
// need not match the configured tab order).
|
||||
func (gui *Gui) moveDefaultTabsToTop() {
|
||||
contexts := sidePanelContexts(gui.State.Contexts)
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
gui.helpers.Window.MoveToTopOfWindow(contexts[panel[0]])
|
||||
}
|
||||
}
|
||||
|
||||
// reloadSidePanels re-applies the side panel config to the current repo after a
|
||||
// live config reload: it reassigns windows and default views, restores each
|
||||
// panel's default tab, and keeps the focused panel in a consistent state.
|
||||
func (gui *Gui) reloadSidePanels() {
|
||||
gui.applySidePanelConfig()
|
||||
gui.moveDefaultTabsToTop()
|
||||
|
||||
// applySidePanelConfig reset every window to show its first configured tab,
|
||||
// which would leave the focused tab hidden behind its panel's default tab
|
||||
// (the panel would look unfocused even though its tab is selected). Re-focus
|
||||
// the current context so its tab stays shown and highlighted. If the new
|
||||
// config has hidden the focused panel entirely, move focus to the default
|
||||
// side panel instead.
|
||||
current := gui.c.Context().Current()
|
||||
if current.GetKind() != types.SIDE_CONTEXT {
|
||||
return
|
||||
}
|
||||
|
||||
if lo.Contains(gui.helpers.Window.SideWindows(), current.GetWindowName()) {
|
||||
gui.c.Context().Activate(current, types.OnFocusOpts{})
|
||||
} else {
|
||||
gui.c.Context().Push(gui.defaultSideContext(), types.OnFocusOpts{})
|
||||
}
|
||||
}
|
||||
|
||||
// assignSidePanelWindows sets each side context's window name from the config so
|
||||
// that contexts grouped into one panel share a window (the window name being the
|
||||
// panel's first tab). Side panels the user hasn't listed get their own window
|
||||
// name; since the layout produces no dimensions for those windows, their views
|
||||
// stay hidden rather than overlapping a visible panel.
|
||||
func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) {
|
||||
contexts := sidePanelContexts(contextTree)
|
||||
assigned := make(map[string]bool, len(contexts))
|
||||
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
windowName := panel[0]
|
||||
for _, name := range panel {
|
||||
contexts[name].SetWindowName(windowName)
|
||||
assigned[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for name, ctx := range contexts {
|
||||
if !assigned[name] {
|
||||
ctx.SetWindowName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
pkg/gui/side_panels_test.go
Normal file
30
pkg/gui/side_panels_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := lo.Keys(m)
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// The three lookups that translate gui.sidePanels names into views, titles, and
|
||||
// contexts must each cover exactly the set of valid names, or a config that uses
|
||||
// a name missing from one of them would hit a nil lookup at runtime.
|
||||
func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) {
|
||||
want := lo.Uniq(config.ValidSidePanelTabs)
|
||||
sort.Strings(want)
|
||||
|
||||
gui := NewDummyGui()
|
||||
|
||||
assert.Equal(t, want, sortedKeys(sidePanelViewNames))
|
||||
assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles()))
|
||||
assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree())))
|
||||
}
|
||||
|
|
@ -59,6 +59,10 @@ type IGuiCommon interface {
|
|||
Suspend() error
|
||||
Resume() error
|
||||
|
||||
// Pause or resume the background routines. Calls nest, so every pause must be balanced
|
||||
// by a resume.
|
||||
PauseBackgroundRefreshes(pause bool)
|
||||
|
||||
Context() IContextMgr
|
||||
ContextForKey(key ContextKey) Context
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,28 @@ const (
|
|||
BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
|
||||
)
|
||||
|
||||
// CommitSelectionBehavior controls which local commit is selected after the
|
||||
// commits list is reloaded by a refresh.
|
||||
type CommitSelectionBehavior int
|
||||
|
||||
const (
|
||||
// Keep the same commit selected by hash (and the same range, when
|
||||
// range-selecting), restoring it at its new position if it moved. This is
|
||||
// the right default whenever the list reloads underneath a selection the
|
||||
// user hasn't deliberately changed.
|
||||
KeepCommitSelectionByHash CommitSelectionBehavior = iota
|
||||
|
||||
// Leave the selection index untouched, because the caller set it itself
|
||||
// before refreshing. Used when jumping to the top of the list after a
|
||||
// checkout, and when following a commit that was just moved up or down.
|
||||
KeepCommitSelectionIndex
|
||||
|
||||
// Select the HEAD commit. Used by operations that create a new commit at
|
||||
// HEAD (committing, merging, pulling with a merge); the by-hash behavior
|
||||
// can't restore a commit that didn't exist before the refresh.
|
||||
SelectHeadCommit
|
||||
)
|
||||
|
||||
type RefreshOptions struct {
|
||||
Then func()
|
||||
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
|
||||
|
|
@ -44,4 +66,16 @@ type RefreshOptions struct {
|
|||
// keeps the selection index the same. Useful after checking out a detached
|
||||
// head, and selecting index 0.
|
||||
KeepBranchSelectionIndex bool
|
||||
|
||||
// Controls which local commit is selected after the refresh. Defaults to
|
||||
// KeepCommitSelectionByHash.
|
||||
CommitSelection CommitSelectionBehavior
|
||||
|
||||
// When true, this refresh was initiated by a background routine rather than
|
||||
// by a user action. We use it to keep background `git status` calls from
|
||||
// taking optional git locks, so they don't contend for index.lock with git
|
||||
// commands the user runs in a terminal. The cost is that such a status won't
|
||||
// persist git's refreshed stat-cache, which is the right trade-off for
|
||||
// unattended work; foreground refreshes leave this false so they do persist.
|
||||
Background bool
|
||||
}
|
||||
|
|
|
|||
101
pkg/gui/views.go
101
pkg/gui/views.go
|
|
@ -9,7 +9,6 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type viewNameMapping struct {
|
||||
|
|
@ -182,10 +181,12 @@ func (gui *Gui) configureViewProperties() {
|
|||
|
||||
gui.Views.Stash.Title = gui.c.Tr.StashTitle
|
||||
gui.Views.Commits.Title = gui.c.Tr.CommitsTitle
|
||||
gui.Views.ReflogCommits.Title = gui.c.Tr.ReflogCommitsTitle
|
||||
gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles
|
||||
gui.Views.Branches.Title = gui.c.Tr.BranchesTitle
|
||||
gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
|
||||
gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle
|
||||
gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle
|
||||
gui.Views.Tags.Title = gui.c.Tr.TagsTitle
|
||||
gui.Views.Files.Title = gui.c.Tr.FilesTitle
|
||||
gui.Views.PatchBuilding.Title = gui.c.Tr.Patch
|
||||
|
|
@ -210,66 +211,64 @@ func (gui *Gui) configureViewProperties() {
|
|||
gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage
|
||||
gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth
|
||||
|
||||
if gui.c.UserConfig().Gui.ShowPanelJumps {
|
||||
keyToTitlePrefix := func(binding config.Keybinding) string {
|
||||
if len(binding) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("[%s]", binding[0])
|
||||
keyToTitlePrefix := func(binding config.Keybinding) string {
|
||||
if len(binding) == 0 {
|
||||
return ""
|
||||
}
|
||||
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
|
||||
jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string {
|
||||
return keyToTitlePrefix(binding)
|
||||
return fmt.Sprintf("[%s]", binding[0])
|
||||
}
|
||||
|
||||
// The views that make up each side panel, in panel order. The whole group
|
||||
// shares the panel's jump label.
|
||||
panelViewGroups := lo.Map(gui.c.UserConfig().Gui.SidePanels, func(panel config.SidePanel, _ int) []*gocui.View {
|
||||
return lo.Map(panel, func(name string, _ int) *gocui.View {
|
||||
view, _ := gui.g.View(sidePanelViewNames[name])
|
||||
return view
|
||||
})
|
||||
})
|
||||
|
||||
gui.Views.Status.TitlePrefix = jumpLabels[0]
|
||||
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
|
||||
jumpLabelForPanel := func(panelIndex int) string {
|
||||
if !gui.c.UserConfig().Gui.ShowPanelJumps || panelIndex >= len(jumpBindings) {
|
||||
return ""
|
||||
}
|
||||
return keyToTitlePrefix(jumpBindings[panelIndex])
|
||||
}
|
||||
|
||||
gui.Views.Files.TitlePrefix = jumpLabels[1]
|
||||
gui.Views.Worktrees.TitlePrefix = jumpLabels[1]
|
||||
gui.Views.Submodules.TitlePrefix = jumpLabels[1]
|
||||
|
||||
gui.Views.Branches.TitlePrefix = jumpLabels[2]
|
||||
gui.Views.Remotes.TitlePrefix = jumpLabels[2]
|
||||
gui.Views.Tags.TitlePrefix = jumpLabels[2]
|
||||
|
||||
gui.Views.Commits.TitlePrefix = jumpLabels[3]
|
||||
gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3]
|
||||
|
||||
gui.Views.Stash.TitlePrefix = jumpLabels[4]
|
||||
for panelIndex, views := range panelViewGroups {
|
||||
prefix := jumpLabelForPanel(panelIndex)
|
||||
for _, view := range views {
|
||||
view.TitlePrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
if gui.c.UserConfig().Gui.ShowPanelJumps {
|
||||
gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
|
||||
} else {
|
||||
gui.Views.Status.TitlePrefix = ""
|
||||
|
||||
gui.Views.Files.TitlePrefix = ""
|
||||
gui.Views.Worktrees.TitlePrefix = ""
|
||||
gui.Views.Submodules.TitlePrefix = ""
|
||||
|
||||
gui.Views.Branches.TitlePrefix = ""
|
||||
gui.Views.Remotes.TitlePrefix = ""
|
||||
gui.Views.Tags.TitlePrefix = ""
|
||||
|
||||
gui.Views.Commits.TitlePrefix = ""
|
||||
gui.Views.ReflogCommits.TitlePrefix = ""
|
||||
|
||||
gui.Views.Stash.TitlePrefix = ""
|
||||
|
||||
gui.Views.Main.TitlePrefix = ""
|
||||
}
|
||||
|
||||
for _, view := range gui.g.Views() {
|
||||
// if the view is in our mapping, we'll set the tabs and the tab index
|
||||
for _, values := range gui.viewTabMap() {
|
||||
index := slices.IndexFunc(values, func(tabContext context.TabView) bool {
|
||||
return tabContext.ViewName == view.Name()
|
||||
})
|
||||
|
||||
if index != -1 {
|
||||
view.Tabs = lo.Map(values, func(tabContext context.TabView, _ int) string {
|
||||
return tabContext.Tab
|
||||
})
|
||||
view.TabIndex = index
|
||||
}
|
||||
// Index the tab strips by view so we can both set them on views that are
|
||||
// part of a multi-tab panel and clear them on views that no longer are
|
||||
// (which matters when the config is reloaded and a tab becomes a standalone
|
||||
// panel).
|
||||
type viewTabs struct {
|
||||
tabs []string
|
||||
index int
|
||||
}
|
||||
tabsByView := map[string]viewTabs{}
|
||||
for _, values := range gui.viewTabMap() {
|
||||
labels := lo.Map(values, func(tabContext context.TabView, _ int) string {
|
||||
return tabContext.Tab
|
||||
})
|
||||
for index, tabContext := range values {
|
||||
tabsByView[tabContext.ViewName] = viewTabs{tabs: labels, index: index}
|
||||
}
|
||||
}
|
||||
|
||||
for _, view := range gui.g.Views() {
|
||||
vt := tabsByView[view.Name()]
|
||||
view.Tabs = vt.tabs
|
||||
view.TabIndex = vt.index
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ type TranslationSet struct {
|
|||
MergeConflictPressEnterToResolve string
|
||||
MergeConflictKeepFile string
|
||||
MergeConflictDeleteFile string
|
||||
MergeConflictTakeCurrentCommit string
|
||||
MergeConflictTakeIncomingCommit string
|
||||
SubmoduleMergeConflictDescription string
|
||||
StageConflictsRangeDisabled string
|
||||
Checkout string
|
||||
CheckoutTooltip string
|
||||
CantCheckoutBranchWhilePulling string
|
||||
|
|
@ -231,7 +235,6 @@ type TranslationSet struct {
|
|||
StashChanges string
|
||||
RenameStash string
|
||||
RenameStashPrompt string
|
||||
OpenConfig string
|
||||
EditConfig string
|
||||
ForcePush string
|
||||
ForcePushPrompt string
|
||||
|
|
@ -1028,6 +1031,8 @@ type Actions struct {
|
|||
StageAllFiles string
|
||||
ResolveConflictByKeepingFile string
|
||||
ResolveConflictByDeletingFile string
|
||||
TakeCurrentSubmoduleCommit string
|
||||
TakeIncomingSubmoduleCommit string
|
||||
NotEnoughContextToStage string
|
||||
NotEnoughContextToDiscard string
|
||||
NotEnoughContextToRemoveLines string
|
||||
|
|
@ -1201,6 +1206,10 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
MergeConflictPressEnterToResolve: "Press %s to resolve.",
|
||||
MergeConflictKeepFile: "Keep file",
|
||||
MergeConflictDeleteFile: "Delete file",
|
||||
MergeConflictTakeCurrentCommit: "Take current commit",
|
||||
MergeConflictTakeIncomingCommit: "Take incoming commit",
|
||||
SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.",
|
||||
StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.",
|
||||
Checkout: "Checkout",
|
||||
CheckoutTooltip: "Checkout selected item.",
|
||||
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",
|
||||
|
|
@ -1357,7 +1366,6 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
StashChanges: "Stash changes",
|
||||
RenameStash: "Rename stash",
|
||||
RenameStashPrompt: "Rename stash: {{.stashName}}",
|
||||
OpenConfig: "Open config file",
|
||||
EditConfig: "Edit config file",
|
||||
ForcePush: "Force push",
|
||||
ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
|
||||
|
|
@ -2116,6 +2124,8 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
StageAllFiles: "Stage all files",
|
||||
ResolveConflictByKeepingFile: "Resolve by keeping file",
|
||||
ResolveConflictByDeletingFile: "Resolve by deleting file",
|
||||
TakeCurrentSubmoduleCommit: "Resolve submodule conflict by taking current commit",
|
||||
TakeIncomingSubmoduleCommit: "Resolve submodule conflict by taking incoming commit",
|
||||
NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.",
|
||||
NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.",
|
||||
NotEnoughContextToRemoveLines: "Removing lines from a commit is not possible with a diff context size of 0. Increase the context using '%s'.",
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@
|
|||
|
||||
The pkg/integration package is for integration testing: that is, actually running a real lazygit session and having a robot pretend to be a human user and then making assertions that everything works as expected.
|
||||
|
||||
TL;DR: integration tests live in pkg/integration/tests. Run integration tests with:
|
||||
TL;DR: integration tests live in pkg/integration/tests, and we run them through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`. Run the whole suite headlessly with:
|
||||
|
||||
```sh
|
||||
go run cmd/integration_test/main.go tui
|
||||
just e2e
|
||||
```
|
||||
|
||||
or
|
||||
or open a terminal UI to browse and run individual tests with:
|
||||
|
||||
```sh
|
||||
go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...]
|
||||
just e2e-tui
|
||||
```
|
||||
|
||||
## Writing tests
|
||||
|
||||
The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `go generate ./...` at the root of the Lazygit repo.
|
||||
The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `just generate` at the root of the Lazygit repo.
|
||||
|
||||
Each test has two important steps: the setup step and the run step.
|
||||
|
||||
|
|
@ -38,19 +38,18 @@ The run step has two arguments passed in:
|
|||
|
||||
## Running tests
|
||||
|
||||
There are three ways to invoke a test:
|
||||
We drive the integration tests through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`, so you'll want `just` installed to run them as described here. (The recipes are thin wrappers, so if you can't install `just`, the underlying commands are right there in the `justfile`.)
|
||||
|
||||
1. go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...]
|
||||
2. go run cmd/integration_test/main.go tui
|
||||
3. go test pkg/integration/clients/*.go
|
||||
- `just e2e` — run the whole suite headlessly, with no visible UI. This is what CI does, and the fastest way to run everything.
|
||||
- `just e2e <name>` — run a single test headlessly, e.g. `just e2e commit/new_branch`; the fastest way to run one test. You can pass several names at once, or a full file path like `pkg/integration/tests/commit/new_branch.go`.
|
||||
- `just e2e-cli [--slow|--sandbox|--debug] <name>` — run a single test in a *visible* lazygit UI, so you can watch it (see slow mode below, and sandbox mode and debugging in the following sections).
|
||||
- `just e2e-tui` — open a terminal UI for browsing and running tests; the easiest way to find and run a test without having to type its name.
|
||||
|
||||
The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests.
|
||||
The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far.
|
||||
The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output.
|
||||
The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is `commit/new_branch`.
|
||||
|
||||
The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`.
|
||||
zsh users can get tab-completion of these test names — `just e2e sub<Tab>` expands to `submodule/…` — by sourcing `scripts/just_e2e_completion.zsh` from their `.zshrc`; see the comment at the top of that file for details.
|
||||
|
||||
You can pass the INPUT_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses or mouse clicks, which helps for watching a test at a realistic speed to understand what it's doing. Or you can pass the '--slow' flag which sets a pre-set 'slow' key delay. In the tui you can press 't' to run the test in slow mode.
|
||||
To watch a test run at a realistic speed, pass `--slow` to `just e2e-cli`; it sets a pre-set delay between keypresses and mouse clicks. For finer control, set the `INPUT_DELAY` env var to a number of milliseconds instead, e.g. `INPUT_DELAY=200 just e2e-cli commit/new_branch`. In the TUI you can press 't' to run a test in slow mode.
|
||||
|
||||
The resultant repo will be stored in `test/_results`, so if you're not sure what went wrong you can go there and inspect the repo.
|
||||
|
||||
|
|
@ -67,8 +66,8 @@ The test will run in a VSCode terminal:
|
|||
|
||||
Debugging an integration test is possible in two ways:
|
||||
|
||||
1. Use the -debug option of the integration test runner's "cli" command, e.g. `go run cmd/integration_test/main.go cli -debug tag/reset.go`
|
||||
2. Select a test in the "tui" runner and hit "d" to debug it.
|
||||
1. Pass `--debug` to `just e2e-cli`, e.g. `just e2e-cli --debug tag/reset`.
|
||||
2. Select a test in `just e2e-tui` and hit "d" to debug it.
|
||||
|
||||
In both cases the test runner will print to the console that it is waiting for a debugger to attach, so now you need to tell your debugger to attach to a running process with the name "test_lazygit". If you are using Visual Studio Code, an easy way to do that is to use the "Attach to integration test runner" debug configuration. The test runner will resume automatically when it detects that a debugger was attached. Don't forget to set a breakpoint in the code that you want to step through, otherwise the test will just finish (i.e. it doesn't stop in the debugger automatically).
|
||||
|
||||
|
|
@ -76,7 +75,7 @@ In both cases the test runner will print to the console that it is waiting for a
|
|||
|
||||
Say you want to do a manual test of how lazygit handles merge-conflicts, but you can't be bothered actually finding a way to create merge conflicts in a repo. To make your life easier, you can simply run a merge-conflicts test in sandbox mode, meaning the setup step is run for you, and then instead of the test driving the lazygit session, you're allowed to drive it yourself.
|
||||
|
||||
To run a test in sandbox mode you can press 's' on a test in the test TUI or in the test runner pass the --sandbox argument.
|
||||
To run a test in sandbox mode, press 's' on a test in `just e2e-tui`, or pass `--sandbox` to `just e2e-cli`, e.g. `just e2e-cli --sandbox conflicts/resolve_multiple_files`.
|
||||
|
||||
## Tips for writing tests
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) {
|
|||
self.press(key[0])
|
||||
}
|
||||
|
||||
// FocusIn simulates the terminal window regaining focus, which causes lazygit
|
||||
// to reload any config files that changed while it was in the background.
|
||||
func (self *TestDriver) FocusIn() {
|
||||
self.SetCaption("Focusing window")
|
||||
self.gui.FocusIn()
|
||||
self.Wait(self.inputDelay)
|
||||
}
|
||||
|
||||
func (self *TestDriver) typeContent(content string) {
|
||||
for _, char := range content {
|
||||
self.pressFast(string(char))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ func (self *fakeGuiDriver) Click(x, y int) {
|
|||
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) FocusIn() {
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) Keys() config.KeybindingConfig {
|
||||
return config.KeybindingConfig{}
|
||||
}
|
||||
|
|
@ -72,6 +75,10 @@ func (self *fakeGuiDriver) View(viewName string) *gocui.View {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) TopViewInWindow(windowName string) *gocui.View {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) SetCaption(string) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -408,6 +408,28 @@ func (self *ViewDriver) IsFocused() *ViewDriver {
|
|||
return self
|
||||
}
|
||||
|
||||
// asserts that the view is the one currently shown in its window, i.e. it's the
|
||||
// active tab of its panel (drawn in front of the window's other tabs). Unlike
|
||||
// IsFocused, this is about what's displayed rather than which view has keyboard
|
||||
// focus; the two can disagree, e.g. if a config reload reshuffles the tabs.
|
||||
func (self *ViewDriver) IsActiveTab() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
expected := self.getView().Name()
|
||||
context := self.t.gui.ContextForView(expected)
|
||||
if context == nil {
|
||||
return false, fmt.Sprintf("%s: Could not find context for view, so can't determine its window", expected)
|
||||
}
|
||||
topView := self.t.gui.TopViewInWindow(context.GetWindowName())
|
||||
actual := ""
|
||||
if topView != nil {
|
||||
actual = topView.Name()
|
||||
}
|
||||
return actual == expected, fmt.Sprintf("%s: Expected view to be the active tab of its window, but it was %s", expected, actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) Press(key config.Keybinding) *ViewDriver {
|
||||
self.IsFocused()
|
||||
|
||||
|
|
|
|||
|
|
@ -73,25 +73,11 @@ var CherryPickCommitThatBecomesEmpty = NewIntegrationTest(NewIntegrationTestArgs
|
|||
// Cherry-picked commit is empty
|
||||
t.Views().Main().Content(DoesNotContain("diff --git"))
|
||||
} else {
|
||||
// Older git versions drop the commit that became empty
|
||||
t.Views().Commits().
|
||||
// We have a bug with how the selection is updated in this case; normally you would
|
||||
// expect the "two changes in one commit" commit to be selected because it was
|
||||
// selected before pasting, and we try to maintain that selection. This is broken
|
||||
// for two reasons:
|
||||
// 1. We increment the selected line index after pasting by the number of pasted
|
||||
// commits; this is wrong because we skipped the commit that became empty. So
|
||||
// according to this bug, the "base" commit should be selected.
|
||||
// 2. We only update the selected line index after pasting if the currently selected
|
||||
// commit is not a rebase TODO commit, on the assumption that if it is, we are in a
|
||||
// rebase and the cherry-picked commits end up below the selection. In this case,
|
||||
// however, we still think we are cherry-picking because the final refresh after the
|
||||
// CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet;
|
||||
// so the "unrelated change" still has a "pick" action.
|
||||
//
|
||||
// Since this only happens for older git versions, we don't bother fixing it.
|
||||
Lines(
|
||||
Contains("unrelated change").IsSelected(),
|
||||
Contains("two changes in one commit"),
|
||||
Contains("unrelated change"),
|
||||
Contains("two changes in one commit").IsSelected(),
|
||||
Contains("base"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
t.Views().Commits().
|
||||
Focus().
|
||||
TopLines(
|
||||
Contains("second-change-branch unrelated change").IsSelected(),
|
||||
Contains("second-change-branch unrelated change"),
|
||||
Contains("second change"),
|
||||
Contains("first change"),
|
||||
Contains("first change").IsSelected(),
|
||||
).
|
||||
SelectNextItem().
|
||||
SelectPreviousItem().
|
||||
Tap(func() {
|
||||
// because we picked 'Second change' when resolving the conflict,
|
||||
// we now see this commit as having replaced First Change with Second Change,
|
||||
|
|
|
|||
|
|
@ -69,23 +69,8 @@ var CherryPickConflictsEmptyCommitAfterResolving = NewIntegrationTest(NewIntegra
|
|||
t.Views().Commits().
|
||||
Focus().
|
||||
TopLines(
|
||||
// We have a bug with how the selection is updated in this case; normally you would
|
||||
// expect the "first change" commit to be selected because it was selected before
|
||||
// pasting, and we try to maintain that selection. This is broken for two reasons:
|
||||
// 1. We increment the selected line index after pasting by the number of pasted
|
||||
// commits; this is wrong because we skipped the commit that became empty. So
|
||||
// according to this bug, the "original" commit should be selected.
|
||||
// 2. We only update the selected line index after pasting if the currently selected
|
||||
// commit is not a rebase TODO commit, on the assumption that if it is, we are in a
|
||||
// rebase and the cherry-picked commits end up below the selection. In this case,
|
||||
// however, we still think we are cherry-picking because the final refresh after the
|
||||
// CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet;
|
||||
// so the "second-change-branch unrelated change" still has a "pick" action.
|
||||
//
|
||||
// We don't bother fixing it for now because it's a pretty niche case, and the
|
||||
// nature of the problem is only cosmetic.
|
||||
Contains("second-change-branch unrelated change").IsSelected(),
|
||||
Contains("first change"),
|
||||
Contains("second-change-branch unrelated change"),
|
||||
Contains("first change").IsSelected(),
|
||||
Contains("original"),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var KeepSelectedCommitAfterExternalCommit = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Keep the same commit selected after an external commit is created",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "first content")
|
||||
shell.Commit("first commit")
|
||||
shell.UpdateFile("file", "second content")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit"),
|
||||
Contains("first commit"),
|
||||
).
|
||||
NavigateToLine(Contains("first commit"))
|
||||
|
||||
t.Views().Main().Content(Contains("+first content"))
|
||||
|
||||
t.GlobalPress(keys.Universal.ExecuteShellCommand)
|
||||
t.ExpectPopup().Prompt().
|
||||
Title(Equals("Shell command:")).
|
||||
Type("git commit --allow-empty -m 'external commit'").
|
||||
Confirm()
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("external commit"),
|
||||
Contains("second commit"),
|
||||
Contains("first commit").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().Content(Contains("+first content"))
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SidePanelsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A per-repo config can set the side panel layout, and switching repos re-applies each repo's own layout",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
otherRepo, _ := filepath.Abs("../other")
|
||||
cfg.GetAppState().RecentRepos = []string{otherRepo}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CloneNonBare("other")
|
||||
// The other repo swaps the branches and commits panels.
|
||||
shell.CreateFile("../other/.git/lazygit.yml", `
|
||||
gui:
|
||||
sidePanels:
|
||||
- [status]
|
||||
- [files, worktrees, submodules]
|
||||
- [commits, reflog]
|
||||
- [branches, remotes, tags]
|
||||
- [stash]`)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// This repo uses the default layout, so the third panel is branches.
|
||||
t.GlobalPress(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Branches().IsFocused()
|
||||
|
||||
// Switch to the other repo, whose per-repo config swaps branches and commits.
|
||||
t.GlobalPress(keys.Universal.OpenRecentRepos)
|
||||
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("Cancel"),
|
||||
).Confirm()
|
||||
t.Views().Status().Content(Contains("other → master"))
|
||||
|
||||
// Now the third panel is commits.
|
||||
t.GlobalPress(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Commits().IsFocused()
|
||||
|
||||
// Switch back to the first repo; its default layout is intact even though
|
||||
// its contexts were built before we visited the other repo.
|
||||
t.GlobalPress(keys.Universal.JumpToBlock[1])
|
||||
t.Views().Files().IsFocused()
|
||||
t.GlobalPress(keys.Universal.OpenRecentRepos)
|
||||
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).Confirm()
|
||||
|
||||
t.GlobalPress(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Branches().IsFocused()
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package conflicts
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
|
||||
shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`)
|
||||
|
||||
// theirs: delete foo, modify bar
|
||||
shell.RunShellCommand(`git checkout -b theirs`)
|
||||
shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`)
|
||||
|
||||
// ours: modify foo, delete bar
|
||||
shell.RunShellCommand(`git checkout base && git checkout -b ours`)
|
||||
shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`)
|
||||
|
||||
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("DU bar"),
|
||||
Contains("UD foo"),
|
||||
).
|
||||
// Pressing space on a single non-textual conflict opens the
|
||||
// resolution menu rather than trying to stage it.
|
||||
NavigateToLine(Contains("bar")).
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel()
|
||||
}).
|
||||
// Staging is disabled for a range selection that includes a conflict.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("foo")).
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts"))
|
||||
}).
|
||||
// Entering a range selection is disabled too, with the usual toast.
|
||||
Press(keys.Universal.GoInto).
|
||||
Tap(func() {
|
||||
t.ExpectToast(Contains("does not support range selection"))
|
||||
})
|
||||
},
|
||||
})
|
||||
82
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
82
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package submodule
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Resolve a submodule conflict (both sides moved the gitlink) by picking one side's commit",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("add submodule")
|
||||
|
||||
sub := "my_submodule_path"
|
||||
|
||||
// Two diverging commits in the submodule, so the gitlink can't be
|
||||
// fast-forwarded and the merge genuinely conflicts.
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "left"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "left"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "right", "HEAD~1"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "right"})
|
||||
|
||||
// "ours" points the submodule at left, "theirs" at right.
|
||||
shell.RunCommand([]string{"git", "checkout", "-b", "ours"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"})
|
||||
shell.RunCommand([]string{"git", "add", sub})
|
||||
shell.Commit("ours")
|
||||
|
||||
shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "right"})
|
||||
shell.RunCommand([]string{"git", "add", sub})
|
||||
shell.Commit("theirs")
|
||||
|
||||
shell.RunCommand([]string{"git", "checkout", "ours"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"})
|
||||
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("UU my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
Tap(func() {
|
||||
// The main view explains the conflict and shows each side's
|
||||
// commits as separate "current" and "incoming" logs.
|
||||
t.Views().Main().Content(
|
||||
Contains("Conflict: the submodule").
|
||||
Contains("Current changes:").Contains("left").
|
||||
Contains("Incoming changes:").Contains("right"),
|
||||
)
|
||||
}).
|
||||
// Enter opens the resolution menu instead of entering the submodule.
|
||||
// The two candidate commits are shown with their summaries.
|
||||
Press(keys.Universal.GoInto).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge conflicts")).
|
||||
Select(Contains("Take current commit").Contains("left")).
|
||||
Select(Contains("Take incoming commit").Contains("right")).
|
||||
Cancel()
|
||||
}).
|
||||
// Space opens the same menu; take the incoming commit to resolve.
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge conflicts")).
|
||||
Select(Contains("Take incoming commit")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Contains("M my_submodule_path (submodule)").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package submodule
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ResolveConflictRewoundSide = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "When a side of a submodule conflict added no commits of its own (it was rewound), the main view shows the commit it points at instead of an empty log",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
shell.CloneIntoSubmodule("sub_name", "sub_path")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("add submodule")
|
||||
|
||||
sub := "sub_path"
|
||||
|
||||
// Mark the submodule's initial commit, then advance it; the merge base
|
||||
// will point the submodule here.
|
||||
shell.RunCommand([]string{"git", "-C", sub, "branch", "initial"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s1"})
|
||||
shell.RunCommand([]string{"git", "add", sub})
|
||||
shell.Commit("base at s1")
|
||||
|
||||
// "ours" rewinds the submodule to its initial commit (so it has no
|
||||
// commits of its own relative to "theirs").
|
||||
shell.RunCommand([]string{"git", "checkout", "-b", "ours"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"})
|
||||
shell.RunCommand([]string{"git", "add", sub})
|
||||
shell.Commit("ours rewinds submodule")
|
||||
|
||||
// "theirs" advances the submodule with a further commit.
|
||||
shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "master"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s2"})
|
||||
shell.RunCommand([]string{"git", "add", sub})
|
||||
shell.Commit("theirs advances submodule")
|
||||
|
||||
shell.RunCommand([]string{"git", "checkout", "ours"})
|
||||
shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"})
|
||||
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("UU sub_path (submodule)").IsSelected(),
|
||||
).
|
||||
Tap(func() {
|
||||
// "ours" has no commits of its own, so its section falls back to
|
||||
// the commit it points at; "theirs" lists the commits it added.
|
||||
t.Views().Main().Content(
|
||||
Contains("Current changes:").Contains("first commit").
|
||||
Contains("Incoming changes:").Contains("s1").Contains("s2"),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -29,7 +29,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("four"),
|
||||
Contains("four").IsSelected(),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("Merge branch 'master' of ../origin"),
|
||||
Contains("Merge branch 'master' of ../origin").IsSelected(),
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("four"),
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ var tests = []*components.IntegrationTest{
|
|||
commit.Highlight,
|
||||
commit.History,
|
||||
commit.HistoryComplex,
|
||||
commit.KeepSelectedCommitAfterExternalCommit,
|
||||
commit.NewBranch,
|
||||
commit.PasteCommitMessage,
|
||||
commit.PasteCommitMessageOverExisting,
|
||||
|
|
@ -161,6 +162,7 @@ var tests = []*components.IntegrationTest{
|
|||
config.CustomCommandsInPerRepoConfig,
|
||||
config.NegativeRefspec,
|
||||
config.RemoteNamedStar,
|
||||
config.SidePanelsInPerRepoConfig,
|
||||
conflicts.Filter,
|
||||
conflicts.MergeFileBoth,
|
||||
conflicts.MergeFileCurrent,
|
||||
|
|
@ -170,6 +172,7 @@ var tests = []*components.IntegrationTest{
|
|||
conflicts.ResolveNoAutoStage,
|
||||
conflicts.ResolveNonTextualConflicts,
|
||||
conflicts.ResolveWithoutTrailingLf,
|
||||
conflicts.SpaceOnNonTextualConflict,
|
||||
conflicts.UndoChooseHunk,
|
||||
custom_commands.AccessCommitProperties,
|
||||
custom_commands.BasicCommand,
|
||||
|
|
@ -427,6 +430,8 @@ var tests = []*components.IntegrationTest{
|
|||
submodule.RemoveNested,
|
||||
submodule.Reset,
|
||||
submodule.ResetFolder,
|
||||
submodule.ResolveConflict,
|
||||
submodule.ResolveConflictRewoundSide,
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
|
|
@ -473,11 +478,15 @@ var tests = []*components.IntegrationTest{
|
|||
ui.Accordion,
|
||||
ui.DisableSwitchTabWithPanelJumpKeys,
|
||||
ui.EmptyMenu,
|
||||
ui.HideSidePanel,
|
||||
ui.KeybindingSuggestionsDontCrashOnDisabledBindings,
|
||||
ui.KeybindingSuggestionsWhenSwitchingRepos,
|
||||
ui.ModeSpecificKeybindingSuggestions,
|
||||
ui.OpenLinkFailure,
|
||||
ui.PromoteTabToSidePanel,
|
||||
ui.RangeSelect,
|
||||
ui.ReloadSidePanels,
|
||||
ui.ReorderSidePanels,
|
||||
ui.SwitchTabFromMenu,
|
||||
ui.SwitchTabWithPanelJumpKeys,
|
||||
undo.UndoCheckoutAndDrop,
|
||||
|
|
|
|||
33
pkg/integration/tests/ui/hide_side_panel.go
Normal file
33
pkg/integration/tests/ui/hide_side_panel.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var HideSidePanel = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Hide a side panel by omitting it from gui.sidePanels",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// No stash panel.
|
||||
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommits(2)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Commits is now the last panel; cycling forward from it wraps around to
|
||||
// the status panel, skipping the hidden stash panel entirely.
|
||||
t.Views().Files().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[3])
|
||||
t.Views().Commits().IsFocused().
|
||||
Press(keys.Universal.NextBlock)
|
||||
t.Views().Status().IsFocused()
|
||||
},
|
||||
})
|
||||
40
pkg/integration/tests/ui/promote_tab_to_side_panel.go
Normal file
40
pkg/integration/tests/ui/promote_tab_to_side_panel.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var PromoteTabToSidePanel = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Promote the worktrees tab to its own top-level side panel via gui.sidePanels",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// Worktrees is pulled out of the files panel into its own panel.
|
||||
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "submodules"},
|
||||
{"worktrees"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommits(2)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Worktrees is now its own panel in the third position, reachable by its
|
||||
// jump key rather than as a tab of the files panel.
|
||||
t.Views().Files().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Worktrees().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[1])
|
||||
|
||||
// The files panel's tabs are now just files and submodules, so cycling
|
||||
// tabs from files goes straight to submodules.
|
||||
t.Views().Files().IsFocused().
|
||||
Press(keys.Universal.NextTab)
|
||||
t.Views().Submodules().IsFocused()
|
||||
},
|
||||
})
|
||||
51
pkg/integration/tests/ui/reload_side_panels.go
Normal file
51
pkg/integration/tests/ui/reload_side_panels.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ReloadSidePanels = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Editing the side panel config and refocusing the window re-applies the layout live, keeping the focused panel focused",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommits(2)
|
||||
// Start with worktrees promoted to its own panel.
|
||||
shell.CreateFile(".git/lazygit.yml", `
|
||||
gui:
|
||||
sidePanels:
|
||||
- [status]
|
||||
- [files, submodules]
|
||||
- [worktrees]
|
||||
- [branches, remotes, tags]
|
||||
- [commits, reflog]
|
||||
- [stash]`)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Worktrees is its own panel in the third position.
|
||||
t.Views().Files().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Worktrees().IsFocused()
|
||||
|
||||
// Demote worktrees back into the files panel, then refocus the window to
|
||||
// trigger a live reload of the changed config.
|
||||
t.Shell().UpdateFile(".git/lazygit.yml", `
|
||||
gui:
|
||||
sidePanels:
|
||||
- [status]
|
||||
- [files, worktrees, submodules]
|
||||
- [branches, remotes, tags]
|
||||
- [commits, reflog]
|
||||
- [stash]`)
|
||||
t.FocusIn()
|
||||
|
||||
// Worktrees is now a tab of the files panel. It stays focused, and is shown
|
||||
// in front rather than being hidden behind the files tab (which would leave
|
||||
// the panel looking unfocused).
|
||||
t.Views().Worktrees().IsActiveTab().IsFocused().
|
||||
Press(keys.Universal.PrevTab)
|
||||
t.Views().Files().IsActiveTab().IsFocused()
|
||||
},
|
||||
})
|
||||
33
pkg/integration/tests/ui/reorder_side_panels.go
Normal file
33
pkg/integration/tests/ui/reorder_side_panels.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ReorderSidePanels = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Reorder the side panels with gui.sidePanels, swapping the branches and commits panels",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"commits", "reflog"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommits(2)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// The third panel is now commits and the fourth is branches (the reverse
|
||||
// of the default order), so their jump keys are swapped.
|
||||
t.Views().Files().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[2])
|
||||
t.Views().Commits().IsFocused().
|
||||
Press(keys.Universal.JumpToBlock[3])
|
||||
t.Views().Branches().IsFocused()
|
||||
},
|
||||
})
|
||||
|
|
@ -24,6 +24,9 @@ type IntegrationTest interface {
|
|||
type GuiDriver interface {
|
||||
PressKey(string)
|
||||
Click(int, int)
|
||||
// Simulate the terminal window regaining focus (which triggers a reload of
|
||||
// changed config files)
|
||||
FocusIn()
|
||||
Keys() config.KeybindingConfig
|
||||
CurrentContext() types.Context
|
||||
ContextForView(viewName string) types.Context
|
||||
|
|
@ -41,6 +44,8 @@ type GuiDriver interface {
|
|||
// e.g. when we're showing both staged and unstaged changes
|
||||
SecondaryView() *gocui.View
|
||||
View(viewName string) *gocui.View
|
||||
// the frontmost visible view in the given window, i.e. the currently shown tab
|
||||
TopViewInWindow(windowName string) *gocui.View
|
||||
SetCaption(caption string)
|
||||
SetCaptionPrefix(prefix string)
|
||||
// Pop the next toast that was displayed; returns nil if there was none
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import (
|
|||
)
|
||||
|
||||
func tailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
|
||||
var lastModified int64 = 0
|
||||
var lastOffset int64 = 0
|
||||
var lastModified int64
|
||||
var lastOffset int64
|
||||
for {
|
||||
stat, err := os.Stat(logFilePath)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,11 @@
|
|||
"description": "If true, periodically refresh files and submodules",
|
||||
"default": true
|
||||
},
|
||||
"autoDetectExternalChanges": {
|
||||
"type": "boolean",
|
||||
"description": "If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.",
|
||||
"default": true
|
||||
},
|
||||
"autoForwardBranches": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -585,6 +590,35 @@
|
|||
"description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.",
|
||||
"default": 2
|
||||
},
|
||||
"sidePanels": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/SidePanel"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.",
|
||||
"default": [
|
||||
[
|
||||
"status"
|
||||
],
|
||||
[
|
||||
"files",
|
||||
"worktrees",
|
||||
"submodules"
|
||||
],
|
||||
[
|
||||
"branches",
|
||||
"remotes",
|
||||
"tags"
|
||||
],
|
||||
[
|
||||
"commits",
|
||||
"reflog"
|
||||
],
|
||||
[
|
||||
"stash"
|
||||
]
|
||||
]
|
||||
},
|
||||
"mainPanelSplitMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -3354,6 +3388,20 @@
|
|||
}
|
||||
],
|
||||
"default": "\u003cctrl+t\u003e"
|
||||
},
|
||||
"editConfig": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "\u003calt+shift+c\u003e"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
|
@ -3543,21 +3591,45 @@
|
|||
"properties": {
|
||||
"refreshInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.",
|
||||
"default": 10
|
||||
},
|
||||
"fetchInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.",
|
||||
"default": 60
|
||||
},
|
||||
"externalChangeCheckInterval": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).\nDetection can be disabled via option 'git.autoDetectExternalChanges'.",
|
||||
"default": 2
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"description": "Background refreshes"
|
||||
},
|
||||
"SidePanel": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"status",
|
||||
"files",
|
||||
"worktrees",
|
||||
"submodules",
|
||||
"branches",
|
||||
"remotes",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflog",
|
||||
"stash"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"SpinnerConfig": {
|
||||
"properties": {
|
||||
"frames": {
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@
|
|||
|
||||
set -e
|
||||
|
||||
git diff --quiet || {
|
||||
echo "Error: there are unstaged changes. Please stage or stash them before running this script."
|
||||
exit 1
|
||||
}
|
||||
|
||||
just test
|
||||
just lint
|
||||
|
||||
status_before_generate=$(git status --porcelain=v1)
|
||||
just generate
|
||||
git diff --quiet || {
|
||||
status_after_generate=$(git status --porcelain=v1)
|
||||
if [[ "$status_after_generate" != "$status_before_generate" ]]; then
|
||||
echo "Error: auto-generated files not up to date."
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
|
|
|||
56
scripts/just_e2e_completion.zsh
Normal file
56
scripts/just_e2e_completion.zsh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Zsh completion for the `e2e` and `e2e-cli` recipes in lazygit's justfile.
|
||||
#
|
||||
# These recipes take integration-test names (e.g. submodule/reset). This makes
|
||||
# `just e2e <Tab>` complete them from pkg/integration/tests/. To enable it, add
|
||||
# the following to your ~/.zshrc, *after* the line that runs `compinit`:
|
||||
#
|
||||
# source /path/to/lazygit/scripts/just_e2e_completion.zsh
|
||||
#
|
||||
# It is a no-op when `just` isn't installed, and only kicks in inside a project
|
||||
# that has a justfile and a pkg/integration/tests/ directory, so it is harmless
|
||||
# to source unconditionally.
|
||||
|
||||
(( $+commands[just] )) || return 0
|
||||
|
||||
# just's own completion is clap-dynamic and has no hook for completing a
|
||||
# recipe's arguments, so we wrap it: handle the e2e recipes ourselves and
|
||||
# delegate everything else (recipe names, flags, ...) to just's completer.
|
||||
source <(JUST_COMPLETE=zsh just) # defines _clap_dynamic_completer_just
|
||||
|
||||
_just_lazygit_e2e() {
|
||||
if (( CURRENT > 2 )); then
|
||||
case ${words[2]} in
|
||||
e2e | e2e-cli)
|
||||
# Find the justfile's directory, then complete the integration
|
||||
# tests under pkg/integration/tests/ relative to it.
|
||||
local dir=$PWD testdir=
|
||||
while [[ $dir != / ]]; do
|
||||
if [[ -e $dir/justfile || -e $dir/.justfile || -e $dir/Justfile ]]; then
|
||||
testdir=$dir/pkg/integration/tests
|
||||
break
|
||||
fi
|
||||
dir=${dir:h}
|
||||
done
|
||||
if [[ -d $testdir ]]; then
|
||||
# A test's name is its path under pkg/integration/tests/ without
|
||||
# the .go extension, e.g. submodule/reset. Build that list, then
|
||||
# let _multi_parts complete it one "/"-separated segment at a
|
||||
# time, so an empty <Tab> offers only categories.
|
||||
local -a tests
|
||||
tests=($testdir/**/*.go(.N:r)) # strip the .go extension
|
||||
tests=(${tests#$testdir/}) # make relative to the tests dir
|
||||
tests=(${(M)tests:#*/*}) # keep category/name (drop top-level helpers)
|
||||
tests=(${tests:#shared/*}) # drop the cross-directory shared package
|
||||
tests=(${tests:#*/shared}) # drop per-category shared.go helpers
|
||||
local expl
|
||||
_wanted tests expl 'integration test' _multi_parts / tests
|
||||
return
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
_clap_dynamic_completer_just "$@"
|
||||
}
|
||||
|
||||
compdef _just_lazygit_e2e just # bind last so this wins over the default
|
||||
|
|
@ -20,3 +20,4 @@ git:
|
|||
# TODO: add tests which explicitly test auto-refresh functionality
|
||||
autoRefresh: false
|
||||
autoFetch: false
|
||||
autoDetectExternalChanges: false
|
||||
|
|
|
|||
Loading…
Reference in a new issue