Compare commits

...

205 commits

Author SHA1 Message Date
Stefan Haller d0ede21e9c
Allow running tests from a tarball (#6003)
Some checks are pending
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Waiting to run
Continuous Integration / build (push) Waiting to run
Continuous Integration / check-codebase (push) Waiting to run
Continuous Integration / lint (push) Waiting to run
Continuous Integration / upload-coverage (push) Blocked by required conditions
Continuous Integration / check-for-fixups (push) Waiting to run
Codespell / Check for spelling errors (push) Waiting to run
Generate Sponsors README / deploy (push) Waiting to run
Running integration tests from a tarball was never possible, but running
unit tests (`go test ./... -short`) was; this broke with v0.64.1
(specifically, with 34da956f5d). Make that possible again, and for all
tests now, including integration tests.
2026-09-09 09:30:10 +02:00
Stefan Haller 8b049be31d Find the lazygit root directory by go.mod instead of .git
Running the tests in an exported source tarball fails with "must run in
lazy project folder or child folder". GetLazyRootDirectory searches the
working directory and its parents for a .git directory, and a tarball
doesn't have one. This has always affected the integration tests; since
34da956f5d a unit test calls the function too, so now even
`go test ./... -short` fails.

Search for the go.mod file that declares lazygit's module instead. It
ships in tarballs, and there is exactly one of it per source tree.

Put the function in our own pkg/utils rather than change lazycore's; the
criterion is specific to lazygit, and I don't feel like making a change
to lazycore.

Return an error rather than call log.Fatal, and report it from the two
callers that run under `go test`. In a test binary, log.Fatal exits
without attributing the failure to any test. That is the failure mode
34da956f5d set out to remove. The remaining callers are development
tools that have nothing useful to do without the root directory; they
keep exiting, now through MustFindLazygitRootDirectory.

Also stop the search at the root of the file system rather than at "/".
On Windows the old loop walks up to "C:\" and then spins there forever.
2026-09-09 09:27:44 +02:00
Stefan Haller a8dc4aaf1b AGENTS.md addition 2026-09-07 09:51:49 +02:00
Stefan Haller c07f4d381b
Update docs and schema for release (#5995)
Some checks failed
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Has been cancelled
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Has been cancelled
Continuous Integration / build (push) Has been cancelled
Continuous Integration / check-codebase (push) Has been cancelled
Continuous Integration / lint (push) Has been cancelled
Continuous Integration / check-for-fixups (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Continuous Integration / upload-coverage (push) Has been cancelled
2026-09-05 16:58:42 +02:00
Stefan Haller f9dd66dbf0 Update docs and schema for release 2026-09-05 16:55:40 +02:00
Stefan Haller 21b649f12e
Update translations from Crowdin (#5994) 2026-09-05 16:49:00 +02:00
Stefan Haller 3ba69d4965 Update translations from Crowdin 2026-09-05 16:46:18 +02:00
Stefan Haller abe273dd3e
Bump github.com/sirupsen/logrus from 1.9.4 to 1.10.2 (#5987)
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus)
from 1.9.4 to 1.10.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sirupsen/logrus/releases">github.com/sirupsen/logrus's
releases</a>.</em></p>
<blockquote>
<h2>v1.10.2</h2>
<h1>Logrus v1.10.2</h1>
<p>This is a small maintenance release that updates
<code>github.com/stretchr/testify</code> to v1.12.1, removing the legacy
<code>gopkg.in/yaml.v3</code> dependency from Logrus' dependency graph.
There are no functional changes in this release.</p>
<p>Dependency Changes</p>
<ul>
<li>update github.com/stretchr/testify to v1.12.1</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/sirupsen/logrus/compare/v1.10.1...v1.10.2">https://github.com/sirupsen/logrus/compare/v1.10.1...v1.10.2</a></p>
<h2>v1.10.1</h2>
<h1>Logrus v1.10.1</h1>
<p>This patch release fixes two issues in field formatting and
handling:</p>
<ul>
<li>Fix a regression introduced in v1.10.0 where
<code>TextFormatter</code> could panic
when formatting nil or panicking <code>error</code> and
<code>fmt.Stringer</code> values.</li>
<li>Allow function-backed values implementing <code>error</code> to be
used with
<code>WithError</code>, <code>WithField</code>, and
<code>WithFields</code>.</li>
</ul>
<p>Dependency Changes</p>
<ul>
<li>update github.com/stretchr/testify to v1.12.0</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/sirupsen/logrus/compare/v1.10.0...v1.10.1">https://github.com/sirupsen/logrus/compare/v1.10.0...v1.10.1</a></p>
<h2>v1.10.0</h2>
<h1>Logrus v1.10.0</h1>
<p>This release focuses on substantial performance improvements,
concurrency correctness, and better interoperability with modern Go
logging APIs.</p>
<h2>🚀 Performance</h2>
<p>Major improvements across <code>TextFormatter</code>, entry handling,
and common logger paths:</p>
<ul>
<li>~17% lower geomean runtime across the benchmark suite</li>
<li>~27% higher geomean formatter throughput</li>
<li>Common enabled logging paths are ~30–44% faster</li>
<li><code>WithError</code> is ~40% faster</li>
<li>Chained fields are ~46% faster</li>
<li><code>TextFormatter</code> paths are up to ~40% faster</li>
<li>Allocation counts are reduced by ~25–74% across measured
<code>TextFormatter</code> cases, with the largest reductions in colored
output</li>
</ul>
<p>The improvements also show up in complete logger paths:</p>
<ul>
<li>Logger + <code>TextFormatter</code> is ~31% faster, with ~24% fewer
allocations</li>
<li>Logger + <code>JSONFormatter</code> is ~21% faster, with ~10% fewer
allocations</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md">github.com/sirupsen/logrus's
changelog</a>.</em></p>
<blockquote>
<h2>1.10.2</h2>
<p>Changed:</p>
<ul>
<li>Update <code>github.com/stretchr/testify</code> to v1.12.1, removing
the legacy
<code>gopkg.in/yaml.v3</code> dependency.</li>
</ul>
<h2>1.10.1</h2>
<p>Fixes:</p>
<ul>
<li>Fix a regression introduced in v1.10.0 where
<code>TextFormatter</code> could panic
when formatting nil or panicking <code>error</code> and
<code>fmt.Stringer</code> values.</li>
<li>Allow function-backed implementations of <code>error</code> as field
values.</li>
</ul>
<h2>1.10.0</h2>
<p>Fixes:</p>
<ul>
<li>Fix reentrant logging deadlocks in formatter paths.</li>
<li>Fix race conditions in formatter and entry handling.</li>
<li>Fix generic <code>Log</code>, <code>Logf</code>, <code>Logln</code>,
and <code>LogFn</code> methods unexpectedly
panicking when called with <code>PanicLevel</code>. Use the
corresponding <code>Panic</code>
methods when panic behavior is desired.</li>
<li>Improve concurrency safety around formatter and hook access.</li>
</ul>
<p>Features:</p>
<ul>
<li>Add <code>slog</code> hook for forwarding Logrus entries to
<code>log/slog</code>.</li>
<li>Add <code>slog.Handler</code> for forwarding <code>log/slog</code>
records to a Logrus logger,
including levels, fields, groups, context, time, and optional caller
reporting. The hook and handler can also be combined to help migrate
between Logrus and <code>log/slog</code>.</li>
<li>Add minimal, composable logging interfaces for each log level. This
enables
consumers to depend on narrower interfaces, making it easier to
substitute
or adapt logging implementations.</li>
<li>Allow <code>Entry.Caller</code> to be set explicitly and preserve it
across derived
entries, enabling custom caller detection without Logrus overwriting
caller information when <code>ReportCaller</code> is enabled.</li>
</ul>
<p>Changed:</p>
<ul>
<li>Raise minimum supported Go version to 1.23.</li>
<li>TextFormatter now renders <code>[]byte</code> values as raw/quoted
strings instead of slice-of-ints.</li>
<li>TextFormatter now uses distinct dimmed colors for debug and trace
output.</li>
<li>TextFormatter now automatically enables colors on Windows terminals
with ANSI support,
matching the behavior on other platforms.</li>
<li><code>Entry.HasCaller</code> is now deprecated in favor of checking
<code>Entry.Caller</code> directly.</li>
<li>Deprecated <code>MutexWrap</code>, which was unintentionally exposed
as public API.
It remains available as an alias for compatibility but should not be
used</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="6d6a132bc0"><code>6d6a132</code></a>
Merge pull request <a
href="https://redirect.github.com/sirupsen/logrus/issues/1586">#1586</a>
from thaJeztah/prepare_v1.10.2</li>
<li><a
href="4f9465318c"><code>4f94653</code></a>
update changelog for v1.10.2</li>
<li><a
href="87434bb3a7"><code>87434bb</code></a>
Merge pull request <a
href="https://redirect.github.com/sirupsen/logrus/issues/1585">#1585</a>
from thaJeztah/bump_testify</li>
<li><a
href="e7d2120300"><code>e7d2120</code></a>
chore(deps): bump github.com/stretchr/testify v1.12.1</li>
<li><a
href="8b673a9eb3"><code>8b673a9</code></a>
Merge pull request <a
href="https://redirect.github.com/sirupsen/logrus/issues/1583">#1583</a>
from thaJeztah/release_1.10.1</li>
<li><a
href="0b920add8d"><code>0b920ad</code></a>
Merge pull request <a
href="https://redirect.github.com/sirupsen/logrus/issues/1584">#1584</a>
from thaJeztah/more_coverage</li>
<li><a
href="5e20694a7a"><code>5e20694</code></a>
TextFormatter: cover nil pointer method receivers</li>
<li><a
href="83127326e4"><code>8312732</code></a>
update changelog for v1.10.1</li>
<li><a
href="e987a4036b"><code>e987a40</code></a>
Merge pull request <a
href="https://redirect.github.com/sirupsen/logrus/issues/1582">#1582</a>
from thaJeztah/panic_handler</li>
<li><a
href="17d574b11e"><code>17d574b</code></a>
TextFormatter: recover panics from Error and String methods</li>
<li>Additional commits viewable in <a
href="https://github.com/sirupsen/logrus/compare/v1.9.4...v1.10.2">compare
view</a></li>
</ul>
</details>
<br />
2026-09-05 16:38:57 +02:00
dependabot[bot] 9182ac9ebc
Bump github.com/sirupsen/logrus from 1.9.4 to 1.10.2
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.9.4 to 1.10.2.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.9.4...v1.10.2)

---
updated-dependencies:
- dependency-name: github.com/sirupsen/logrus
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-05 14:21:01 +00:00
Stefan Haller 9aa2c0b85a
Bump github.com/gdamore/tcell/v3 from 3.4.1 to 3.4.2 (#5970)
Bumps [github.com/gdamore/tcell/v3](https://github.com/gdamore/tcell)
from 3.4.1 to 3.4.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gdamore/tcell/releases">github.com/gdamore/tcell/v3's
releases</a>.</em></p>
<blockquote>
<h2>Version 3.4.2 Bug Fix Release</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): bump actions/setup-go from 6 to 7 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1141">gdamore/tcell#1141</a></li>
<li>perf: skip grapheme iterator for printable ASCII by <a
href="https://github.com/ayn2op"><code>@​ayn2op</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1145">gdamore/tcell#1145</a></li>
<li>chore(deps): bump github.com/lucasb-eyer/go-colorful from 1.4.0 to
1.4.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1153">gdamore/tcell#1153</a></li>
<li>chore(deps): bump golang.org/x/text from 0.40.0 to 0.41.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1157">gdamore/tcell#1157</a></li>
<li>fix(tscreen): guard inputLoop keyQ send against shutdown deadlock
(revives <a
href="https://redirect.github.com/gdamore/tcell/issues/673">#673</a>) by
<a href="https://github.com/gmlewis"><code>@​gmlewis</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1155">gdamore/tcell#1155</a></li>
<li>fix(st): The st terminal is very limited - disable extensions that
br… by <a href="https://github.com/gdamore"><code>@​gdamore</code></a>
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1160">gdamore/tcell#1160</a></li>
<li>fix: Fix SS3 application-keypad sequences (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1159">#1159</a>)
by <a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1161">gdamore/tcell#1161</a></li>
<li>fix(input): extend escape sequence timeouts by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1162">gdamore/tcell#1162</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/gmlewis"><code>@​gmlewis</code></a> made
their first contribution in <a
href="https://redirect.github.com/gdamore/tcell/pull/1155">gdamore/tcell#1155</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/gdamore/tcell/compare/v3.4.1...v3.4.2">https://github.com/gdamore/tcell/compare/v3.4.1...v3.4.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="c72e284716"><code>c72e284</code></a>
fix(input): extend escape sequence timeouts (<a
href="https://redirect.github.com/gdamore/tcell/issues/1162">#1162</a>)</li>
<li><a
href="36892cde4e"><code>36892cd</code></a>
test: cover modified SS3 keypad runes</li>
<li><a
href="e794ff2439"><code>e794ff2</code></a>
fix: Fix SS3 application-keypad sequences (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1159">#1159</a>)</li>
<li><a
href="331861bcbd"><code>331861b</code></a>
fix(st): The st terminal is very limited - disable extensions that break
it (...</li>
<li><a
href="22ae0b117b"><code>22ae0b1</code></a>
fix(tscreen): guard inputLoop keyQ send against shutdown deadlock</li>
<li><a
href="ec458f1327"><code>ec458f1</code></a>
chore(deps): bump golang.org/x/text from 0.40.0 to 0.41.0</li>
<li><a
href="8d07aa89c2"><code>8d07aa8</code></a>
chore(deps): bump github.com/lucasb-eyer/go-colorful from 1.4.0 to
1.4.1</li>
<li><a
href="3182f3e975"><code>3182f3e</code></a>
perf: skip grapheme iterator for printable ASCII</li>
<li><a
href="45d70ee4ab"><code>45d70ee</code></a>
chore(deps): bump actions/setup-go from 6 to 7</li>
<li>See full diff in <a
href="https://github.com/gdamore/tcell/compare/v3.4.1...v3.4.2">compare
view</a></li>
</ul>
</details>
<br />
2026-09-05 16:19:12 +02:00
dependabot[bot] 9d128e8503
Bump github.com/gdamore/tcell/v3 from 3.4.1 to 3.4.2
Bumps [github.com/gdamore/tcell/v3](https://github.com/gdamore/tcell) from 3.4.1 to 3.4.2.
- [Release notes](https://github.com/gdamore/tcell/releases)
- [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md)
- [Commits](https://github.com/gdamore/tcell/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: github.com/gdamore/tcell/v3
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-05 14:06:21 +00:00
Stefan Haller 3c95d635cb
Bump github.com/stretchr/testify from 1.11.1 to 1.12.1 (#5968)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify)
from 1.11.1 to 1.12.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/stretchr/testify/releases">github.com/stretchr/testify's
releases</a>.</em></p>
<blockquote>
<h2>v1.12.1</h2>
<p>This is the first release which has the minimum dependencies
practical in testify v1. The last remaining dependencies are
github.com/stretchr/objx which itself has no dependencies, and
go.yaml.in/yaml/v3. Removing objx would require v2, it cannot be
vendored. Removing YAML would require vendoring the yaml library, which
would do more harm than good. It's better to become aware of
vulnerabilities in the official yaml package than to attempt to maintain
our own.</p>
<h2>What's Changed</h2>
<ul>
<li>Change yaml library to <code>go.yaml.in/yaml/v3</code> by <a
href="https://github.com/harryzcy"><code>@​harryzcy</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1935">stretchr/testify#1935</a></li>
<li>change yaml library to go.yaml.in/yaml/v3 by <a
href="https://github.com/boekkooi-impossiblecloud"><code>@​boekkooi-impossiblecloud</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1772">stretchr/testify#1772</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/harryzcy"><code>@​harryzcy</code></a>
made their first contribution in <a
href="https://redirect.github.com/stretchr/testify/pull/1935">stretchr/testify#1935</a></li>
<li><a
href="https://github.com/boekkooi-impossiblecloud"><code>@​boekkooi-impossiblecloud</code></a>
made their first contribution in <a
href="https://redirect.github.com/stretchr/testify/pull/1772">stretchr/testify#1772</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1">https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1</a></p>
<h2>What's Changed</h2>
<ul>
<li>Change yaml library to <code>go.yaml.in/yaml/v3</code> by <a
href="https://github.com/harryzcy"><code>@​harryzcy</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1935">stretchr/testify#1935</a></li>
<li>change yaml library to go.yaml.in/yaml/v3 by <a
href="https://github.com/boekkooi-impossiblecloud"><code>@​boekkooi-impossiblecloud</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1772">stretchr/testify#1772</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/harryzcy"><code>@​harryzcy</code></a>
made their first contribution in <a
href="https://redirect.github.com/stretchr/testify/pull/1935">stretchr/testify#1935</a></li>
<li><a
href="https://github.com/boekkooi-impossiblecloud"><code>@​boekkooi-impossiblecloud</code></a>
made their first contribution in <a
href="https://redirect.github.com/stretchr/testify/pull/1772">stretchr/testify#1772</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1">https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1</a></p>
<h2>v1.12.0</h2>
<h2>What's Changed</h2>
<h3>Functional Changes</h3>
<ul>
<li>assert: make *AssertionFunc types just aliases by <a
href="https://github.com/dolmen"><code>@​dolmen</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1563">stretchr/testify#1563</a></li>
</ul>
<h3>Fixes</h3>
<ul>
<li>mock: avoid panic when expected type is nil in Arguments.Diff by <a
href="https://github.com/mutaiib"><code>@​mutaiib</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1775">stretchr/testify#1775</a></li>
<li>mock: revert to pre-v1.11.0 argument matching behavior for mutating
stringers by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1786">stretchr/testify#1786</a></li>
<li>suite: validate method signatures and continue execution for valid
tests by <a
href="https://github.com/vyas-git"><code>@​vyas-git</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1665">stretchr/testify#1665</a></li>
<li>assert.PanicsWithError: report error message by <a
href="https://github.com/olivergondza"><code>@​olivergondza</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1400">stretchr/testify#1400</a></li>
<li>assert: IsIncreasing et al can return false w/out failing by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1787">stretchr/testify#1787</a></li>
<li>add type to error message of assert.Same by <a
href="https://github.com/egawata"><code>@​egawata</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1792">stretchr/testify#1792</a></li>
<li>mock.AssertExpectationsForObjects fix panic with wrong testObject
type. by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1795">stretchr/testify#1795</a></li>
<li>assert: truncate very long objects in test failure messages by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1646">stretchr/testify#1646</a></li>
<li>assert: fix NotSubset error messages using %#v instead of %q (fixes
<a
href="https://redirect.github.com/stretchr/testify/issues/1800">#1800</a>)
by <a href="https://github.com/nghiack7"><code>@​nghiack7</code></a> in
<a
href="https://redirect.github.com/stretchr/testify/pull/1888">stretchr/testify#1888</a></li>
<li>suite: prevent panic when SetupTest skips with HandleStats by <a
href="https://github.com/blackwell-systems"><code>@​blackwell-systems</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1877">stretchr/testify#1877</a></li>
</ul>
<h3>Documentation, Build &amp; CI</h3>
<ul>
<li>CI: test also with Go 1.23 by <a
href="https://github.com/dolmen"><code>@​dolmen</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1783">stretchr/testify#1783</a></li>
<li>Vendor unmaintained github.com/pmezard/go-difflib by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1708">stretchr/testify#1708</a></li>
<li>Promote ccoVeille to maintainer by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1784">stretchr/testify#1784</a></li>
<li>build(deps): bump actions/setup-go from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/stretchr/testify/pull/1790">stretchr/testify#1790</a></li>
<li>assert.YAMLEq: Document mutlidoc behavior by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1791">stretchr/testify#1791</a></li>
<li>_codegen: copy dependency github.com/ernesto-jimenez/gogen/imports
by <a href="https://github.com/dolmen"><code>@​dolmen</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1782">stretchr/testify#1782</a></li>
<li>doc: remove ineffective inline code blocks by <a
href="https://github.com/brackendawson"><code>@​brackendawson</code></a>
in <a
href="https://redirect.github.com/stretchr/testify/pull/1714">stretchr/testify#1714</a></li>
<li>Tag generated assertions as non-generated in new .gitattributes by
<a href="https://github.com/ubunatic"><code>@​ubunatic</code></a> in <a
href="https://redirect.github.com/stretchr/testify/pull/1815">stretchr/testify#1815</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="959dbdacf1"><code>959dbda</code></a>
Merge pull request <a
href="https://redirect.github.com/stretchr/testify/issues/1935">#1935</a>
from harryzcy/yaml-update</li>
<li><a
href="9bb71766fa"><code>9bb7176</code></a>
Update go.yaml.in/yaml/v3 to v3.0.5</li>
<li><a
href="001eb7946b"><code>001eb79</code></a>
Merge pull request <a
href="https://redirect.github.com/stretchr/testify/issues/1905">#1905</a>
from Kentzo/patch-1</li>
<li><a
href="ad40f384b1"><code>ad40f38</code></a>
Merge pull request <a
href="https://redirect.github.com/stretchr/testify/issues/1906">#1906</a>
from stretchr/dependabot/github_actions/actions/chec...</li>
<li><a
href="3bae01746b"><code>3bae017</code></a>
build(deps): bump actions/checkout from 6.0.2 to 6.0.3</li>
<li><a
href="f8c01f33a3"><code>f8c01f3</code></a>
mock: Mock.Return does not exist anymore</li>
<li><a
href="12f8b5612e"><code>12f8b56</code></a>
Merge pull request <a
href="https://redirect.github.com/stretchr/testify/issues/1563">#1563</a>
from stretchr/make-AssertionFunc-types-aliases</li>
<li><a
href="a11649e427"><code>a11649e</code></a>
assert: make *AssertionFunc type just aliases</li>
<li><a
href="dc20f41986"><code>dc20f41</code></a>
Merge pull request <a
href="https://redirect.github.com/stretchr/testify/issues/1890">#1890</a>
from stretchr/dolmen/codegen-modernize</li>
<li><a
href="098f8d75b3"><code>098f8d7</code></a>
_codegen: use strings.Builder</li>
<li>Additional commits viewable in <a
href="https://github.com/stretchr/testify/compare/v1.11.1...v1.12.1">compare
view</a></li>
</ul>
</details>
<br />
2026-09-05 16:04:41 +02:00
dependabot[bot] 910d1e49cf
Bump github.com/stretchr/testify from 1.11.1 to 1.12.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-05 14:00:38 +00:00
Stefan Haller 91970a1202
Bump github.com/xo/terminfo from 0.0.0-20220910002029-abceb7e1c41e to 1.0.0 (#5950)
Bumps [github.com/xo/terminfo](https://github.com/xo/terminfo) from
0.0.0-20220910002029-abceb7e1c41e to 1.0.0.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/xo/terminfo/commits/v1.0.0">compare
view</a></li>
</ul>
</details>
<br />
2026-09-05 15:58:42 +02:00
dependabot[bot] 730b590ce6
Bump github.com/xo/terminfo
Bumps [github.com/xo/terminfo](https://github.com/xo/terminfo) from 0.0.0-20220910002029-abceb7e1c41e to 1.0.0.
- [Commits](https://github.com/xo/terminfo/commits/v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/xo/terminfo
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-05 13:55:33 +00:00
Stefan Haller 9673a627ca
Re-render the focused main view if its content changes while it is being searched (#5993)
When searching the focused main view using `/`, any updates to its
content were ignored because back when we introduced the focused main
view feature we couldn't make it work; search mode couldn't cope well
with the view content changing under it.

In this PR we make that work, and remove the limitation. Along the way
we fix a bunch of other related problems; some are only theoretical race
conditions that have been found by reading the code, but never observed
in reality; some are real problems that are too edge-casey to describe
in detail. See the individual commit messages for details.
2026-09-05 15:50:28 +02:00
Stefan Haller 114d3a5a25 Render the focused main view again while it is being searched
A refresh left the focused main view alone while a search was on, so the
diff on screen stayed as it was however much the working tree had moved
on underneath it. The search could not cope with the content changing
under it, and leaving the content alone was the way around that.

It can cope now. The positions are worked out again from whatever the
view holds, and the status with them, so render it like any other.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller 811b3fbdd1 Show the search status of what a re-rendered view now holds
Rendering a view's content again while a search is on leaves the "x of y"
describing the content that has just been replaced. The status is worked
out when the search is typed and again when a key steps through the
matches, and a render is neither. Change the diff context size while
searching the focused main view, and the count stays as it was, however
many matches the wider context brought in or took away.

Run the search again over the new content once the render has finished
putting it there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller 04a7ae3ec8 Read a view that is being searched to the end when it renders again
Opening the search prompt reads the whole of the view's content, so that
the search counts every match in it. Rendering the content again reads
only as much as the scrollbar needs, so the matches below that point are
lost. The "x of y" drops to what the shortened content holds, and grows
again as the user scrolls far enough to load more.

Read to the end while a search is on, the way opening the prompt does.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller 0505e778b3 Work the search positions out when they are read, not on each line written
A view's search positions were worked out again from every write, and
each of those walks the whole view. Content arrives a line at a time, so
rendering into a searched view costs a walk per line. Streaming 2000
lines takes 565ms, where the same render into an unsearched view takes
about 10ms.

Mark the positions stale on a write instead, and work them out where they
are read: when the view is drawn, when a key steps through the matches,
when the status is asked for. That is at most once a frame, and the same
2000 lines now take 8ms.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller 4c90bc334c Bring the current search match back into range when the matches change
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller b54318e80c Add a test for the current search match after the matches change
Search a view, step to the last match, then have the view re-rendered
with fewer matches in it, and the status reads "3 of 1". The positions
are worked out again whenever the content changes, but the index into
them stays where it was. Stepping on from there indexes the positions
out of range and panics.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller acb6e48a98 Hold a task while a view is read to its end
ReadToEnd reads the rest of a view's content on the render task's own
goroutine, and calls back once it has. Nothing held a task for that, so
lazygit counted as idle from the moment the caller returned until the
callback ran. The search prompt in the focused main view opens from such
a callback, so an integration test takes the idle report as its cue to
carry on, and presses its next key while the prompt is not open yet.

Hold the task in ReadToEnd rather than in the caller, so that every
caller is covered (see docs/dev/Busy.md).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller dd2a1a634f Answer every read request, whether or not a task is still serving
A caller of ReadLines or ReadToEnd is told that the content it asked for
has been read by the request's Then being called. A task that reaches the
end of its input answers the requests still queued behind the one it was
serving, but a task that is stopped drops them, and their callers wait
for a callback that never comes. Pressing "/" in the focused main view
opens the search prompt from such a callback, so if a re-render replaces
the task at that moment the prompt never opens.

Answering them as the read loop ends would leave a request handed over
after that point unanswered, and there is a window for one. A caller
reads the channel to send on, and can reach the send itself only once the
loop has gone. So hand requests over through a queue instead. Asking
whether a task is there and giving it the request are one step, as are
taking the task away and handing back what it never answered; a request
made in between goes back to the caller to answer.

The queue is unbounded rather than a fixed-size channel, for the reasons
gocui's userEventQueue is. Requests are handed over from the UI thread,
where a blocking send would deadlock against the task waiting to be let
go, and a fixed channel that fills up leaves only blocking, dropping,
reordering or panicking to choose between.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller e148959865 Add a test for read requests waiting when a task is stopped
Ask a view buffer manager to read to the end of its content twice over,
then stop the task before it has served either request, as a re-render
replacing it does. Only the request it had already picked up is answered;
the one still queued behind it is dropped, and its caller waits for a
callback that never comes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:09:52 +02:00
Stefan Haller ef3a4c71cc Addition to AGENTS.md 2026-09-05 15:09:52 +02:00
Stefan Haller e0b2a5081d
Fix some obscure selection highlighting bugs (#5990)
Some checks failed
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Has been cancelled
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Has been cancelled
Continuous Integration / build (push) Has been cancelled
Continuous Integration / check-codebase (push) Has been cancelled
Continuous Integration / lint (push) Has been cancelled
Continuous Integration / check-for-fixups (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Continuous Integration / upload-coverage (push) Has been cancelled
2026-09-03 22:57:21 +02:00
Stefan Haller d2dc38ee87 Drop the highlight fixups the context stack now makes unnecessary
Two places nudged the flags because nothing else would: switching repos,
where the view focused in the repo being left is not the one focused in the
repo being entered, and tabbing from the suggestions list back to the
prompt, which replaces the top of the stack rather than popping it, so the
suggestions context never hears that it lost the focus. Both are just a
context leaving the stack now.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller ec3f681ebf Derive the selection highlight from the context stack
A view drew a selection because something told it to, from four places on
three different schedules: a context being focused, a context losing focus,
a context being activated over another one, and a list being re-rendered.
Whether the flags ended up describing the state of the app depended on
which of those had run last, and the last one to run was often none of
them: a refresh only re-focuses the view that has the focus, so a list
whose contents changed underneath an unfocused panel kept whichever
highlight it happened to have.

Derive both flags instead, in one place, from the two things they mean: a
view shows a selection while its context is on the stack and has something
to select, and the context the user is in shows an active one where the
ones behind it show inactive ones. Nothing else needs to say anything about
highlighting, so nothing else can leave a view saying something untrue
about where the focus is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller d1707d5dd2 Re-render the main view without re-focusing the panel beneath it
Toggling whitespace needs the panel beneath to render its diff again, which
is what HandleRenderToMain is for; HandleFocus does that and also everything
else that belongs to a panel gaining the focus, which this panel already has
or, when the focus is in the main view, does not want. Re-selecting its
current item is harmless, but re-deriving its highlight as a focused panel's
is not: the selection turns bright while the user is somewhere else.

Changing the context size and switching diff renderers already ask for a
re-render this way.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller e8009599e0 Demonstrate that toggling whitespace undims the panel beneath the main view
Toggling whitespace re-focuses the side panel to re-render the diff, which
also re-derives that panel's highlight — as though the panel had the focus,
which it doesn't.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller dd01d67879 Demonstrate that an unfocused list's selection goes stale
A refresh only re-derives the highlight of the view that has the focus, so
a list whose contents change while the user is somewhere else keeps the
selection it had: none for a list that just got its first item, and one
over nothing for a list that just lost its last.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller 4b391acec0 Ask each context whether it has content to select
Whether a view draws a selection is about to be derived in one place from
the context stack, which needs to ask any context — list or not — whether
there is something for a selection to sit on. Name the existing flag after
that question, and let a list context answer it from its length.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller eb760ee928 Cover how a view's selection follows the focus in and out of a context
Nothing said that a context leaving the stack takes its selection with it,
which the work coming up is about to make the rule for every view. Two
places already depend on it and are held together by hand: switching repos,
where the view focused in the repo being left is not the one focused in the
repo being entered, and tabbing from the suggestions list back to the
prompt, which replaces the top of the stack rather than popping it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller 1b2bd85fc5 Let a test assert how a view draws its selection
The selected line of a view says nothing about whether a selection is drawn
over it, or which of the two ways it is drawn in, and those are what the
tests coming up are about.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:19:04 +02:00
Stefan Haller e7de68331a
Validate the context names in the "context" field of custom commands (#5989)
If a custom command's "context" field contains a context name that
doesn't exist, lazygit panics when building the keybindings. This could
happen either because of a typo, or because a context is removed or
renamed in a later version. Prevent the panic by validating those names
at config load time, and rejecting the config as invalid there, like we
do for other config errors.
2026-09-03 21:18:48 +02:00
Stefan Haller e0927d4faf Validate the context names in the "context" field of custom commands
If a custom command's "context" field contains a context name that
doesn't exist, lazygit panics when building the keybindings. This could
happen either because of a typo, or because a context is removed or
renamed in a later version. Prevent the panic by validating those names
at config load time, and rejecting the config as invalid there, like we
do for other config errors.

The gui package owns the list, but can't be imported from here, so it is
mirrored and a test over there ensures the copies stay in sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:07:32 +02:00
Stefan Haller 3f6be3b3ee
Allow filtering the keybindings and recent repos menus more directly (simply by typing) (#5985)
Some checks failed
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Has been cancelled
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Has been cancelled
Continuous Integration / build (push) Has been cancelled
Continuous Integration / check-codebase (push) Has been cancelled
Continuous Integration / lint (push) Has been cancelled
Continuous Integration / check-for-fixups (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Continuous Integration / upload-coverage (push) Has been cancelled
All menus in lazygit can be filtered by pressing the `/` key; for most
menus which only show a handful of choices this is not really needed,
but with the two cases where it's useful, it was unnecessarily
inconvenient: you first have to press `/` to open the filter prompt, and
then press enter to confirm the filter before you could press enter
again to trigger the chosen item. It's much easier to simply type to
filter, and still use the arrow keys to select one of the filtered
items, or press enter to trigger it while the filter prompt is showing.

The consequence of this is that while the keybindings menu is open you
can no longer use the displayed key bindings to trigger the commands; I
think that's fine, that menu is more for looking up those keybindings
rather than for using them from within the menu.

Also: since `j`/`k` are bound to move the list selection by default, it
is not possible to filter for something that begins with `j`/`k`. I
didn't want to change this because I'm concerned that die-hard vim users
would perceive it as a regression if they can no longer type `j` to
select the next menu item. The workaround is to type some other letter
and backspace; this keeps the filter prompt open, so you can now type
`j` or `k`.
2026-08-31 21:25:23 +02:00
Stefan Haller 90f5348371 Add a hint about filtering menus to the docs 2026-08-31 20:59:15 +02:00
Stefan Haller 4f78a5576a Reword stale comment about filtering not being available in the files view
This was implemented quite a while ago.
2026-08-31 20:59:15 +02:00
Stefan Haller 8aa57264fa Point the note about the menu's essential keys at what it means
There is no `reservedKeys` any more; the list of keys that menu items must
not shadow is `essentialKeys` in the function that creates the menu.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 4116a15dae Filter the recent repositories menu as you type
Picking a repository out of that list is the other place where the menu is
a list to search rather than a set of commands, and its items have no keys
that typing could clash with.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller bb7e74968b Drop the menu-specific wording for the filter prompt
The only menu that ever asked for it was the keybindings menu, which now
filters as you type and doesn't use the prompt at all. That leaves every
filterable context with the same prompt, so the whole hook can go, and
with it the two implementations that only existed to satisfy it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller fb761892aa Filter the keybindings menu as you type
Looking up a keybinding is a search, so the menu that lists them is the
one that most wants this. Its items do have keys, but only as a reminder
of what they do outside the menu, so nothing is lost by not binding them.

The prompt in front of the input field says what '@' does. It only ever
showed up while the user was typing in the search prompt, so it could
afford to be wordy; on a row that is on screen for as long as the menu
is, it can't.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 68355862c1 Add test helpers for a menu's filter row
Its footer, the hint in the menu's subtitle, where the row sits in
relation to the menu and the tooltip, and whether the text cursor is
showing are all things the tests for it need to look at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 2048c7f0a6 Keep a filtering menu navigable whatever the keybindings are
The keys for paging through a menu are ',' and '.' by default, and there
is no non-printable alternative for them, so a menu that filters as you
type would lose paging altogether as soon as the user typed anything. The
same goes for confirming and cancelling if those keys are configured as
printable ones.

So bind the physical keys for all of it, on top of whatever is configured,
and only where they aren't the configured keys anyway.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller fe9b990c4c Filter a menu by typing into it
The filter input is where the keyboard points for as long as such a menu
is open, so that the first printable key can go straight into it. The menu
still gets every key the input doesn't take, because the input view is
embedded in the menu view, and the two are drawn as one focused panel.

Which keys the input takes changes once there is a filter: until then
printable keys still drive the menu, so that the configured navigation
keys work as usual, and afterwards they are all filter text. A menu item's
own keys are never bound in such a menu, because typing one has to reach
the filter rather than execute the item.

Escape gives up the filter and leaves the menu open; the next one closes
it. The filter prompt behind '/' is gone from these menus: the row already
does that job, and a second filter would only be confusing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 1fb5f87d05 Lay out the filter row of a menu that filters as you type
The row is reserved for as long as such a menu is open, even while it is
still hidden, so that it can appear without moving the menu. That costs
two rows of the popup, which is why the screen has to be a little taller
before a menu is worth showing at all.

The prompt in front of the input field is dropped when the row gets too
narrow to type in, and the keybindings menu says what '@' does when it
still fits.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller c80035d7ee Add the views for a menu's filter row
Nothing shows or positions them yet. The row is two views because the
input field has to start after the "Filter:" prompt, and a gocui view is
a rectangle: the frame view draws the row and the prompt, the field sits
inside it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller f1b0347111 Allow a list to render its footer elsewhere
The footer is drawn on the bottom border of the list's view, which is not
always a free row: a panel that puts something else below the list shares
that border with it, and has to render the footer there instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller a9fe055af4 Extract applying a filter to a context
A filter can come from somewhere other than the search prompt: a menu
that filters as you type has its own input field, and needs to apply what
is typed there without going through the prompt's state.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 45d68bccc2 Treat the views of a popup panel as a group when clicking
The check was a single set of view names, so it also let a click move
between two different panels, e.g. from the prompt to the commit message.
List the panels instead, and require both views to be in the same one.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller f9ec7adb61 Draw embedded views as one focused unit
A view can only be drawn with the focused frame and title colors while it
is the current view, but a panel made of an outer view and an editable
field embedded in it has to look focused as a whole, whichever of the two
the keyboard is pointed at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 9e98b3d2f3 Let an editable view opt into receiving printable keys as keybindings
Printable keys are withheld from keybindings while the user is typing in
a field, so that they end up as text. Decide that from the field that has
the focus rather than from the view a binding happens to be registered
for: a field can be embedded in another view, and that view's keys must
be withheld too, or its bindings would swallow the characters.

That makes it worth honouring KeybindOnEdit, which has been documented
but ignored ever since it was introduced. A field that sets it sees
printable keys offered to the keybindings first, and still gets them if
no binding handles them, which is what lets a view keep its keys until
the field has something to type into.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller 28c5f5748c Give a parent view's keybindings the same precedence as a view's own
When a key matches several bindings of the same view, the first one wins;
when it matches several of the view's parent, the last one did.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller b2a684bec1 Add a helper for recognizing printable keys
Two places test for "a character the user typed" by hand, and a third
one is about to be needed. Give the test a name.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:41:22 +02:00
Stefan Haller c840013ca3 Remove error return value from functions that always return nil
Originally I thought we'd benefit from this change in this branch; turns
out that we didn't after all, because we changed the approach, but it's
a nice cleanup anyway, so we include it here.
2026-08-31 20:41:22 +02:00
Stefan Haller 8924bca76f
Delete cpu.out file that was committed accidentally (#5983)
Some checks failed
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Has been cancelled
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Has been cancelled
Continuous Integration / build (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Has been cancelled
Continuous Integration / check-codebase (push) Has been cancelled
Continuous Integration / lint (push) Has been cancelled
Continuous Integration / check-for-fixups (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Continuous Integration / upload-coverage (push) Has been cancelled
2026-08-30 09:44:18 +02:00
Stefan Haller 07fa0ef732 Delete cpu.out file that was committed accidentally 2026-08-30 09:41:15 +02:00
Stefan Haller a8b762d03b
Improve performance of moving rebase todos (#5978)
Some checks are pending
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Waiting to run
Continuous Integration / build (push) Waiting to run
Continuous Integration / check-codebase (push) Waiting to run
Continuous Integration / lint (push) Waiting to run
Continuous Integration / upload-coverage (push) Blocked by required conditions
Continuous Integration / check-for-fixups (push) Waiting to run
Codespell / Check for spelling errors (push) Waiting to run
Generate Sponsors README / deploy (push) Waiting to run
In larger repos, moving a rebase todo in an interactive rebase was
slower than necessary, especially when moving it up/down all the way
using auto-repeat.
2026-08-29 17:42:27 +02:00
Stefan Haller eddbbdad23
Avoid rehydrating unchanged rebase todos
Narrow rebase refreshes already have complete metadata for existing
todos. Reuse it so repeated todo moves do not spawn git show for the
entire list. New hashes still fall back to hydration.
2026-08-29 17:25:59 +02:00
Stefan Haller 3914755c98
Fix visual glitches when moving a rebase todo
This fixes two problems:
1. the list selection updated before the list content did, which caused
a bit of a wobble effect in the list
2. the main view would first update to a different commit's diff and
then back to the one that is being moved, resulting in a very ugly
flicker especially when moving the todo multiple times with auto repeat
2026-08-29 17:25:59 +02:00
Stefan Haller d37f901ac1
Remove PostRefreshUpdateKeepingScrollPosition
Now that we have PostRefreshUpdateWithOptions there's no reason to offer
a bespoke method for setting one particular option.
2026-08-29 17:25:59 +02:00
Stefan Haller fa531dc518
Add PostRefreshUpdateWithOptions 2026-08-29 17:25:59 +02:00
Stefan Haller fd1b229f93
Cleanup: remove CommitSelection option when refreshing rebase todos
This option has no effect, it isn't respected by refreshRebaseCommits,
so it looks misleading to include it here.
2026-08-29 17:25:59 +02:00
Stefan Haller b7ffa93dc6
Allow filtering worktrees by branch name in Worktrees pane (#5980)
Closes #5945.
2026-08-29 17:25:30 +02:00
Stefan Haller 530d773052 Allow filtering worktrees by branch name in Worktrees pane
Co-authored-by: phanirithvij <phanirithvij2000@gmail.com>
2026-08-29 17:02:44 +02:00
Stefan Haller c3027caf6b Cleanup: use lowercase for function parameters 2026-08-29 17:02:07 +02:00
Stefan Haller 655f913f44 Add test for filtering worktrees 2026-08-29 17:02:03 +02:00
Stefan Haller c300c319f9
Fix broken commit auto-scrolling (#5972)
Some checks are pending
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Waiting to run
Continuous Integration / check-codebase (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Waiting to run
Continuous Integration / build (push) Waiting to run
Continuous Integration / lint (push) Waiting to run
Continuous Integration / upload-coverage (push) Blocked by required conditions
Continuous Integration / check-for-fixups (push) Waiting to run
Codespell / Check for spelling errors (push) Waiting to run
Generate Sponsors README / deploy (push) Waiting to run
Fix a bug introduced in #5928: dragging a commit with auto-scrolling so
that the original commit leaves the viewport, and then dragging back
into the view would snap the original commit back into view.

Labeled as ignore-for-release because it's a regression in a PR that
wasn't released yet.
2026-08-27 09:55:00 +02:00
Stefan Haller 35753fd042 Fix scrolling back when dragging a commit
During auto-scrolling, turn off the automatic
scroll-to-make-the-selected-item-visible functionality of
PostRefreshUpdate.
2026-08-27 08:22:55 +02:00
Stefan Haller c8d610cb58 Demonstrate commit drag scroll reset on re-entry
When dragging a commit with auto-scrolling so that the original commit
leaves the viewport, dragging back into the view makes the original
commit snap back into view. This is a regression that was introduced by
aebf495dce.
2026-08-27 08:22:55 +02:00
Stefan Haller c09b682f63 Add some comments to the drag_to_reorder_with_autoscroll test 2026-08-27 08:22:55 +02:00
Stefan Haller 6a932f3057 Read mouse pointer geometry on the UI thread in tests
Drag autoscrolling deliberately keeps rendering after the test action
has returned, so view bounds can change while the test goroutine
prepares its next mouse event. Snapshot the geometry on the event loop
before translating view-relative coordinates to avoid data races. This
hasn't been a problem so far, but only because we were lucky; the added
test assertions later in this branch would cause consistent race
detector failures without this fix.
2026-08-27 08:22:55 +02:00
Stefan Haller ea91639546
Show renames when selecting a directory that a file was moved into or out of (#5924)
In a commit that moves a bunch of files from one directory to another,
showing the commit's files and selecting the target directory of those
moves would show these files as newly added rather than moved in the
main view's diff. Selecting the source directory would show them as
removed. Fix this to keep showing them as moved in both cases. The same
applies to the files panel when staging the move of a file, and when
filtering the file list down to just the source or target directory
using the `/` filter in either panel.

The decision to show them as renames when selecting the "moved-from"
directory was not an easy one; it's slightly weird because the list of
files in the side panel doesn't show them there (they appear in the
target directory), but the main view does. An alternative would have
been not to show them in that case, to match the side panel. However,
the point of selecting a directory is to see all the changes that affect
it, and the moved-out files are relevant changes you want to see there.

See
https://github.com/jesseduffield/lazygit/discussions/4899#discussioncomment-17976172.
2026-08-18 09:47:38 +02:00
Stefan Haller d7401559f0 Collapse the paths of a moved directory into the directory itself
A commit that moves an entire package elsewhere renames hundreds of
files, and passing every one of their old paths can push the command past
the length limit the OS imposes (~32k characters on Windows). Their
common parent directory does just as well whenever everything it holds
ends up in the diff anyway.

Deciding that needs to consider every file of the diff, not only those on
display, so the paths are now derived from the model rather than from the
tree; a status filter must not make a directory look emptier than it is.
2026-08-17 09:32:30 +02:00
Stefan Haller bee03d3b98 Show renames when diffing a directory that a file was moved into or out of
Git limits its tree diff by the pathspec before it looks for renames, so
a directory only ever gets one end of a rename whose other end is outside
it. Nothing is left to pair up, and the file turns into an addition or a
deletion that the commit doesn't contain.

Pass the other end along with the directory. This is bounded by the
number of renames that cross the directory's boundary, so it costs
nothing at all for the vast majority of commits.
2026-08-17 09:32:30 +02:00
Stefan Haller 40cb4bb24d Extract a single helper for the paths a node's diff is limited to
The files and commit files panels each had their own copy of this, one of
which used to be missing the previous path of a rename. Growing them
apart again is the last thing we want, since the next commit needs to
teach both of them about renames that cross a directory boundary.

The files panel version only returned paths for the filtered case, and
left it to WorktreeFileDiffCmdObj to derive the rest from the node; now
that all callers pass the paths in, that command doesn't need to know
about renames at all.
2026-08-17 09:32:30 +02:00
Stefan Haller 2c9187acb9 Pass the previous path when diffing a filtered directory in the files panel
Restricting the diff to the files that a filter leaves visible drops the
delete-side entry of a staged rename, so git shows the file as an
addition instead. Its commit files counterpart already passes both paths;
this brings the files panel in line.
2026-08-17 09:32:30 +02:00
Stefan Haller 6913f2afce Add tests for diffing a directory that files were renamed into or out of
Pathspec limiting happens before rename detection in git's tree diff, so
filtering the diff to a directory hides the delete-side entry of a rename
whose other end is outside that directory. Git then has nothing to pair
up, and reports a file moved into the directory as an addition and one
moved out of it as a deletion.

Selecting a directory is supposed to filter the commit's diff down, never
to change it, so both are wrong.
2026-08-17 09:32:30 +02:00
Stefan Haller c199ac69f5
Keep showing files whose conflicts have been resolved (#5940)
When several files have conflicts, resolving one of them makes it vanish
from the files panel as soon as it is auto-staged, and it only comes
back once the last conflict is resolved and the filter turns off again.
By then it sits among all the other changed files of the merge, so it is
hard to find the ones whose resulting diff you still wanted to check.

So remember which files had conflicts while the conflicted-files filter
is on, and keep showing them once they are resolved. This is the general
solution that #5936 called for; that PR only helped for the case of a
single conflicted file.

The consequence is that the selection no longer moves on to the next
conflicted file when one is resolved: it stays on the file you just
resolved, which shows you its diff right away.
2026-08-16 17:09:25 +02:00
Stefan Haller 43b47d16dd Keep showing files whose conflicts have been resolved
When several files have conflicts, resolving one of them makes it vanish
from the files panel as soon as it is auto-staged, and it only comes back
once the last conflict is resolved and the filter turns off again. By
then it sits among all the other changed files of the merge, so it is
hard to find the ones whose resulting diff you still wanted to check.

So remember which files had conflicts while the conflicted-files filter
is on, and keep showing them once they are resolved. This is the general
solution that 39513d244d called for; that commit only helped for the
case of a single conflicted file.

The consequence is that the selection no longer moves on to the next
conflicted file when one is resolved: it stays on the file you just
resolved, which shows you its diff right away.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:45:50 +02:00
Stefan Haller c10bc3b697 Collect the paths of the files with conflicts, rather than only counting them
The next commit needs to know which files have conflicts, not just how
many of them there are.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:45:50 +02:00
Stefan Haller adf59703ac AGENTS.md additions 2026-08-16 16:45:50 +02:00
Stefan Haller 431993b606
Bump golangci-lint to v2.12.2 (#5941) 2026-08-16 16:45:04 +02:00
Stefan Haller 37c53ac1bf Bump golangci-lint to 2.12.2 2026-08-16 16:35:11 +02:00
Stefan Haller 486d8536f2 Preallocate arrays where the new linter version would warn about it 2026-08-16 16:35:11 +02:00
Stefan Haller 92e50a5d9a Avoid appending to an array literal
Instead, create the one dynamic element beforehand and include it in the
literal. This avoids a preallocation warning from the linter.
2026-08-16 16:35:11 +02:00
Stefan Haller f0ccb937d3 Use lo.Map instead of manual append loops
Not only is this nicer code (and more idiomatic at least in this code
base), but it also avoids linter warnings about missing preallocations
(lo.Map does preallocate the result array).
2026-08-16 16:35:11 +02:00
Stefan Haller 1db9f9cdb8 Fix linter warning about WriteString(fmt.Sprintf(...)) 2026-08-16 16:35:11 +02:00
Stefan Haller a26899f5ab Fix use of reflect.Ptr
Apparently Ptr is a deprecated name; with the new golangci-lint version
this would cause

  inline: Constant reflect.Ptr should be inlined
2026-08-16 16:35:11 +02:00
Stefan Haller 4820241caf Remove the common-false-positives and legacy linter exception presets
I don't really know what they are for, but they don't trigger any
errors.
2026-08-16 15:44:36 +02:00
Stefan Haller 6032225472
Fix flicker, scroll glitches, and crashes in async diff rendering (#5938)
This is a preparation PR for the upcoming fold-staging-into-main-view
work; see the individual commit messages for details.

The most notable change is probably that we switch to a double-buffering
approach for flicker-free view updates; previously we would overwrite
the view from the top, and keep the existing viewlines below untouched
to update without flicker. This caused numerous problems though that
will become more painful when we start using the main view for more
operations (especially staging); telling whether the selected line still
belongs to the previous task or already to the new one is tricky.
Rendering into an offscreen buffer and swapping it in as soon as we have
enough to fill the screen makes this much easier.
2026-08-15 15:52:42 +02:00
Stefan Haller ebfa8c71b2 Drop FlushStaleCells, which no longer has anything to flush
It existed for the incremental re-render: a shorter render left the previous
one's view lines in the tail (deliberately, to avoid a blank frame), and this
cleared them once the new content was fully read. Async renders now build
off-screen and swap in whole, so refreshViewLinesIfNeeded truncates the view
lines to the buffer and no tail can form. All the call at end-of-input still
did was discard every wrapped line and force the whole buffer to be re-wrapped
on the next draw, which is pure work on a large diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 3cbf40d4ef Reset the scroll to the top at first paint, not when the task starts
When a main view re-renders content different from what it last showed, the
scroll resets to the top. That reset fired synchronously when the task started —
but with the off-screen render the previous content stays displayed until the
swap, so resetting the origin up front scrolled that still-visible content to the
top before the new content replaced it: a distracting jump when switching commits
(or any item) while scrolled down.

Defer the reset to the first paint that reveals the new content, so the previous
content stays at its scroll until the new content takes its place, and then the
new content appears at the top. Swap and reset happen in one hop on the UI
thread, so no draw can land between them and show the new content at the old
scroll. A same-content re-render keeps its scroll. The "loading..." indicator
path also resets the origin now, since it clears the previous content to show the
message and must put it at the top.

The reset moves out of NewTask into the read loop, keying off the flag that
already records whether the render's content is new. NewTask still decides,
from the same command-key comparison as before and under the same lock. It has
to be that flag rather than per-task state, because a task can be stopped and
replaced before it ever paints — a background refresh landing just after the
user clicked a different item, which is the ordering a VS Code terminal
produces, since it delivers the focus-in event (and so the refresh) before the
click. The replacement renders the same content and so sets nothing of its own,
and the click's reset would be lost with the task that owed it.

The manager's onNewKey callback is renamed resetOrigin to match its now-decoupled
timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller bf6c34798c Don't run end-of-input handling for a render that was stopped
When a task is stopped to make way for a newer one, stopping closes
opts.Stop, and the scanner goroutine then closes lineChan. The read loop's
select between those two channels is therefore non-deterministic: it can
land on the closed lineChan (ok == false) instead of the opts.Stop case,
sending a stopped task into the end-of-input branch.

There it runs the full finalize — swapping its half-read off-screen buffer
in, clamping the origin to the truncated content, and clearing the loading
flag — all of which corrupt what the incoming task is about to render. The
most visible symptom is a brief frame of truncated content with the scroll
yanked to the top, seen when re-renders overlap rapidly (e.g. the periodic
background refresh re-rendering a main view faster than it can load, very
easy to hit under LAZYGIT_SLOW_RENDER).

The underlying bug predates the off-screen render (the EOF branch always
clamped the origin via onEndOfInput), but that change made it far worse by
also swapping a truncated buffer into the display. Fix it at the source: in
the EOF branch, check whether we were stopped and, if so, bail out like the
explicit stop case, leaving the view entirely to the task that replaces us.

There's no test because the bug is the non-deterministic select itself:
any test would have to win a coin flip to observe it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 9e23111172 Render async content into an off-screen buffer and swap it in
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, clicks, the
view-line mapping) could observe a half-written buffer at the wrong
scroll.

Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically, so the view jumps
straight from the previous render to the new one with no intermediate
frame. Subsequent lines append to the now-displayed buffer.

Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.

With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so a stale
tail never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.

The swap holds writeMutex for now; it could later move to the main thread.
Flicker behaviour still needs interactive verification (LAZYGIT_SLOW_RENDER
+ a real diff renderer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 87b30d9581 Don't take the view over for "loading..." when the content isn't changing
A render that takes more than 200ms to produce its first line takes the view
over to say "loading...", which clears the buffer it was showing. That is
worth doing when the content coming is different — the view is showing
something the user has moved on from, and saying so beats leaving it there
silently. It is pure flicker when the content isn't changing: the view is
already showing exactly what the render is about to put back, and a slow
re-render of unchanged content is common (a background refresh over a repo
with submodules that have uncommitted changes, say).

So track whether the render in flight has content the view isn't already
showing, and only let the indicator take over when it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller cc5d5057a7 Make the buffer-writing methods operate on a viewBuffer
write, writeCells, makeWriteable, parseInput and
autoRenderHyperlinksInCurrentLine produced cells into v.buf; move them onto
viewBuffer so they can write into any buffer, not just the displayed one.
The display-side effects that don't belong to content production —
tainting, clearing hover, updating search positions — stay behind in the
View.write wrapper, which delegates the actual writing to v.buf.write(v).
Render config the writer needs (Editable, colors, width, tab width,
hyperlink auto-render) is read from the passed View. Behaviour-preserving:
the wrapper still always targets v.buf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 2a6cb8d78e Bundle a view's cell buffer and write state into a viewBuffer
The fields that make up a view's content and the act of writing to it —
the cell buffer (lines), the write cursor (wx/wy), the escape-sequence
decoder (ei) and the held-newline flag (pendingNewline) — were loose
fields on View. Bundle them into a viewBuffer struct that View holds by
pointer. This is a behaviour-preserving prep refactor: every access just
goes through v.buf now. It sets up rendering into a second, off-screen
viewBuffer that can be swapped in atomically, so an async re-render never
exposes a half-written buffer to readers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 114d18dc04 Reset other main views' scroll after copying content, not before
refreshMainViews reset the scroll position of every other main view at the
very top, before moveMainContextPairToTop runs its CopyContent. CopyContent
copies the previously-shown view's content into the now-visible one to avoid a
blank frame during the async re-render — but because the reset ran first, it
had already zeroed the origin of that soon-to-be-copied source view. The
placeholder therefore always appeared scrolled to the top, jumping away from
wherever the screen actually was, on every cross-pair transition.

Move the reset to after the copy. The end state is unchanged (each other main
view still ends at origin 0, and the destination always re-renders), but the
brief placeholder now stays at the source view's real scroll position until
the real content paints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller fbadbbf99a Don't scroll a view up to fill blank space while its content is loading
The layout scrolls a view up if its origin is past the bottom of its
content, to avoid showing blank space (e.g. after a resize). But it measures
content height by the lines loaded so far, and command/pty tasks load
asynchronously. So when a view is re-rendered while scrolled down, the layout
would yank it to the top because only a fraction of the content has been read
yet, then leave it there once loading finished.

Track whether a command task is actively reading (set synchronously when the
task is created, so a layout pass in between sees it; cleared at EOF, but not
when stopped, since that means a newer task is taking over) and skip the
scroll-up clamp for such views. onEndOfInput already re-clamps once loading
completes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 85cff19a2a Fire queued ReadToEnd callbacks when the initial read reaches EOF
A task's read loop processes one LinesToRead request at a time. The initial
request has a large line count and no Then callback; if the content is shorter
than that, the loop hits EOF on the initial request and breaks out, abandoning
any further requests still sitting in the readLines channel. So a ReadToEnd
call that races a still-loading-but-shorter-than-its-initial-read view has its
Then silently dropped: it isn't fired immediately (the channel was non-nil at
call time) and it's never dequeued.

On EOF, drain the queued requests and fire their Then callbacks before
breaking out, since reaching EOF trivially satisfies any "read more" request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 9293f03c83 Add a test for a read request queued while a task reaches EOF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 86c9e6a20a Lock the view while reading viewLines on the event-handling thread
hyperlinkAt (the click path) and onMouseMove/findHyperlinkAt (hover) read
v.viewLines without holding writeMutex, unlike every other reader. They run
on the event-handling goroutine, so a re-render on the task goroutine can
shrink or rebuild viewLines between the bounds check and the indexing,
causing an out-of-range panic (observed: "index out of range [60] with
length 0" while hovering during a diff re-render).

Take writeMutex for the duration, like the other viewLines readers do, so
the check and the access see the same slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller e3fe321080 Move the click-path hyperlink lookup onto View
Reading a view's internal buffer belongs on the view itself, next to
findHyperlinkAt, rather than in the event loop; and the view is where the
lock that guards that buffer can be taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller d65ee95942 Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames
Re-rendering a diff into a main view is asynchronous and lazy: the read
loop fills the view a screenful at a time and refreshes as it goes. When
debugging scroll-restore and flicker behaviour, the individual frames go by
too fast to see. Setting LAZYGIT_SLOW_RENDER=<milliseconds> sleeps that long
after each line is written, stretching the load out so the frames become
visible. It has no effect when unset, so it's safe to leave in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 94018de3e8 Route all view origin writes through SetOriginX and SetOriginY
Several methods assigned v.ox and v.oy directly: SetOrigin, CopyContent,
the wrap/autoscroll branches in draw, FocusPoint, and
Scroll{Up,Down,Left,Right}. Funnelling them all through SetOriginX and
SetOriginY gives a single place to observe (or set a breakpoint on)
every change to a view's scroll position, which makes debugging scroll
behaviour much easier.

This means those call sites now also get the setters' `< 0` clamps, but
that is behaviour-preserving in every case: each assigned value is
already >= 0. calculateNewOrigin never returns a negative number;
CopyContent copies origins that are themselves always >= 0; and the draw
and scroll writes are all guarded (or fed only non-negative amounts) so
the result can't go below zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 01600042cd Guard taskKey with the mutex that already guards the task ID
taskKey is written on the goroutine NewTask spawns, under taskIDMutex, but
GetTaskKey read it without the lock — and the string renders in
tasks_adapter.go call that from the UI thread while a previous task's
goroutine may be writing. A Go string is a two-word value, so a torn read
can pair one string's pointer with another's length and index out of
bounds, not merely return the wrong key.

Take the lock in GetTaskKey, and read the field directly at the one call
site that already holds it.

No test: the failure needs two goroutines to interleave inside a
two-word assignment, which nothing can schedule deterministically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan Haller 1868167a7a
Auto-select the conflicted commit when stopping in rebase (#5937)
When a rebase (or multi-commit cherry-pick or revert) stops with a
conflict, it is often useful to look at the diff of the "<-- CONFLICT"
commit to double-check that the conflict resolution matches the diff of
the original commit. To make that easier, select that commit
automatically.
2026-08-15 15:29:31 +02:00
Stefan Haller 1c17ee1a92 Auto-select the conflicted commit when stopping in rebase
When a rebase (or multi-commit cherry-pick or revert) stops with a
conflict, it is often useful to look at the diff of the "<-- CONFLICT"
commit to double-check that the conflict resolution matches the diff of
the original commit. To make that easier, select that commit
automatically.
2026-08-15 15:24:36 +02:00
Stefan Haller 6417da5319 Add selection assertions to tests involving conflicts
This doesn't change anything, we just pin down the selection behavior
around conflicts; we are going to change that behavior, and these tests
will make it obvious how when they change in the next commit.
2026-08-15 15:24:15 +02:00
Stefan Haller aa38daff98
Keep the last conflict file selected after resolution (#5936)
When there is a single conflicting file left to be resolved, lazygit
dismisses the conflicted-files-only filter when the file no longer has
conflict markers. However, the selection moved to the top, which is
annoying because very often it is useful to look at that file's
resulting diff once more to confirm that conflicts were resolved
correctly, and finding it again can be cumbersome when there are many
changed files. So keep it selected.

Of course, this only helps for the last (or only) conflicted files; when
there are multiple, a resolved file disappears from the panel until all
are resolved, which makes it hard to double-check the resulting diffs.
Doing it afterwards is not easy because you'd have to remember which
files were conflicting. This needs a different solution, but for the
special case of only a single conflicting file this is already a big
improvement.
2026-08-15 15:24:01 +02:00
Stefan Haller 39513d244d Keep the last conflict file selected after resolution
When there is a single conflicting file left to be resolved, lazygit
dismisses the conflicted-files-only filter when the file no longer has
conflict markers. However, the selection moved to the top, which is
annoying because very often it is useful to look at that file's
resulting diff once more to confirm that conflicts were resolved
correctly, and finding it again can be cumbersome when there are many
changed files. So keep it selected.

Of course, this only helps for the last (or only) conflicted files; when
there are multiple, a resolved file disappears from the panel until all
are resolved, which makes it hard to double-check the resulting diffs.
Doing it afterwards is not easy because you'd have to remember which
files were conflicting. This needs a different solution, but for the
special case of only a single conflicting file this is already a big
improvement.
2026-08-15 15:03:29 +02:00
Stefan Haller 4e2a1cd5b0 Add a function SetStatusFilterPreservingSelection 2026-08-15 14:58:27 +02:00
Stefan Haller b2b9519bcc Extract a private helper function preserveSelection
The operation around which the selection should be preserved is passed
in so that it can be reused for different purposes.
2026-08-15 14:58:10 +02:00
Stefan Haller a1f1a4ee8c
Draw the UI in a more inactive look when the window is not focused (#5935)
When using lazygit in a multi-tab terminal it is useful to see if the
lazygit tab is currently active; ghostty does a very good job at dimming
down the inactive tabs, but VS Code's builtin terminal does not, so
indicate this on our side by removing the green highlight from panel
frames and tab titles, and showing the selection as inactive like we do
for a side panel when the main view is focused.
2026-08-15 12:39:51 +02:00
Stefan Haller a5a2bd0699 Draw the UI in a more inactive look when the window is not focused
When using lazygit in a multi-tab terminal it is useful to see if the
lazygit tab is currently active; ghostty does a very good job at dimming
down the inactive tabs, but VS Code's builtin terminal does not, so
indicate this on our side by removing the green highlight from panel
frames and tab titles, and showing the selection as inactive like we do
for a side panel when the main view is focused.
2026-08-15 12:17:40 +02:00
Stefan Haller ae1007612b
Improve startup time (#5934)
This PR has two separate improvements for the startup time, in
particular for the time until the Files panel shows the modified files:

- avoid walking the `.git/workspaces` dir recursively, looking for the
gitdir files of linked worktrees. This code was not used to populate the
worktrees panel, but only for the decision in the Files panel whether to
show an entry with the worktree icon; it was doing unnecessary work,
because walking the `.git/workspaces` dir recursively is pointless (git
stores the worktree gitdir files only at the top level, so a flat read
of that directory would have been enough), and can take significant time
in large repos, especially when they have many submodules. Instead of
fixing that code, remove it entirely and rearrange the refresh code so
that we can use the regular worktree model for this Files panel
decision.
- at startup we were doing two full refreshes at the same time: the
regular one that we always do after loading a repo for the first time,
and then also a focus-in refresh. I didn't realize that a terminal will
send us a focus-in event right at the moment we request these events.
Nothing bad happens from doing those two refreshes at the same time, but
it slows things down a bit when we have two "git status" calls running
concurrently.

Both of these together reduce the time it takes for the Files panel to
show its files at startup (in my regular work repo with three worktrees
and ~30 submodules) from 860ms to 440ms, so almost a factor of two. If
you want to measure this in your own repo, here's a small throwaway
patch that you can use for that:

```diff
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index 40ce86eae..fbb482f39 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -26,6 +26,11 @@ import (
 	"github.com/sasha-s/go-deadlock"
 )
 
+var (
+	applicationStartTime     = time.Now()
+	applicationStartTimeOnce sync.Once
+)
+
 type RefreshHelper struct {
 	c                    *HelperCommon
 	refsHelper           *RefsHelper
@@ -1213,6 +1218,10 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState
 	self.refreshView(self.c.Contexts().Submodules, env)
 	self.refreshView(self.c.Contexts().Files, env)
 
+	applicationStartTimeOnce.Do(func() {
+		self.c.Log.Infof("Time until first files refresh: %s", time.Since(applicationStartTime))
+	})
+
 	return nil
 }
 
```

To use it, run `./lazygit -l | grep "first files refresh"` in one
terminal, and `lazygit -d` in the one that you want to test. I'm curious
about your before/after measurements, feel free to post them below in
the comments.
2026-08-15 12:02:33 +02:00
Stefan Haller 312a5f2cc1 Only react to focus reports that change whether we're focused
A terminal that supports focus reporting answers with the state it is
already in when we turn reporting on, so at startup we were told that we
had gained focus that we never lost, and refreshed everything a second
time on top of the refresh that loading the repo had just started. The
two ran at once, each with its own `git status`, which made both of them
slower than the one refresh needed to be.

Keep track of what the reports say, then, and pass on only the ones that
change it. Assuming that we start out focused costs us nothing when we
don't: that same first report says so, so a lazygit started in a window
that isn't in front knows it from the start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:26:58 +02:00
Stefan Haller 80ad2db71a Recognize worktrees among the files from the worktrees model
Finding out which of the files are worktrees of ours had its own answer
to where this repo's worktrees are, walking the directory that git keeps
them in. The worktrees panel asks git itself, and that is the better
answer: it is the one git gives for the same question elsewhere in the
app, and it doesn't need to know where git records what.

The model that panel fills is all the files need, so mark them from it.
That takes the work out of the file loader, whose other two callers were
paying for it without wanting it, and it costs no git call at all: both
models are written on the UI thread, so whichever of the two refreshes
lands second marks the files against the other's fresh data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:26:58 +02:00
Stefan Haller a1f5db6bce Add test for how we show a worktree that is inside the main work tree
We show this with a worktree icon (which is only shown when nerd fonts
are used, so turn these on), but also we strip the trailing `/` that
"git status" reports, so that it shows as a file rather than a directory
with a bogus file in it.

The reason for adding the test is that we are going to touch the logic
that determines whether an item in the Files panel is a linked worktree,
and this guards against regressing.
2026-08-15 11:26:58 +02:00
Stefan Haller 62caf427ac Refresh the worktrees in their own scope again
The worktrees were loaded and written by the branches refresh whenever
both were in scope, because the branches view shows worktrees against
branches: refreshing them separately rendered that view twice, once
with worktrees that were still stale.

Ordering the two is enough for that, and it leaves each scope owning
its own model again. The worktrees refresh now runs first and queues
its model write before it reports being done, so a branches refresh
that waits for it queues its own write behind that one, and renders
once with both. The worktrees scope only renders the branches view
itself when nobody else is going to.

As a side effect the two loads now run concurrently, where the branches
refresh used to load the worktrees after its own branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:18:23 +02:00
Stefan Haller cfe7961d54 Give commits and branches their own scope checks
Everything in performRefresh is meant to read as "if this scope was
asked for, refresh it", with the scopes that always change together
expanded into each other up front so that each check can name a single
one. The commits and the branches were the exception: one condition
asking for either of them refreshed both, so what that block does only
followed from reading it together with the expansion at the top of the
function. The rebase commits hung off the same condition as an else,
even though it is the commits refresh they are an alternative to.

Expand those two into each other like the other pairs, and give each of
them a check of its own. They now capture their inputs separately,
which is what every other scope has always done.

The reflog stays with the branches rather than getting a check of its
own, because sorting the branches by recency needs it loaded first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:18:23 +02:00
Stefan Haller 1f1fd99357
Two small improvements to the debug log (#5933)
- log time stamps with nano-second granularity (on macOS we effectively
get only microseconds, but that's still more than we need); `lazygit -l`
prints them with millisecond resolution, which can be useful for
investigating performance
- add a blank line at startup to make it much easier to see where a new
run starts
2026-08-15 09:53:13 +02:00
Stefan Haller 0140e19eb2 Separate the runs in the debug log with a blank line
This makes it much easier to find where a new run starts; previously you
had to search for `git --version` for that.
2026-08-15 09:48:11 +02:00
Stefan Haller d298a7ce4b Use nano-second time stamp granularity in the debug log
It's overkill for most purposes, but I'd say it doesn't hurt to have the
extra resolution available in the raw JSON data for the few cases where
it's useful. Have "lazygit -l" print them with milli-second resolution;
that seems to be a good middle ground.
2026-08-15 09:47:32 +02:00
Stefan Haller 1248ac4457
Disable staging all files when there are none to stage (#5932)
Pressing `a` in the files panel to stage all files would show a
confusing error popup about something with submodules, which made no
sense at all. And worse, when pressing `a` very quickly after startup
(before the initial refresh had a chance to populate the files panel) it
would crash with a nil pointer panic.

Fix both by showing an error toast that there are no files to stage.

Fixes #5929.
2026-08-15 09:47:16 +02:00
Stefan Haller cbc3da507b Disable staging all files when there are none to stage
Besides the misleading error about submodules, the command crashes when
it runs before the first files refresh has come in: the file tree
doesn't exist yet at that point, and staging all of a tree that isn't
there dereferences a nil root node. That is easy to hit in a big repo,
where `git status` takes a moment while the panel sits there empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:52:45 +02:00
Stefan Haller 0c04ee5c61 Add a test for staging all files when there are none
The stage-all command acts on the whole file tree, and nothing stops it
from doing that when the tree is empty. It ends up in the branch that
explains why a submodule couldn't be staged, which has nothing to do
with what the user did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:52:45 +02:00
Stefan Haller c477a2959b
Avoid gofumpt'ing other worktrees (#5931)
This is usually not a problem (except for the unnecessarily wasted time)
because all worktrees should normally be correctly formatted; but it is
a problem when we bump the formatter version, and some worktrees are
already on the new version and others still on the old.
2026-08-14 22:51:59 +02:00
Stefan Haller 1a3cf798f9 Avoid gofumpt'ing other worktrees
This is usually not a problem (except for the unnecessarily wasted time)
because all worktrees should normally be correctly formatted; but it is
a problem when we bump the formatter version, and some worktrees are
already on the new version and others still on the old.
2026-08-14 22:43:37 +02:00
Stefan Haller af04698d9a
Scroll the selection into view by default again (#5928)
In v0.58 (see #5134) we changed the refresh behavior to no longer scroll
a selection into view by default if it had been scrolled out of view
using the mouse wheel; the primary reason for that was to avoid a
background fetch or files refresh to yank the selection back into view
while you are looking at something else, which was pretty annoying.
However, that meant we had to fix lots of cases where the selection
didn't become visible after a normal, foreground user action, and add
code to manually scroll it into view again in those cases. This is
error-prone and easy to forget for new features, and to this day we were
still missing some.

So turn it around: by default, the selection is scrolled into view
again, and the few cases where we don't want it (background routines and
the focus-in refresh) opt out.

This does mean that some user actions now scroll into view that didn't
before, and there might be cases where this is unwanted (I can't think
of one, but who knows). If we come across one we can easily fix it by
opting out; this is probably still much better than not scrolling into
view where it's wanted.
2026-08-14 08:31:46 +02:00
Stefan Haller 5c4c7139d7 Remove the scroll calls that are now redundant
Every one of these did by hand what focusing the list now does on its
own: five hand-added scroll requests, and four origin resets that paired
a "select the first item" with a "and show the top of the list".

The scroll that the commits refresh performed when it found the selected
commit at a new index goes too. It is now unconditional for a foreground
refresh, and deliberately absent for a background one: when an agent
commits in another window, we would rather see the new commits arrive
than have the view yank itself back to the commit we had selected.

The one origin reset that stays is the one in ReApplyFilter, which runs
as part of a refresh and so can't rely on the refresh scrolling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller aebf495dce Scroll the selection into view by default
Ever since scrolling the selection into view became opt-in, we have been
fixing the same class of regression by hand, five times so far: a
controller moves the selection somewhere new, doesn't say that it wants
the view to follow, and the selection ends up off screen. The decision
needs facts from two places — whether the selection went somewhere new is
known to the list, whether the scroll position is the caller's to manage
is known to the caller — and asking every caller for both is what keeps
going wrong. The callers that get it wrong are usually not even the ones
that moved the selection: they are pass-throughs like postRefreshUpdate,
which can't know what a refresh did to the selection.

So default to scrolling, and let the two callers that maintain the scroll
position themselves say so.

The one case where scrolling is always wrong is a refresh that no user
action is behind: a background poll, or a reload of state on window
focus, after a subprocess, or after a repo switch. Those must leave the
viewport wherever the user last scrolled it to — that is what made the
scrolling opt-in in the first place. Both are already marked in
RefreshOptions, so the refresh can decide it once, centrally, instead of
each caller judging it.

A user action that ends in a foreground refresh does now yank the view
back to the selection if the user had scrolled away from it. That's a
behaviour change, and there may be actions where it turns out to be
unwelcome; those we can fix individually, and it beats the ones that
don't scroll today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller 29d05e23f9 Add tests for the scroll-into-view regressions we fixed by hand
Since scrolling the selection into view became opt-in, five places have
had to be fixed by hand after the fact, none of them with a test. Cover
them now: making the scrolling automatic has to keep all five working,
and once it does, the hand-added scroll calls can go.

Two of them assert that the selection is visible rather than on an exact
scroll position, because the panel they look at changes height along the
way (filtering mode switches to half screen), or because what matters is
only that the commit we jumped to can be seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller a85d6e0349 Add a test for dragging a range selection past the bottom of a panel
This is the other place that manages its own scroll position: while a
drag extends the selection to a line below the viewport, the view stays
put, and the drag autoscroller scrolls it one line at a time for as long
as the pointer stays there. Making the scroll automatic would centre the
selection instead, i.e. jump the view rather than scroll it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller e9faf0325d Add a test that a background refresh keeps the scroll position
The one behaviour that made scrolling the selection into view opt-in in
the first place — a background refresh must not yank the view back to a
selection the user scrolled away from — has never been covered by a test.
It's about to become the one case that the automatic scrolling has to
suppress, so cover it first.

Getting there needs two things from the test harness: mouse wheel events,
which are the only way to scroll a list panel without moving the
selection, and a way to trigger a background refresh. The periodic
routine that issues it is turned off in tests, and turning it on would
mean waiting for its timer and hoping it fires while we're looking, so
drive the refresh directly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller a881fb7ee0 Add a test for paging up and down in a list
We are about to make list panels scroll their selection into view
automatically. Page up and down are one of the few places that manage
the scroll position themselves, keeping the selection at the edge of the
viewport rather than in its middle, and nothing covers that today.

Asserting on it needs an exact scroll position assertion; only
OriginYAtLeast existed so far.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00
Stefan Haller 4e4cd93993
Fix example for args field in rawGit diff renderer docs (#5927) 2026-08-14 07:45:17 +02:00
Stefan Haller ac5e0b4ae9 Fix example for args field in rawGit diff renderer docs 2026-08-14 07:42:25 +02:00
Stefan Haller d25315b55a
Bump gofumpt to version 0.11.0 (#5925) 2026-08-13 20:51:19 +02:00
Stefan Haller 25c6aeadf0 go get -tool mvdan.cc/gofumpt@v0.11.0 2026-08-13 20:40:11 +02:00
Stefan Haller b1e9ac3969 Remove a few unnecessary parentheses
The new version of gofumpt that we are going to update to in a moment
would complain about these.
2026-08-13 20:40:11 +02:00
Stefan Haller fbe2379fa5
Bump JamesIves/github-sponsors-readme-action from 1.6.0 to 1.6.1 (#5917)
Bumps
[JamesIves/github-sponsors-readme-action](https://github.com/jamesives/github-sponsors-readme-action)
from 1.6.0 to 1.6.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jamesives/github-sponsors-readme-action/releases">JamesIves/github-sponsors-readme-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.6.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Dependencies 🤖</h3>
<ul>
<li>chore(deps): bump the misc group across 1 directory with 3 updates
by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1034">JamesIves/github-sponsors-readme-action#1034</a></li>
<li>chore(deps-dev): bump <code>@​types/node</code> from 25.2.0 to
25.2.1 in the misc group by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1036">JamesIves/github-sponsors-readme-action#1036</a></li>
<li>chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1041">JamesIves/github-sponsors-readme-action#1041</a></li>
<li>chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1040">JamesIves/github-sponsors-readme-action#1040</a></li>
<li>chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1052">JamesIves/github-sponsors-readme-action#1052</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/JamesIves/github-sponsors-readme-action/compare/v1...v1.6.1">https://github.com/JamesIves/github-sponsors-readme-action/compare/v1...v1.6.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="02650b8cd4"><code>02650b8</code></a>
Merge remote-tracking branch 'origin/dev' into releases/v1</li>
<li><a
href="e958d3e9b7"><code>e958d3e</code></a>
Deploy Production Code for Commit
d0e97b6c881bf5598173fe04a41bffac988b7667 🚀</li>
<li><a
href="c0481077d6"><code>c048107</code></a>
Merge remote-tracking branch 'origin/dev' into releases/v1</li>
<li><a
href="8fd9552b1c"><code>8fd9552</code></a>
fix: use a dedicated RELEASE_PAT for release creation</li>
<li><a
href="d0e97b6c88"><code>d0e97b6</code></a>
Merge branch 'dev' of <a
href="https://github.com/JamesIves/github-sponsors-readme-act">https://github.com/JamesIves/github-sponsors-readme-act</a>...</li>
<li><a
href="6eb9fb19bd"><code>6eb9fb1</code></a>
ci: run sponsors README update twice a week instead of daily</li>
<li><a
href="152fad67d2"><code>152fad6</code></a>
ci: run integration tests weekly instead of daily</li>
<li><a
href="7962d89b08"><code>7962d89</code></a>
chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (<a
href="https://redirect.github.com/jamesives/github-sponsors-readme-action/issues/1052">#1052</a>)</li>
<li><a
href="7b03cded5b"><code>7b03cde</code></a>
fix: remove unsupported semver cooldown keys from the github-actions
ecosystem</li>
<li><a
href="592de7d30b"><code>592de7d</code></a>
security: add explicit permissions blocks to workflows</li>
<li>Additional commits viewable in <a
href="2fd9142e76...02650b8cd4">compare
view</a></li>
</ul>
</details>
<br />
2026-08-12 19:45:58 +02:00
dependabot[bot] f77c1d37f1
Bump JamesIves/github-sponsors-readme-action from 1.6.0 to 1.6.1
Bumps [JamesIves/github-sponsors-readme-action](https://github.com/jamesives/github-sponsors-readme-action) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/jamesives/github-sponsors-readme-action/releases)
- [Commits](2fd9142e76...02650b8cd4)

---
updated-dependencies:
- dependency-name: JamesIves/github-sponsors-readme-action
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 17:43:40 +00:00
Stefan Haller 27fcf4b729
Bump github.com/lucasb-eyer/go-colorful from 1.4.0 to 1.4.1 (#5916)
Bumps
[github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful)
from 1.4.0 to 1.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lucasb-eyer/go-colorful/releases">github.com/lucasb-eyer/go-colorful's
releases</a>.</em></p>
<blockquote>
<h2>v1.4.1</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: correct D50ToD65 to the CSS Color 4 matrix that inverts
D65ToD50 by <a
href="https://github.com/gaoflow"><code>@​gaoflow</code></a> in <a
href="https://redirect.github.com/lucasb-eyer/go-colorful/pull/85">lucasb-eyer/go-colorful#85</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/gaoflow"><code>@​gaoflow</code></a> made
their first contribution in <a
href="https://redirect.github.com/lucasb-eyer/go-colorful/pull/85">lucasb-eyer/go-colorful#85</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1">https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/lucasb-eyer/go-colorful/blob/master/CHANGELOG.md">github.com/lucasb-eyer/go-colorful's
changelog</a>.</em></p>
<blockquote>
<h2>[1.4.1] - 2026-08-02</h2>
<h3>Fixed</h3>
<ul>
<li>Corrected <code>D50ToD65</code> to use the CSS Color 4 matrix
inverse of <code>D65ToD50</code> (<a
href="https://redirect.github.com/lucasb-eyer/go-colorful/issues/85">#85</a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="315b48282c"><code>315b482</code></a>
ready for v1.4.1</li>
<li><a
href="e9123175c0"><code>e912317</code></a>
fix: correct D50ToD65 to the CSS Color 4 matrix that inverts
D65ToD50</li>
<li>See full diff in <a
href="https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1">compare
view</a></li>
</ul>
</details>
<br />
2026-08-12 19:41:33 +02:00
dependabot[bot] 93b8f343bb
Bump github.com/lucasb-eyer/go-colorful from 1.4.0 to 1.4.1
Bumps [github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/lucasb-eyer/go-colorful/releases)
- [Changelog](https://github.com/lucasb-eyer/go-colorful/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: github.com/lucasb-eyer/go-colorful
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 17:38:40 +00:00
Stefan Haller c18a0c6680
Fix pull requests silently disappearing until lazygit is restarted (#5921)
A lazygit instance that has been open for a while sometimes stops
showing pull requests, and keeps not showing them until you quit and
restart it. The cause is that we resolve the GitHub token once per
process and then keep using that stale answer.

We get the token from go-gh, which reads gh's `hosts.yml` on the first
call and caches it for the lifetime of the process. gh rewrites that
file whenever the active account changes, and keeps the active account's
token either in the file or in the system keyring, depending on the
account. Once the file changes under us, our snapshot no longer
describes reality: either we keep sending a token for an account that is
no longer active, or — if the snapshot was taken while a keyring-backed
account was active — we find no token at all, drop the remote, and show
no pull requests. Nothing is reported to the user, so it looks like PR
fetching is simply broken.

Switching accounts with `gh auth switch` is the easiest way to trigger
this, but it isn't limited to multi-account setups: a single account
hits the same thing whenever its token is rotated, or moved between the
keyring and `hosts.yml` by a fresh `gh auth login`.

So ask gh itself for the token on every refresh, with `gh auth token
--hostname <host>`, which resolves it afresh from whichever of the
environment, the keyring, or the config file currently holds it. Not
passing `--secure-storage` (which go-gh does internally) also means it
stops mattering which of the two the active account uses. go-gh's lookup
stays as a fallback for setups without the gh binary, where it still
picks up `GH_TOKEN` and friends; when gh is present it checks those
variables itself.

To reproduce on master, with two gh accounts, one with its token in the
keyring and one in `hosts.yml`: start lazygit while the keyring-backed
account is active, then run `gh auth switch` to the other one. The pull
request information disappears on the next refresh and doesn't come back
until lazygit is restarted. Running `gh auth token --secure-storage
--hostname github.com` by hand at that point prints `no oauth token
found for github.com`.
2026-08-12 19:30:37 +02:00
Stefan Haller c8bc1928f2 Get the GitHub token from gh instead of resolving it in-process
go-gh reads gh's config file once per process and answers from that
snapshot for the rest of the process's life. gh rewrites the file
whenever the active account changes, and stores the active account's
token either in it or in the system keyring, depending on the account.
A lazygit that has been running for a while therefore consults a
snapshot that no longer describes reality: it either keeps using a
token for an account that is no longer active, or, when the snapshot
was taken while a keyring-backed account was active, finds no token at
all and silently stops showing pull requests until it is restarted.

Asking gh resolves the token afresh on every refresh, from whichever of
the environment, the keyring or the config file currently holds it.
go-gh's lookup stays behind as a fallback for setups without the gh
binary, where it still picks up GH_TOKEN and friends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:13:06 +02:00
Stefan Haller 4b22b844e7
Fix hang on quit when confirmOnQuit is true (#5919)
When confirmOnQuit is true, quitting would sometimes hang for three
seconds and then print "cannot kill child process". Concretely, this
happened whenever the Files panel was focused but there were no changed
files (the main view shows "No changed files").

This is a regression in 0.64.0, it worked before.

Fixes #5918.
2026-08-12 19:12:16 +02:00
Stefan Haller ec577f1afa Give up waiting for the UI thread once the main loop has exited
Quitting with confirmOnQuit set hung for three seconds and printed
"cannot kill child process", but only with a clean working tree. Closing
the confirmation pops the context before running its handler, so the
files panel is re-focused and re-renders the main view, and only then
does the handler return ErrQuit. With no changed files that render is a
string task, whose whole body is one hop to the UI thread — a hop that
is never served, because the handler's ErrQuit has meanwhile brought the
main loop down. The task can't finish, so the ViewBufferManager.Close
that follows waits for it until it times out. (With changed files it's a
command task instead, and every blocking point in one of those selects
on the stop channel, so Close gets through.)

A wait for the UI thread now ends when the loop does. That also covers
the command task's own hops, which are stopped only in between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan Haller 70427c8ff5 Add a test for waiting on the UI thread after the loop has exited
Nothing dequeues user events once MainLoop has returned, so a worker
blocked in OnUIThreadAndWait is blocked for good. The assertion records
that; the next commit makes the wait give up instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan Haller 2f06724b80 Make RefreshHelper pay attention to the error returned from OnUIThreadAndWait
Right now the function always returns nil, but this will change later in
this branch, so handle errors properly. Without that, the first capture
that assigns env.git would not run, leave env.git nil, and subsequent
code would crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan Haller f9b790a1f9 Let OnUIThreadAndWait's error be about the wait, not about f
Every caller passes an f that unconditionally returns nil, so f's error
return has never carried anything: the value is dead weight, and it
occupies the one channel the wait itself needs to report that it couldn't
run f at all. Drop it, so that the error the wait returns can only ever
mean that.

Work that can fail hands its error back through a captured variable, the
way the background fetch already hands back four values, which keeps the
two outcomes distinguishable at a call site that has both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:04:53 +02:00
Stefan Haller ddceff6962
Bump mheap/github-action-required-labels from 5.5.2 to 5.6.0 (#5769)
Bumps
[mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels)
from 5.5.2 to 5.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mheap/github-action-required-labels/releases">mheap/github-action-required-labels's
releases</a>.</em></p>
<blockquote>
<h2>v5.6.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump GitHub client to v7 by <a
href="https://github.com/VincentLanglet"><code>@​VincentLanglet</code></a>
in <a
href="https://redirect.github.com/mheap/github-action-required-labels/pull/96">mheap/github-action-required-labels#96</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/VincentLanglet"><code>@​VincentLanglet</code></a>
made their first contribution in <a
href="https://redirect.github.com/mheap/github-action-required-labels/pull/96">mheap/github-action-required-labels#96</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/mheap/github-action-required-labels/compare/v5.5.2...v5.6.0">https://github.com/mheap/github-action-required-labels/compare/v5.5.2...v5.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="23e10fde7e"><code>23e10fd</code></a>
Automatic compilation</li>
<li><a
href="6e4081ebcb"><code>6e4081e</code></a>
Replace nock with undici in tests + bump GH client to v7 (<a
href="https://redirect.github.com/mheap/github-action-required-labels/issues/96">#96</a>)</li>
<li>See full diff in <a
href="0ac283b4e6...23e10fde7e">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:59:43 +02:00
dependabot[bot] f416a4ba6a
Bump mheap/github-action-required-labels from 5.5.2 to 5.6.0
Bumps [mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels) from 5.5.2 to 5.6.0.
- [Release notes](https://github.com/mheap/github-action-required-labels/releases)
- [Commits](0ac283b4e6...23e10fde7e)

---
updated-dependencies:
- dependency-name: mheap/github-action-required-labels
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:57:21 +00:00
Stefan Haller 2399cac0db
Bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14 (#5813)
Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji)
from 2.2.13 to 2.2.14.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/kyokomi/emoji/releases">github.com/kyokomi/emoji/v2's
releases</a>.</em></p>
<blockquote>
<h2>v2.2.14</h2>
<h2>What's Changed</h2>
<h3>Emoji data</h3>
<ul>
<li>Regenerated <code>emoji_codemap.go</code> with Unicode Emoji 16.0 /
17.0 additions — 18 new emoji such as <code>:fingerprint:</code>,
<code>:orca:</code>, <code>:treasure_chest:</code>,
<code>:ballet_dancer:</code> (no removals)</li>
</ul>
<h3>Behavior change</h3>
<ul>
<li><code>NormalizeShortCode</code> now consistently returns the
lowercase alias when same-length aliases differ only in case. Affects 26
entries (e.g. <code>:Aries:</code> → <code></code>,
<code>:OK_hand:</code> → <code>👌</code>, <code>:ZZZ:</code> →
<code>💤</code>, <code>:T-Rex:</code> → <code>🦖</code>)</li>
</ul>
<h3>Maintenance</h3>
<ul>
<li>go.mod: <code>go 1.14</code> → <code>go 1.21</code> (cmd module:
<code>go 1.12</code> → <code>go 1.25</code>, goquery v1.5.1 →
v1.12.0)</li>
<li>CI: pin Go via <code>go-version-file</code>, golangci-lint-action v6
→ v8, verify cmd module</li>
<li>Added dependabot for github-actions and gomod</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14">https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="a659fe56a6"><code>a659fe5</code></a>
Maintenance: update emoji data, modernize Go/CI, add dependabot (<a
href="https://redirect.github.com/kyokomi/emoji/issues/65">#65</a>)</li>
<li><a
href="eb10848906"><code>eb10848</code></a>
Bump GitHub workflow actions (<a
href="https://redirect.github.com/kyokomi/emoji/issues/64">#64</a>)</li>
<li>See full diff in <a
href="https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:55:18 +02:00
dependabot[bot] d6cf948dca
Bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14
Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji) from 2.2.13 to 2.2.14.
- [Release notes](https://github.com/kyokomi/emoji/releases)
- [Commits](https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14)

---
updated-dependencies:
- dependency-name: github.com/kyokomi/emoji/v2
  dependency-version: 2.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:52:34 +00:00
Stefan Haller 4b257646ad
Bump actions/setup-go from 6 to 7 (#5843)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to
7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/763">actions/setup-go#763</a></li>
<li>chore(deps): bump <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v7.0.0">https://github.com/actions/setup-go/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<h3>Dependency update</h3>
<ul>
<li>Upgrade actions dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in
<a
href="https://redirect.github.com/actions/setup-go/pull/744">actions/setup-go#744</a></li>
<li>Upgrade <code>@​types/node</code> and typescript-eslint dependencies
to resolve npm audit findings by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/755">actions/setup-go#755</a></li>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/758">actions/setup-go#758</a></li>
<li>Upgrade version to 6.5.0 in package.json and package-lock.json by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/762">actions/setup-go#762</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/744">actions/setup-go#744</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/758">actions/setup-go#758</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.5.0">https://github.com/actions/setup-go/compare/v6...v6.5.0</a></p>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Enhancement</h3>
<ul>
<li>Add go-download-base-url input for custom Go distributions by <a
href="https://github.com/gdams"><code>@​gdams</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/721">actions/setup-go#721</a></li>
</ul>
<h3>Dependency update</h3>
<ul>
<li>Upgrade minimatch from 3.1.2 to 3.1.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/727">actions/setup-go#727</a></li>
</ul>
<h3>Documentation update</h3>
<ul>
<li>Rearrange README.md, add advanced-usage.md by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/724">actions/setup-go#724</a></li>
<li>Fix Microsoft build of Go link by <a
href="https://github.com/gdams"><code>@​gdams</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/734">actions/setup-go#734</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/gdams"><code>@​gdams</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/721">actions/setup-go#721</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.4.0">https://github.com/actions/setup-go/compare/v6...v6.4.0</a></p>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update default Go module caching to use go.mod by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/705">actions/setup-go#705</a></li>
<li>Fix golang download url to go.dev by <a
href="https://github.com/178inaba"><code>@​178inaba</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/469">actions/setup-go#469</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.3.0">https://github.com/actions/setup-go/compare/v6...v6.3.0</a></p>
<h2>v6.2.0</h2>
<h2>What's Changed</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b7ad1dad31"><code>b7ad1da</code></a>
chore(deps): bump <code>@​actions/cache</code> to 6.2.0 (<a
href="https://redirect.github.com/actions/setup-go/issues/771">#771</a>)</li>
<li><a
href="0778a10ce4"><code>0778a10</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-go/issues/763">#763</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:50:54 +02:00
dependabot[bot] d3a2c87293
Bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:47:46 +00:00
Stefan Haller f162dc5aec
Honor the conflict-marker-size gitattribute (#5902)
If the `conflict-marker-size` git attribute is used to set the marker
size to a non-default value (!= 7), lazygit's handling of conflicted
files was totally broken. Stopping at a commit with conflicts in a
rebase would show the `UU` files for a moment, and then, a few seconds
later, would stage all conflicted files and offer to continue the rebase
(with the conflicts baked into the resulting commits if you confirmed).
Even if you cancelled the continue prompt, it wasn't possible to use
git's conflict panel to resolve the conflicts; it would only show the
regular diff for those files, not its conflicts editor.

Fix this by querying the `conflict-marker-size` git attribute for all
conflicting files and use that to match the conflict markers.

Fixes #4367.
2026-08-08 12:58:14 +02:00
Stefan Haller d0078bf05c Recognize conflict markers that have no label
Git only writes the space after a marker when there is a label to write
after it, and the label can be empty: `git checkout -m` with the diff3
conflict style, for instance, has no name for the common ancestor, so it
writes a bare "|||||||" line.
2026-08-08 12:43:42 +02:00
Stefan Haller 5481436d8c Honor the conflict-marker-size gitattribute
Ask git for the attribute of every conflicted file whenever we load the
file status, so that we recognize the markers it actually wrote. Files
that are set up this way are precisely the ones whose regular content
tends to contain marker-looking lines, so matching a run of at least
seven characters instead is not an option: we'd take the file's own
content for markers and then never consider its conflicts resolved.

One `git check-attr` call covers all conflicted files at once; asking per
file would take seconds when hundreds of files are conflicted, and it
would hurt worst on Windows, where spawning a process is expensive.
Because the lookup rides along with the file status, it costs nothing
when there are no conflicts, and editing .gitattributes during a merge
takes effect on the next refresh.
2026-08-08 12:43:42 +02:00
Stefan Haller c3450f9406 Add tests demonstrating that we ignore the conflict-marker-size gitattribute
When a file's conflict markers aren't seven characters long we don't
recognize them at all. Two things go wrong: we consider the file's
conflicts resolved, so we stage it and offer to continue the merge a
moment after stopping at it; and pressing enter on it shows its diff
instead of the merge conflicts view, leaving no way to resolve it in
lazygit.
2026-08-08 12:43:42 +02:00
Stefan Haller bc9fafff02 Make the conflict marker size a parameter of our marker matching
Git doesn't always write conflict markers of seven characters: the
conflict-marker-size gitattribute overrides that per file, and it is set
for good reasons — for file types whose regular content tends to contain
marker-looking lines, such as documentation about merging, or test
scripts. We hard-code seven characters everywhere we look for markers,
so none of that works.

Prepare for honoring the attribute by threading the marker size through
everything that recognizes a marker, carried on the file model. Nothing
fills it in yet, so we still use git's default size of seven everywhere,
and matching is unchanged: a marker consists of exactly that many marker
characters, and all but the "=======" one are followed by a space and a
label.
2026-08-08 12:43:42 +02:00
Stefan Haller 5dec89abfe
Update the UI after stash operations in a single frame (#5905)
This fixes a regression in 0.64.0: before that version, creating or
popping a stash would happen synchronously on the UI thread (including
the refresh), blocking the UI until everything changed, including the
panel focus. Blocking the UI was not nice of course, but at least the UI
update was clean. With 0.64.0 this changed to a background refresh, so
that the update to the two panels and the focus change all happened out
of sync, which looks rather ugly. Fix this by using Refresh's mechanism
to batch UI updates, and switch the panel focus in the Refresh's Then so
that it updates at the same time.

While we're at it, use a waiting status spinner for these operations;
they are usually fast when only few files are involved, but when
stashing a large number of files in a larger repo it can be noticeable,
and it looks ugly if the confirmation prompt stays on the screen while
it is running.
2026-08-08 12:43:29 +02:00
Stefan Haller 4c39b0b903 Run the stash operations with a waiting status
Creating and applying a stash both touch every changed file, so in a
large repo they can take long enough to be noticeable — and running them
on the UI thread meant the confirmation popup stayed on screen, frozen,
for the whole operation. Run them on a worker instead, with a spinner,
and keep blocking input for their duration so that the type-ahead
guarantee the refresh used to provide still holds.

Dropping stays on the UI thread: it only rewrites the stash reflog, so
it's fast no matter how big the stashes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller ed22322ec8 Collapse the stash range selection from the refresh's Then
Collapsing the range before kicking off the refresh paints the new
selection against the list as it was before the drop, so for a frame the
entries that were just dropped are still on screen (and, with
gui.shrinkSidePanelsToContent, the panel is still at its old size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller 6c567d1eb6 Switch to the files panel from the post-stash refresh's Then
Pushing the files context right after kicking off the refresh moves the
focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a
frame before the refreshed stash and files lists arrive. Doing it from
Then puts it in the same frame as the data it belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller 544f3b834b Apply the panel updates after stash operations in a single frame
Stashing and popping change both the stash list and the files list.
With each scope updating the UI as soon as its own refresh is done, the
two panels visibly change at different times; with
gui.shrinkSidePanelsToContent that also means their sizes change at
different times than their contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller da7ca77c9d AGENTS.md additions 2026-08-08 12:26:18 +02:00
Stefan Haller bf5829af3f
Fix several problems with repos whose git dir lives outside the working tree (#5910)
Lazygit assumes a repo can be found again from its working directory: it
chdirs there and lets git rediscover the git dir from `<worktree>/.git`.
That holds for an ordinary repo and breaks for every setup where the git
dir lives somewhere else, which is where these bugs come from. Opening
such a repo worked at all only when `--git-dir` happened to leave
`GIT_DIR` in the environment for every command to inherit — which is
also why entering a submodule, which has to clear it, broke the way back
out.

Three reported problems:

- **`core.worktree` (#5895).** A repo whose work tree is elsewhere
panicked on startup with `fatal: not a git repository`: we chdir'd into
a work tree with no `.git` in it and every command after that was lost.
We now work out at startup whether git can find the repo from its work
tree, and when it can't we put `GIT_DIR`/`GIT_WORK_TREE` on every
command the repo's command builder produces — as well as in the process
environment, for subprocesses that don't come through the builder.
Nothing is set for the repos git can find on its own, which is nearly
all of them.

- **Escaping a submodule of a dotfile repo (#1118).** The repo-path
stack we push the superproject onto only held its path, and for a repo
opened with `--git-dir`/`--work-tree` the path leads nowhere. Escaping
failed with `not a git repository`, or, if some unrelated repo happened
to lie above the work tree, quietly switched to that one instead. The
stack now carries the environment as well, taken from the repo paths
rather than from the process env, so it also covers a repo whose
location lazygit worked out itself.

- **Opening a directory that holds a bare repo (#5469, #5681).** `git
rev-parse --show-toplevel` is fatal when there's no work tree, so we
never got an answer at all for a bare repo: `IsBareRepo()` could never
come out true, and lazygit either died with a stack trace or decided we
weren't in a repository. We now ask again without `--show-toplevel` when
the first query fails, and the existing "open most recent repo?" prompt
does its job.

Some related things that turned up on the way:

- **A submodule no longer looks like a linked worktree.** `git worktree
list` reports the main worktree as the common git dir with a trailing
`/.git` removed, which is not the working tree when the git dir doesn't
live inside it. Comparing that against the working tree path matched
nothing, so inside a submodule the status bar claimed we were in a
linked worktree named after the submodule, the worktrees panel listed it
as not current, and its branch got a "checked out elsewhere" marker.
Worktrees are now identified by their git dir, which names them
unambiguously.

- **Commands aimed at another repo no longer resolve against ours.**
With `GIT_DIR` set, `git -C mysub log -1` reports the *superproject's*
commit, silently. So opening lazygit with `--git-dir`/`--work-tree`
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree.

- **Starting lazygit in a repo's `.git` dir opens the repo.** It used to
tell you that you were in a bare repo, which you weren't — the work tree
was one directory up. git's own convention is that a git dir called
`.git` belongs to the directory holding it, so we look there. (A linked
worktree's or a submodule's git dir isn't called `.git`, and nothing we
look at says where their work tree is, so those still get the prompt.)

- **`RepoPath()`** is documented to be the work tree when we're in the
main worktree, but was derived from the git dir's location, which is
only the same thing when the git dir is inside the work tree. This fixes
the repo name shown in the status panel for split setups.

Fixes #1118
Fixes #5469
Fixes #5681
Fixes #5736
Fixes #5895
2026-08-08 12:20:44 +02:00
Stefan Haller f141fcc570 Open the repo when lazygit is started in its .git dir
Running lazygit in a .git dir got you told you were in a bare repo,
which you weren't: the worktree was sitting right there, one directory
up. git's own convention is that a git dir called .git belongs to the
directory holding it — that's how `git worktree list` names the main
worktree — so ask that directory, and if it is a worktree, open the repo
we were really being asked about.

The git dirs that aren't called .git keep the answer they had. A linked
worktree's and a submodule's do have a worktree, but nothing we look at
says where, so we would be guessing; a bare repo's has none to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 06b421ad0c Remember how to get back to a repo we entered a submodule from
Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they
say where the superproject is. But the stack we push the superproject
onto so that escape brings us back only held its path, and for a repo
opened with --git-dir/--work-tree the path leads nowhere — git can't
find a repo there. Escaping out of a submodule of a dotfile repo failed
with "not a git repository", or, if some unrelated repo happened to lie
above the work tree, quietly switched to that one instead.

Push the environment onto the stack along with the path, taken from the
repo paths rather than from the process env, so that it also covers a
repo we worked the location out for ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 9b1078a2ca Make StringStack generic
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller d19af37ee7 Tell git where the repo is when it can't find it itself
git finds a repo by looking for a .git in the directory a command runs
in. Lazygit runs its commands in the work tree, so that normally works —
but not when the git dir lives somewhere else entirely, which is what
core.worktree and --work-tree are for. Lazygit chdir'd into such a work
tree and then ran commands that couldn't see any repo from there, so
opening a repo with core.worktree set panicked on startup. It only
worked with --git-dir because that leaves GIT_DIR in the environment for
every command to inherit.

Work out at startup whether git can find the repo from its work tree,
and when it can't, put GIT_DIR and GIT_WORK_TREE on every command the
repo's builder produces. As with the working directory the builder pins
(527124d0e0), these also go into the process env — subprocesses don't
come through the builder — but the commands don't read them from there,
because the process env belongs to whichever repo we have switched to
since.

Working out whether git can find the repo means asking git, rather than
reading the .git file, whose contents can spell the same directory
differently than git does. The extra query is skipped for a repo whose
git dir is simply its .git directory, which is nearly all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 34d41b5d51 Don't let our repo answer for a different one
GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command
we run inherits them — including the ones we point at a submodule or
another worktree. git resolves those against our repo instead, and says
nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the
superproject's commit. So opening lazygit with --git-dir/--work-tree
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree; the worktree list came back
claiming every worktree shared our git dir.

Drop the two variables from the commands that address another repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 616d75a1fa Run Reset in the parent module the way the other commands do
Reset told git to change directory with -C while runInParentModule does
it by setting the command's working directory, but they were computing
the same directory for the same reason. Use the helper, so that there is
one place that knows what running in a nested submodule's parent means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 3d80e466ce Say why runInParentModule can name a relative directory
Its working directory resolves against the process rather than against
the repo the command builder pins commands to, which is only safe
because nothing but foreground commands come through here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 0ce248d1bf Recognize a repo that has no work tree instead of bailing out
git makes `rev-parse --show-toplevel` fatal when there's no work tree,
so asking for it together with everything else meant we never got an
answer at all for a bare repo: GetRepoPaths returned an error, nobody
ever saw IsBareRepo() == true, and lazygit either died with a stack
trace or decided we weren't in a repository. That's what you got for
opening it in a directory holding a bare repo and a .git file pointing
at it, which is a normal way to keep a repo and its worktrees together.

Ask again without --show-toplevel when the first query fails: the other
queries work fine without a work tree, so if they now succeed we know
we're in a bare repo, and the existing prompt offering to open a recent
repo does its job. If they fail too we're not in a repo at all, and the
first error already says so.

--is-bare-repository is gone from the query: a work tree implies
core.bare is false, so it could only ever come back false there, and
what matters to us is whether there is a work tree to show, which is
what we now go by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller e10a2f6a27 Stop promising bare repo support
"does not yet support" reads as a promise that it will, but it's quite
likely that it never will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller ca6c0500e6 Don't clear gui.git when we fail to open a repo
onNewRepo also runs when switching repos, and a failure there leaves us
in the repo we came from — with a nil GitCommand, which nothing else is
prepared for. Only assign once we have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller e17ed2484c Use the work tree as the repo path when it is the main worktree
RepoPath() is meant to be the same as WorktreePath() when we're in the
main worktree, but we derived it from the git dir's location instead.
That is only the same thing when the git dir lives inside the work tree.
With core.worktree, --work-tree, or a .git file pointing at a repo dir
that isn't called .git, it lands on a directory that isn't a worktree at
all, and the repo name we show follows it there.

A worktree that has the repo's common git dir to itself is the main
worktree, so use its path. That subsumes the submodule case, whose git
dir lives under the superproject's .git/modules but is still the
submodule's own common dir; --show-superproject-working-tree is now only
needed for a linked worktree of a submodule.

The existing bare repo test asserted a git output that can't occur (a
work tree and --is-bare-repository=true at once), but the rest of it is
the shape of a repo opened with --git-dir/--work-tree, where the new
repo path is the correct one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 180039e78c Add a repo paths test for a repo with a separate work tree
When the work tree lives somewhere else entirely — set up with
core.worktree or --work-tree — we're still in the main worktree, so
RepoPath() should be the work tree, as its own doc comment says. Instead
we derive it from the git dir's location, which lands somewhere that
isn't a worktree at all, and the repo name follows it.

The ACTUAL lines are indented as they will be once the EXPECTED ones
replace them, rather than as gofumpt wants them while the comment
markers are still splitting the struct's alignment. That leaves this one
file not gofumpt-clean until the next commit, in exchange for a diff
there that shows only the lines that actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller d2d5bdc2bc Identify the current and main worktree by git dir, not by path
`git worktree list` reports the main worktree as the common git dir with
a trailing "/.git" removed, which equals the working tree only when the
git dir sits inside it. In a submodule, a bare repo, or a repo using
core.worktree it doesn't, so comparing the reported path against the
working tree path matches nothing: no worktree is recognized as current
or as main. Most visibly, inside a submodule lazygit claimed we were in
a linked worktree named after the submodule, and offered to remove that
"worktree".

Comparing git dirs identifies a worktree unambiguously, so use that.
A worktree whose directory is gone has no git dir to compare, and there
we still have nothing better than its path.

The submodule tests were asserting the linked-worktree suffix in the
status view; it is gone now, and the repo name still says which
submodule we're in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller e1b8ef048a Add a worktree loader test for being in a submodule
A submodule's git dir doesn't live inside its working tree, and `git
worktree list` reports it by its git dir. Lazygit compares that against
the working tree path, so it recognizes neither the current nor the main
worktree, and the UI ends up claiming we're in a linked worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller 7cbd93f945 Give the worktree loader tests their repos' git dirs
The scenarios describe their repo by its paths but leave the git dirs
empty, which no repo has. Unused for now; the loader is about to want
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller d1014aecf3
Simplify the run_integration_tests.sh script (#5908)
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:52:20 +02:00
Stefan Haller e1391298aa Simplify the run_integration_tests.sh script
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:31:58 +02:00
Stefan Haller 813de837ef
update Nix flake dependencies (#5894)
### PR Description

Refresh the Nix flake inputs - see #5882.

Direct input revisions:

- `flake-parts`: `758cf7296b` → `427bf4bd94`
- `nixpkgs`: `c9b6fb7985` → `e72e4f2994`
- `treefmt-nix`: `5eda4ee812` → `d1187f8bc7`
- `systems`: unchanged at `da67096a3b`
- `flake-compat`: unchanged at `ff81ac966b`; its immutable URL metadata
  was normalized by the lock refresh

The refresh also updates the transitive `nixpkgs-lib` and
`treefmt-nix/nixpkgs` inputs.

The newer nixpkgs revision deprecates `pkgs.nixfmt-rfc-style` because it
now aliases `pkgs.nixfmt`. Uses the current attribute directly,
preserving
formatter behavior and removing the two evaluation warnings.

### Testing

- `nix flake check`
- `nix develop --ignore-environment -c just format` with a writable
  `GOCACHE`
- `nix develop --ignore-environment -c just build` with a writable
  `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just unit-test` with
  a writable `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just lint` with
  managed proxy variables and task-local caches
- `nix-shell --run 'just --version'`
- `git diff --check`

### Please check if the PR fulfills these requirements

- [x] Cheatsheets are up-to-date — no integration-test or keybinding
changes
- [x] Code has been formatted
- [x] Tests have been added/updated — no test changes needed; unit suite
passes
- [x] Text is internationalised — no user-facing strings changed
- [x] UserConfig hot reload is unaffected — no UserConfig changes
- [x] Docs have been updated if necessary — no documentation changes
needed
- [x] You've read through your own file changes for silly mistakes etc

<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view',
and make sure the title
is suitable to be included as a bullet point in release notes (i.e.
phrased from a user's point
of view).
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for
examples
-->
2026-08-06 16:51:55 +02:00
Stefan Haller 71ff5fd827 Update the versions pinned for the Nix build
flake.lock pins the exact revision of everything the Nix build pulls in
(the package set it takes git, Go and the formatters from, plus a few
helper libraries), much like go.sum does for Go modules. Nothing
refreshes it automatically and no CI job exercises it, so the pins had
quietly drifted the better part of a year behind.

The flake-compat entry also grows a "?rev=...&revCount=..." suffix on
its download URL. That is not a version change: the server hosting the
archive advertises those two values as part of the canonical URL now,
and they only restate the rev and revCount that the same entry already
lists as separate fields. The archive fetched is byte-for-byte identical
either way.
2026-08-06 16:49:23 +02:00
Stefan Haller a9bb960e5c Use nixfmt's current package name in the flake
The nixpkgs package set renamed the Nix code formatter: what used to be
called nixfmt-rfc-style is now simply nixfmt, and the old name lives on
only as an alias. Both names resolve to the identical program, so this
doesn't change how anything gets formatted.

The rename comes first because the dependency update in the next commit
makes that alias start printing a deprecation warning, so doing it in
this order means no commit in the history evaluates with one.
2026-08-06 16:49:23 +02:00
Stefan Haller 900c3e3c45
Fix race in "Stash staged changes" on git versions before 2.32.0 (#5903)
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left git
apply with an empty patch, so it failed with "unrecognized input", the
following `git stash drop` never ran, and the user was left with a stray
stash entry. This turned up as a flaky stash/stash_staged on the git
2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.
2026-08-06 16:14:08 +02:00
Stefan Haller d2a1a4f2a2 Start all commands of a pipeline before waiting for any of them
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left
git apply with an empty patch, so it failed with "unrecognized input",
the following `git stash drop` never ran, and the user was left with a
stray stash entry. This turned up as a flaky stash/stash_staged on the
git 2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:34:47 +02:00
Stefan Haller bd76666d97
Fix transitions of entering and exiting filtering mode (e.g. by path or author) (#5897)
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

This is a regression in 0.64.0 (more specifically, from #5790): before
that we stopped blocking the UI thread on refreshes, the reload happened
before any of it, so the two always agreed; the price was a frozen UI
for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed
while it runs arrive after the change rather than acting on a list that
is about to be replaced.
2026-08-05 17:49:00 +02:00
Stefan Haller 1b901c7187 Scroll the selection into view after a filtering mode change
The commit list a filtering mode change leaves behind has nothing to do
with the one that was showing, so the scroll position it inherits says
nothing about where the selection ended up, and the selection can land
anywhere off screen. PostRefreshUpdate only moves the cursor within the
existing scroll position, so ask for the scroll separately, the way the
commits refresh does when it moves the selection itself.

Exiting filtering mode looked like it worked, but only by accident: the
commits refresh recognizes the commit that was selected before it ran,
selects it again at its new index, and scrolls because the index moved.
That does nothing for the case where the commit is gone from the list, or
for entering filtering mode, where we select the first commit ourselves.
2026-08-05 17:29:20 +02:00
Stefan Haller 7e6d5ff7c1 Don't show an unfiltered list as if it were filtered
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

Before we stopped blocking the UI thread on refreshes, the reload
happened before any of it, so the two always agreed; the price was a
frozen UI for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed while
it runs arrive after the change rather than acting on a list that is
about to be replaced.
2026-08-05 17:29:20 +02:00
Stefan Haller b30c734513 Handle entering and leaving filtering mode in one place
Setting a filter and clearing it are the same transition in opposite
directions: mutate the mode, bring the screen mode in line with it,
reload the views that depend on the filter, and put the selection
somewhere sensible in the reloaded commit list. They were implemented
twice, once in the filtering menu and once in ModeHelper, which is how
the two came to repaint the commit list in different ways.

Derive the screen mode and the panel switch from whether a filter is
active after the change, so both directions fall out of the same code,
and give ModeHelper the entry points for both. The filtering menu is
left with nothing but the menu.
2026-08-05 17:29:20 +02:00
Stefan Haller e54cb4bf42 Decouple hiding the working tree state from blocking input
Blocking keyboard input and hiding the working tree state mode are two
separate concerns; they were fused into one helper because every caller
so far wanted both. A caller that blocks input for something other than a
rebase would then hide the "Rebasing" indicator for the duration of its
operation, which has nothing to do with it.

Make it an explicit option instead, so blocking input on its own doesn't
imply anything about the modes on display.
2026-08-05 17:29:20 +02:00
Stefan Haller f4968f6839 Rename suppressRebasingMode to suppressWorkingTreeStateMode
The mode it suppresses is active for any working tree state, not just a
rebase: merging, cherry-picking and reverting show through the same
indicator. Name it after what it hides.
2026-08-05 17:29:20 +02:00
Stefan Haller f8b7bab1ab Decide the commit graph from the loaded list, not the filtering mode
Whether a graph can be drawn was read from the filtering mode, while the
graph itself is drawn over the commit list in the model. Those two only
agree once the list has been reloaded for the new mode, and a filtering
mode change reloads the list in the background, so in between we can be
asked to draw a graph over a list the graph makes no sense for.

That is not just cosmetic. Commits in a filtered list are almost never
each other's parents, so no pipe ever terminates: the pipe set grows by
one per row and every continuing pipe rescans it, which is cubic in the
length of the list. Escaping out of filtering mode with a filtered list
of 13000 commits — as you get once the 300 commit limit has been lifted,
which happens for good as soon as the selection passes COMMIT_THRESHOLD
— wedges the UI thread for around twenty minutes.

Record whether the list was loaded with a filter, right where the list
itself is stored, and decide from that. The graph now also stays up while
the pre-change list is still on display, rather than vanishing a moment
before the list it belongs to.
2026-08-05 17:29:20 +02:00
Stefan Haller 8996bd68b9
Don't let integration tests race a background git repack (#5898)
This fixes spurious test failures caused by git's auto maintenance
running concurrently with a test's fixture setup; see the first commit's
message for details.

In addition, we change the test harness so that such a failure in a
setup method doesn't take down the whole test binary.
2026-08-05 17:28:56 +02:00
Stefan Haller 34da956f5d Don't let a broken fixture take down the whole test binary
A failing setup step called Shell.fail, which panicked. Tests run as
parallel subtests, so that panic aborted the entire test binary: one bad
fixture cost us the results of all ~500 tests, and the failure was
reported as a stack trace rather than against the test that caused it.

Keep panicking to skip the remaining setup steps -- they would only
produce follow-on failures -- but recover in createFixture and return the
message as that test's error. All three clients already propagate an
error from a test, so they report it the way they report any other
failure.
2026-08-05 17:02:06 +02:00
Stefan Haller 4ec91a0bf5 Don't let integration tests race a background git repack
Every `git commit` forks `git maintenance run --auto --quiet --detach`,
and git 2.54 changed what that runs from the `gc` task to the
"geometric" strategy. The geometric repack's auto condition passes its
threshold of 100 to too_many_loose_objects(), which estimates the loose
object count from the objects/17 fanout directory times 256, so the real
trigger is two objects in that one directory -- where the old gc task
needed 27. Fixture repos reach two easily: every CreateNCommits(n>=6)
repo already stores the blob for file06.txt there, so a single commit
object hashing into 17 (about 4% of fixtures with 10 commits, 15% with
40) tips it over, and from then on every commit in that repo forks a
detached `git repack -d`, which prunes loose objects while the next
fixture command -- or lazygit under test -- is still working in the same
repo.

That is where the CI panics during fixture setup come from:

    panic: error running command: [git commit -m commit-10]
        error: invalid object 100644 50d5612... for 'file09.txt'
        error: Error building trees

The reported hashes are exactly the fixture blobs, so `git add` staged
them correctly; they were unlinked underneath the commit. Only the
"git latest" jobs saw this, since the pinned 2.32/2.38/2.44 jobs predate
the strategy change.

git's own test suite guards against the same thing by exporting
GIT_TEST_MAINT_AUTO_DETACH=false ("Ensure that tests cannot race with
background maintenance by default"). Turning maintenance off outright is
stronger: no test repo needs it, and it also spares us a forked git
process per commit. maintenance.auto has been honored since git 2.29, so
it covers every version in the CI matrix.

Measured on a 40-commit fixture: a background repack fired in 4 of 25
runs before, 0 of 25 after.
2026-08-05 16:58:37 +02:00
330 changed files with 21642 additions and 8090 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,11 +82,26 @@ while still being meaningful and self-contained.
excuse bundling it in. Before committing, review your diff and split out any excuse bundling it in. Before committing, review your diff and split out any
hunk that is behavior-preserving (an extraction, a rename, a move) into a hunk that is behavior-preserving (an extraction, a rename, a move) into a
preceding commit, by staging hunks or resetting and recommitting in order. preceding commit, by staging hunks or resetting and recommitting in order.
- **A preparatory refactor is a new commit only when it prepares something
new.** Before adding one, find the commit that introduced the code you are
about to restructure. If that commit is on this branch, the refactor is a
`fixup!` for it rather than a commit of its own: a branch must never contain
a commit whose code a later commit on the same branch tidies up. A prep
refactor earns a commit of its own only when the shape it corrects came from
before the branch. This holds across a branch stack too — if the commit that
introduced the code is in an earlier branch of the stack, the fixup belongs
there, and the branches above it get replayed. The one exception is when
fixing it there turns out to be unreasonably difficult; ask me what to do
rather than deciding to leave the repair at the tip.
- **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes). - **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
Match the plain English imperative style of the existing history. Match the plain English imperative style of the existing history.
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80 - **Wrap message body to 72 characters**. The subject is allowed to go up to 80
characters, or even a little more if needed to convey a good single-line characters, or even a little more if needed to convey a good single-line
summary; the body should be wrapped at 72 exactly, no more, no less. summary; the body should be wrapped at 72 exactly, no more, no less.
- **End every commit message with the `Co-authored-by:` trailer** naming the
model that wrote it, exactly as your harness instructions spell it. Nothing
in `just check` catches a missing one, so it has to be part of writing the
message rather than something to notice afterwards.
## Iterate with `fixup!` commits ## Iterate with `fixup!` commits
@ -105,6 +120,20 @@ separate, reviewable commit that the user decides when to fold in. A bare
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't `--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception. treat "I'm only touching the tip commit" as an exception.
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
you naturally would because "the branch isn't pushed yet". The user always wants
to review what you changed, so make this transparent; no exceptions.
**When the tip is the wrong place for a fixup, insert it mid-branch.**
Committing a fixup at the tip of the branch only works while the code it
touches still looks the same there; once later commits have rewritten that
code — or the target has since been split — the fixup won't apply, and
rewriting the later commits to accommodate it defeats the point. Check out the
target, make the change, `git commit --fixup=<target>`, then
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
branch. The fixup stays a separate, reviewable commit; only its position
changes.
If the changes don't map cleanly onto existing commits — say they cut If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to than any existing commit naturally owns — stop and ask the user how to
@ -390,12 +419,32 @@ Avoid phrasings like:
- "cleaner than the previous approach" - "cleaner than the previous approach"
- "we used to ... but ..." - "we used to ... but ..."
- "after trying X, we found Y" - "after trying X, we found Y"
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The commit message, which is the durable record of *why this change was made*. The
code comment should make sense to someone who has never seen any prior version code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists. and is just trying to understand the file as it currently exists.
The tell is subtler than an explicit "we used to". A comment that justifies the
code against an alternative — "run it on a worker rather than blocking the UI",
"switch panels in `Then` rather than a moment earlier" — is history in disguise
whenever that alternative is what the code did before the change. It reads as
ordinary rationale, but the reader has no way to know the contrast is with a
version that no longer exists.
So the check to apply is: would you have written this comment if you were
writing the file from scratch, with no diff in mind? If not, the sentence
belongs in the commit message.
## Don't justify routine call sites
If the codebase calls a helper in twenty places without explanation, your
twenty-first call site doesn't need one either. A comment there says "something
here is unusual"; when nothing is, it's noise — and it invites exactly the kind
of before/after justification the section above warns about. Look at the
neighboring call sites before writing one: if they're bare, match them.
## Don't present "live with the bug" as an option ## Don't present "live with the bug" as an option
When you're investigating a defect and laying out fix options for the user, When you're investigating a defect and laying out fix options for the user,

BIN
cpu.out

Binary file not shown.

View file

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

View file

@ -4,10 +4,14 @@
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
## Menu filtering
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
## Filtering files by status ## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view. You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

View file

@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | | | `` w `` | New worktree | |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Delete the remote branch from the remote. | | `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | | | `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------| |-----|--------|-------------|
| `` <enter> `` | Bekijk branches | | | `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | | | `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
| `` e `` | Edit | Wijzig remote | | `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch remote | | `` f `` | Fetch | Fetch remote |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |

View file

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

View file

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

View file

@ -4,10 +4,14 @@
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
## Menu filtering
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
## Filtering files by status ## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view. You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

View file

@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | | | `` w `` | New worktree | |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Delete the remote branch from the remote. | | `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | | | `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------| |-----|--------|-------------|
| `` <enter> `` | Bekijk branches | | | `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | | | `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
| `` e `` | Edit | Wijzig remote | | `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch remote | | `` f `` | Fetch | Fetch remote |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |

View file

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

View file

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

View file

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

27
go.mod
View file

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

57
go.sum
View file

@ -25,22 +25,20 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w= github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU=
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw= github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
@ -73,10 +71,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -100,12 +98,11 @@ github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeD
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@ -114,8 +111,8 @@ github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rY
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w= github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y= github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs= github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs=
@ -125,28 +122,30 @@ github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304/go.
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -175,14 +174,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -196,5 +195,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -538,6 +538,110 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
} }
} }
func TestCommitLoaderGetHydratedTodoCommitsReusesExistingCommit(t *testing.T) {
hashPool := &utils.StringPool{}
runner := oscommands.NewFakeRunner(t)
loader := &CommitLoader{
cmd: oscommands.NewDummyCmdObjBuilder(runner),
}
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: "0123456789012345678901234567890123456789",
Name: "hydrated subject",
AuthorName: "Jane Doe",
AuthorEmail: "jane@example.com",
UnixTimestamp: 1234,
Parents: []string{"1123456789012345678901234567890123456789"},
Status: models.StatusRebasing,
Action: todo.Pick,
})
refreshedTodo := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingCommit.Hash(),
Name: "subject from the todo file",
Status: models.StatusConflicted,
Action: todo.Fixup,
ActionFlag: "-C",
})
commits, err := loader.getHydratedTodoCommits(
hashPool,
[]*models.Commit{refreshedTodo},
[]*models.Commit{existingCommit},
false,
)
assert.NoError(t, err)
assert.Equal(t, []*models.Commit{
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingCommit.Hash(),
Name: "hydrated subject",
AuthorName: "Jane Doe",
AuthorEmail: "jane@example.com",
UnixTimestamp: 1234,
Parents: []string{"1123456789012345678901234567890123456789"},
Status: models.StatusConflicted,
Action: todo.Fixup,
ActionFlag: "-C",
}),
}, commits)
assert.Equal(t, todo.Pick, existingCommit.Action)
assert.Equal(t, models.StatusRebasing, existingCommit.Status)
runner.CheckForMissingCalls()
}
func TestCommitLoaderGetHydratedTodoCommitsLoadsMissingCommit(t *testing.T) {
hashPool := &utils.StringPool{}
existingHash := "0123456789012345678901234567890123456789"
missingHash := "2123456789012345678901234567890123456789"
missingCommitOutput := strings.ReplaceAll(
`+2123456789012345678901234567890123456789|1235|John Doe|john@example.com||>|tag: new|new subject`,
"|",
"\x00",
)
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(
[]string{
"-c", "log.showSignature=false", "show", "--no-patch", "--oneline", "--abbrev=20",
prettyFormat, missingHash,
},
missingCommitOutput,
nil,
)
loader := &CommitLoader{
cmd: oscommands.NewDummyCmdObjBuilder(runner),
}
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingHash,
Name: "existing subject",
Status: models.StatusRebasing,
Action: todo.Pick,
})
refreshedTodos := []*models.Commit{
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingHash,
Status: models.StatusRebasing,
Action: todo.Pick,
}),
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: missingHash,
Status: models.StatusRebasing,
Action: todo.Edit,
}),
}
commits, err := loader.getHydratedTodoCommits(
hashPool,
refreshedTodos,
[]*models.Commit{existingCommit},
false,
)
assert.NoError(t, err)
assert.Len(t, commits, 2)
assert.Equal(t, "existing subject", commits[0].Name)
assert.Equal(t, "new subject", commits[1].Name)
assert.Equal(t, todo.Edit, commits[1].Action)
runner.CheckForMissingCalls()
}
func TestCommitLoader_setCommitStatuses(t *testing.T) { func TestCommitLoader_setCommitStatuses(t *testing.T) {
type scenario struct { type scenario struct {
testName string testName string

View file

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

View file

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

View file

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

View file

@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"os/exec"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
return queryString, variables return queryString, variables
} }
// GetAuthToken returns the token to authenticate against the given host with,
// or an empty string if there is none.
//
// The token has to come from gh itself rather than from an in-process lookup
// with go-gh: that reads gh's config file once per process and answers from
// that snapshot ever after, whereas gh rewrites the file whenever the active
// account changes, and keeps the active account's token either there or in the
// system keyring. Under a long-running lazygit the snapshot therefore drifts
// out of date, leaving us with a token for an account that is no longer active,
// or with no token at all.
func (self *GitHubCommands) GetAuthToken(host string) string { func (self *GitHubCommands) GetAuthToken(host string) string {
token, _ := auth.TokenForHost(host) ghExe := ghExecutable()
return token if ghExe == "" {
// Without gh installed, the environment variables and config file that
// gh would have consulted are still worth a look.
token, _ := auth.TokenFromEnvOrConfig(host)
return token
}
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
// Not being logged in to this host is a normal state rather than
// something to report; the runner logs gh's stderr for the rest.
return ""
}
return strings.TrimSpace(output)
}
// ghExecutable returns the path of the gh binary, or an empty string if it
// isn't installed.
func ghExecutable() string {
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
return ghExe
}
// A gh found in the current directory rather than on PATH comes back as
// exec.ErrDot, which we treat as not having found one at all.
ghExe, err := exec.LookPath("gh")
if err != nil {
return ""
}
return ghExe
} }
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo // FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,13 +50,13 @@ type editPreset struct {
suspend func() bool suspend func() bool
} }
func returnBool(a bool) func() bool { return (func() bool { return a }) } func returnBool(a bool) func() bool { return func() bool { return a } }
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md // IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset { func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012. // By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") { if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end` nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end` nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end` nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`

View file

@ -191,6 +191,51 @@ func validateCustomCommandKey(key Keybinding) error {
return nil return nil
} }
// ValidCustomCommandContexts lists the names a custom command's 'context' may
// use. It mirrors context.AllContextKeys in the gui package, which this package
// can't import; a test over there keeps the two in sync.
var ValidCustomCommandContexts = []string{
"global",
"status",
"files",
"localBranches",
"remotes",
"worktrees",
"remoteBranches",
"tags",
"commits",
"reflogCommits",
"subCommits",
"commitFiles",
"stash",
"normal",
"normalSecondary",
"staging",
"stagingSecondary",
"patchBuilding",
"patchBuildingSecondary",
"mergeConflicts",
"menu",
"confirmation",
"prompt",
"search",
"commitMessage",
"submodules",
"suggestions",
"cmdLog",
}
func validateCustomCommandContext(context string) error {
for _, name := range strings.Split(context, ",") {
name = strings.TrimSpace(name)
if !slices.Contains(ValidCustomCommandContexts, name) {
return fmt.Errorf("Unknown context '%s' for custom command. Allowed values: %s",
name, strings.Join(ValidCustomCommandContexts, ", "))
}
}
return nil
}
func validateCustomCommands(customCommands []CustomCommand) error { func validateCustomCommands(customCommands []CustomCommand) error {
for _, customCommand := range customCommands { for _, customCommand := range customCommands {
if err := validateCustomCommandKey(customCommand.Key); err != nil { if err := validateCustomCommandKey(customCommand.Key); err != nil {
@ -216,6 +261,15 @@ func validateCustomCommands(customCommands []CustomCommand) error {
return err return err
} }
} else { } else {
// A command in a menu may leave the context out, in which case it is
// offered whatever is focused; a top-level one may not, but that is
// only noticed when the keybindings are built.
if customCommand.Context != "" {
if err := validateCustomCommandContext(customCommand.Context); err != nil {
return err
}
}
for _, prompt := range customCommand.Prompts { for _, prompt := range customCommand.Prompts {
if err := validateCustomCommandPrompt(prompt); err != nil { if err := validateCustomCommandPrompt(prompt); err != nil {
return err return err

View file

@ -225,6 +225,43 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false}, {value: "invalid_value", valid: false},
}, },
}, },
{
name: "Custom command context",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Context: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "global", valid: true},
{value: "commits", valid: true},
{value: "commits, subCommits", valid: true},
{value: "commits,subCommits", valid: true},
{value: "invalid_value", valid: false},
{value: "commits, invalid_value", valid: false},
},
},
{
name: "Custom command context in a sub menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "commits", valid: true},
{value: "invalid_value", valid: false},
},
},
{ {
name: "Custom command sub menu", name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) { setup: func(config *UserConfig, _ string) {

44
pkg/env/env.go vendored
View file

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

View file

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

View file

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

View file

@ -38,6 +38,11 @@ var (
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further // ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled") ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
// returned. Nothing dequeues user events after that, so the callback it was
// asked to run on the main goroutine never will be.
ErrLoopExited = standardErrors.New("main loop exited")
) )
const ( const (
@ -217,6 +222,11 @@ type Gui struct {
// worker goroutines, so it's atomic. // worker goroutines, so it's atomic.
uiThreadID atomic.Int64 uiThreadID atomic.Int64
// focused says whether the terminal we're running in has focus, as far as
// its focus reports tell us (see IsFocused). Written by the event loop,
// readable from anywhere, so it's atomic.
focused atomic.Bool
// blockInputCount, when greater than zero, withholds keyboard input from // blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed // the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are // once the count drops back to zero, while mouse clicks and hover are
@ -301,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
// runs during startup, before we reach MainLoop. // runs during startup, before we reach MainLoop.
g.uiThreadID.Store(goid.Get()) g.uiThreadID.Store(goid.Get())
// Assume we start out focused: a terminal that supports focus reports sends
// one for the state it is already in when we turn reporting on in MainLoop,
// and passing that on as a change would have the app react to a change that
// never happened.
g.focused.Store(true)
return g, nil return g, nil
} }
@ -659,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
} }
// SetTabClickBinding sets a binding for a tab click event // SetTabClickBinding sets a binding for a tab click event
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error { func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) {
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{ g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
viewName: viewName, viewName: viewName,
handler: handler, handler: handler,
}) })
return nil
} }
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error { func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) {
g.viewMouseBindings = append(g.viewMouseBindings, binding) g.viewMouseBindings = append(g.viewMouseBindings, binding)
return nil
} }
// captureMouse routes subsequent mouse events to view until the mouse button is // captureMouse routes subsequent mouse events to view until the mouse button is
@ -893,36 +905,50 @@ func (g *Gui) EndBlockingEvents() error {
} }
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the // OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
// caller until f has run, returning f's error. Use it to read UI-thread-owned // caller until f has run. Use it to read UI-thread-owned state (the model,
// state (the model, contexts) from a worker without racing the UI thread. // contexts) from a worker without racing the UI thread.
//
// The error it returns is the wait's own, never f's: it reports that f was not
// run at all, which happens when the main loop has exited (ErrLoopExited). f
// doesn't report an error because what callers want on the UI thread — reading
// and mutating state — doesn't fail.
// //
// It must be called from a worker goroutine, never from the UI thread itself: // It must be called from a worker goroutine, never from the UI thread itself:
// the UI thread would block waiting for a callback only it can run, which // the UI thread would block waiting for a callback only it can run, which
// deadlocks. Callers arrange this by construction (see the refresh helper's // deadlocks. Callers arrange this by construction (see the refresh helper's
// RefreshFromWorker); a debug-only assertion there guards against getting it // RefreshFromWorker); a debug-only assertion there guards against getting it
// wrong. // wrong.
func (g *Gui) OnUIThreadAndWait(f func() error) error { func (g *Gui) OnUIThreadAndWait(f func()) error {
return g.onUIThreadAndWait(f, false) return g.onUIThreadAndWait(f, false)
} }
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine, // Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
// so it doesn't count towards the program being busy (see UpdateBackground). // so it doesn't count towards the program being busy (see UpdateBackground).
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error { func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
return g.onUIThreadAndWait(f, true) return g.onUIThreadAndWait(f, true)
} }
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error { func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
enqueue := g.Update enqueue := g.Update
if background { if background {
enqueue = g.UpdateBackground enqueue = g.UpdateBackground
} }
result := make(chan error, 1) ran := make(chan struct{})
enqueue(func(*Gui) error { enqueue(func(*Gui) error {
result <- f() f()
close(ran)
return nil return nil
}) })
return <-result
select {
case <-ran:
return nil
case <-g.loopExited:
// The queue we just enqueued onto is no longer being served, so waiting
// on `ran` here would mean waiting for the rest of the process's life.
return ErrLoopExited
}
} }
// Calls a function in a goroutine. Handles panics gracefully and tracks // Calls a function in a goroutine. Handles panics gracefully and tracks
@ -1246,7 +1272,7 @@ func calcScrollbarRune(
func calcRealScrollbarStartEnd(v *View) (bool, int, int) { func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
height := v.InnerHeight() height := v.InnerHeight()
fullHeight := v.ViewLinesHeight() - v.scrollMargin() fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
if v.CanScrollPastBottom { if v.CanScrollPastBottom {
fullHeight += height fullHeight += height
@ -1428,7 +1454,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
currentBgColor = v.BgColor currentBgColor = v.BgColor
} }
if i >= currentTabStart && i <= currentTabEnd { if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() {
currentFgColor = v.SelFgColor currentFgColor = v.SelFgColor
if v != g.currentView { if v != g.currentView {
currentFgColor &= ^AttrBold currentFgColor &= ^AttrBold
@ -1467,7 +1493,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
// drawListFooter draws the footer of a list view, showing something like '1 of 10' // drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.lines) == 0 { if len(v.buf.lines) == 0 {
return nil return nil
} }
@ -1589,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views) return g.flushContentOnly(views)
} }
// hasFocus reports whether a view is drawn as focused. Views that are embedded
// in one another (see View.ParentView) form a single unit, so they are all drawn
// as focused while any one of them is the current view.
func (g *Gui) hasFocus(v *View) bool {
return g.currentView != nil && outermostView(v) == outermostView(g.currentView)
}
func outermostView(v *View) *View {
for v.ParentView != nil {
v = v.ParentView
}
return v
}
// draw manages the cursor and calls the draw function of a view. // draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error { func (g *Gui) draw(v *View) error {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
@ -1609,11 +1649,11 @@ func (g *Gui) draw(v *View) error {
Screen.HideCursor() Screen.HideCursor()
} }
v.draw() v.draw(g.IsFocused())
if v.Frame { if v.Frame {
var fgColor, bgColor, frameColor Attribute var fgColor, bgColor, frameColor Attribute
if g.Highlight && v == g.currentView { if g.Highlight && g.hasFocus(v) && g.IsFocused() {
fgColor = g.SelFgColor fgColor = g.SelFgColor
bgColor = g.SelBgColor bgColor = g.SelBgColor
frameColor = g.SelFrameColor frameColor = g.SelFrameColor
@ -1717,13 +1757,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if newY < 0 { if newY < 0 {
newY = 0 newY = 0
newCy = -v.oy newCy = -v.oy
} else if newY >= len(v.lines) { } else if newY >= len(v.buf.lines) {
newY = len(v.lines) - 1 newY = len(v.buf.lines) - 1
newCy = newY - v.oy newCy = newY - v.oy
} }
visibleLineWidth := 0 visibleLineWidth := 0
for _, c := range v.lines[newY].cells { for _, c := range v.buf.lines[newY].cells {
visibleLineWidth += c.width visibleLineWidth += c.width
} }
if visibleLineWidth < newX { if visibleLineWidth < newX {
@ -1733,10 +1773,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
} }
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil { if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 { if link := v.hyperlinkAt(newX, newY); link != "" {
if link := v.viewLines[newY].line[newX].hyperlink; link != "" { return g.openHyperlink(link, v.name)
return g.openHyperlink(link, v.name)
}
} }
} }
@ -1955,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
matchingParentViewKb = nil matchingParentViewKb = nil
break break
} }
if v != nil && g.matchView(v.ParentView, kb) { if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = kb matchingParentViewKb = kb
} }
if globalKb == nil && kb.viewName == "" { if globalKb == nil && kb.viewName == "" {
@ -1990,7 +2028,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
return nil return nil
} }
// IsFocused reports whether the terminal we're running in has focus. Terminals
// that don't report focus at all leave this true for good.
func (g *Gui) IsFocused() bool {
return g.focused.Load()
}
func (g *Gui) onFocus(ev *GocuiEvent) error { func (g *Gui) onFocus(ev *GocuiEvent) error {
// Terminals report their focus state when we turn focus reporting on, and
// some report it again when their window is activated, so only pass on the
// reports that actually change it.
if ev.Focused == g.focused.Load() {
return nil
}
g.focused.Store(ev.Focused)
if g.focusHandler != nil { if g.focusHandler != nil {
return g.focusHandler(ev.Focused) return g.focusHandler(ev.Focused)
} }
@ -2053,13 +2105,15 @@ func (g *Gui) isSuspended() bool {
return g.suspended return g.suspended
} }
// matchView returns if the keybinding matches the current view (and the view's context) // matchView returns if the keybinding matches the given view (and the view's context)
func (g *Gui) matchView(v *View, kb *keybinding) bool { func (g *Gui) matchView(v *View, kb *keybinding) bool {
// if the user is typing in a field, ignore char keys
if v == nil { if v == nil {
return false return false
} }
if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 { // If the user is typing in a field, printable keys are theirs to type, so no
// keybinding gets a look at them: not the field's own, and not those of the
// view it is embedded in either.
if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() {
return false return false
} }
if kb.viewName != v.name { if kb.viewName != v.name {

View file

@ -61,6 +61,12 @@ func (k Key) IsSet() bool {
return k.keyName != 0 return k.keyName != 0
} }
// IsPrintable reports whether the key stands for a character that can be typed
// into a text field.
func (k Key) IsPrintable() bool {
return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone
}
func (k Key) Equals(otherKey Key) bool { func (k Key) Equals(otherKey Key) bool {
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
} }

16
pkg/gocui/key_test.go Normal file
View file

@ -0,0 +1,16 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestKeyIsPrintable(t *testing.T) {
assert.True(t, NewKeyRune('x').IsPrintable())
assert.True(t, NewKeyRune('界').IsPrintable())
assert.True(t, NewKeyRune(' ').IsPrintable())
assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable())
assert.False(t, NewKeyName(KeyEnter).IsPrintable())
assert.False(t, Key{}.IsPrintable())
}

View file

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

View file

@ -0,0 +1,142 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
// A view and its parent view, with the child holding the focus.
func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) {
t.Helper()
parent, _ := g.SetView("parent", 0, 0, 20, 10, 0)
child, _ := g.SetView("child", 0, 10, 20, 12, 0)
child.ParentView = parent
_, err := g.SetCurrentView(child.Name())
assert.NoError(t, err)
return parent, child
}
func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
pressed := []string{}
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
pressed = append(pressed, "parent")
return nil
})
g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error {
pressed = append(pressed, "child")
return nil
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)}))
assert.Equal(t, []string{"parent", "child"}, pressed)
}
func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) {
g := newTestGui(t)
parent, _ := setupParentAndChildView(t, g)
pressed := []string{}
for _, name := range []string{"first", "second"} {
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
pressed = append(pressed, name)
return nil
})
}
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.Equal(t, []string{"first"}, pressed)
}
func TestEmbeddedViewsAreFocusedTogether(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0)
sibling.ParentView = parent
unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0)
assert.True(t, g.hasFocus(child))
assert.True(t, g.hasFocus(parent))
assert.True(t, g.hasFocus(sibling))
assert.False(t, g.hasFocus(unrelated))
_, err := g.SetCurrentView(unrelated.Name())
assert.NoError(t, err)
assert.True(t, g.hasFocus(unrelated))
assert.False(t, g.hasFocus(parent))
assert.False(t, g.hasFocus(child))
}
func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) {
for _, test := range []struct {
name string
keybindOnEdit bool
declineKeybinding bool
expectedPresses int
expectedEdits int
}{
{name: "the field gets the key", expectedEdits: 1},
{name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1},
{
name: "the field gets the key the parent view declined",
keybindOnEdit: true,
declineKeybinding: true,
expectedPresses: 1,
expectedEdits: 1,
},
} {
t.Run(test.name, func(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
child.Editable = true
child.KeybindOnEdit = test.keybindOnEdit
edits := 0
child.Editor = EditorFunc(func(*View, Key) bool {
edits++
return true
})
presses := 0
g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error {
presses++
if test.declineKeybinding {
return ErrKeybindingNotHandled
}
return nil
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')}))
assert.Equal(t, test.expectedPresses, presses)
assert.Equal(t, test.expectedEdits, edits)
})
}
}
func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
edited := []Key{}
child.Editable = true
child.Editor = EditorFunc(func(_ *View, key Key) bool {
edited = append(edited, key)
return true
})
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
return ErrKeybindingNotHandled
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited)
}

58
pkg/gocui/search_test.go Normal file
View file

@ -0,0 +1,58 @@
package gocui
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// writeLines writes the given lines to the view, as a task rendering content into it
// does: one line at a time.
func writeLines(v *View, lines ...string) {
for _, line := range lines {
fmt.Fprintf(v, "%s\n", line)
}
}
func TestSearchStatusAfterTheMatchesChange(t *testing.T) {
v := NewView("name", 0, 0, 40, 10, OutputNormal)
writeLines(v, "match", "other", "match", "other", "match")
v.Search("match", nil)
_ = v.gotoNextMatch()
_ = v.gotoNextMatch()
index, total := v.GetSearchStatus()
assert.Equal(t, 2, index)
assert.Equal(t, 3, total)
// The content is re-rendered with only the first of those matches left in it.
v.Clear()
writeLines(v, "match", "other", "other")
index, total = v.GetSearchStatus()
assert.Equal(t, 0, index)
assert.Equal(t, 1, total)
}
func TestSearchPositionsFollowStreamedContent(t *testing.T) {
v := NewView("name", 0, 0, 40, 10, OutputNormal)
v.Search("match", nil)
// A render arrives a line at a time, and the status describes all of it.
writeLines(v, "other", "match", "other", "match")
_, total := v.GetSearchStatus()
assert.Equal(t, 2, total)
}
func BenchmarkWriteToSearchedView(b *testing.B) {
for b.Loop() {
v := NewView("name", 0, 0, 100, 40, OutputNormal)
v.Search("match", nil)
for i := range 2000 {
fmt.Fprintf(v, "line %d of a diff, most of which does not match\n", i)
}
v.GetSearchStatus()
}
}

View file

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

View file

@ -0,0 +1,43 @@
package gocui
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// errStillWaiting stands in for the result of a wait that hasn't produced one.
var errStillWaiting = errors.New("still waiting")
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
// returned by the time we give up on it.
func resultOrTimeout(result chan error) error {
select {
case err := <-result:
return err
case <-time.After(time.Second):
return errStillWaiting
}
}
// A worker waiting for the UI thread must not be left parked there once the
// main loop has stopped: nothing will ever run its callback, and the shutdown
// that follows blocks until such workers have finished (see
// tasks.ViewBufferManager.Close).
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
g := newTestGui(t)
// Closing this is what MainLoop returning does. From here on nothing
// dequeues user events, so the callback below is never going to run.
close(g.loopExited)
result := make(chan error, 1)
go func() {
result <- g.OnUIThreadAndWait(func() {})
}()
err := resultOrTimeout(result)
assert.ErrorIs(t, err, ErrLoopExited)
}

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import (
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color" "github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
"github.com/samber/lo"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -101,15 +102,13 @@ func TestWriteString(t *testing.T) {
for _, test := range tests { for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal) v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines { for _, l := range test.existingLines {
v.lines = append(v.lines, lineType{cells: stringToCells(l)}) v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
} }
for _, s := range test.stringsToWrite { for _, s := range test.stringsToWrite {
v.writeString(s) v.writeString(s)
} }
var resultingLines [][]string resultingLines := lo.Map(v.buf.lines,
for _, l := range v.lines { func(l lineType, _ int) []string { return cellsToStrings(l.cells) })
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
assert.Equal(t, test.expectedLines, resultingLines) assert.Equal(t, test.expectedLines, resultingLines)
} }
} }
@ -144,19 +143,115 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt") v.writeString("htt")
// No hyperlinks are generated for incomplete URLs // No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.lines[0].cells[0].hyperlink) assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even // Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet) // though we didn't see a newline yet)
v.writeString("ps://example.com") v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
v.Clear() v.Clear()
// Valid but incomplete URL // Valid but incomplete URL
v.writeString("https://exa") v.writeString("https://exa")
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink) assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link // Writing more characters to the same fixes the link
v.writeString("mple.com") v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
func TestOffscreenRender(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString("a\nb\nc")
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Render new, longer content off-screen.
v.BeginOffscreenRender()
v.writeString("w\nx\ny\nz")
// The displayed buffer is untouched: readers still see the previous render.
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Swapping in reveals the new content in one step.
v.SwapInOffscreenRender()
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
// A further write now appends to the displayed buffer directly.
v.writeString("\nmore")
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
}
// When a render produces fewer view lines than the previous one,
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
// than leaving the previous render's entries in the tail: with the off-screen
// render there is no half-loaded buffer whose tail we'd want to keep showing,
// and a leftover tail is just stale lines describing content that is gone.
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Two lines of 27 characters each wrap into 3 view lines apiece.
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
assert.Equal(t, 6, v.ViewLinesHeight())
// Re-render with three short, unwrapped lines: only 3 view lines remain.
v.BeginOffscreenRender()
v.writeString("aaa\nbbb\nccc")
v.SwapInOffscreenRender()
assert.Equal(t, 3, v.ViewLinesHeight())
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
// snap back as the rest streams in. See View.scrollbarHeightFloor.
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
// Initial render: 100 lines, scrolled well down.
v.writeString(strings.Repeat("x\n", 100))
v.SetOrigin(0, 80)
assert.Equal(t, 100, v.scrollbarContentHeight())
// A re-render begins while the previous render is still shown: hold the
// scrollbar height at the current value.
v.FreezeScrollbarHeight()
// The off-screen render swaps in only a screenful at its first paint.
v.BeginOffscreenRender()
v.writeString(strings.Repeat("y\n", 30))
v.SwapInOffscreenRender()
// The displayed buffer is now short, but the scrollbar height stays held, so
// the thumb keeps its position instead of jumping.
assert.Equal(t, 30, v.ViewLinesHeight())
assert.Equal(t, 100, v.scrollbarContentHeight())
// The rest of the content streams in.
v.writeString(strings.Repeat("y\n", 70))
assert.Equal(t, 100, v.scrollbarContentHeight())
// Once the load ends, the scrollbar tracks the real content directly again.
v.UnfreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
}
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
// before it reaches its end, the held scrollbar height must be released, so the
// scrollbar reflects the new content rather than the abandoned load's height.
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
v.writeString(strings.Repeat("x\n", 100))
v.FreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
// A synchronous render replaces the content before the (notional) load ends.
v.SetContent("just a few\nshort lines\nhere")
assert.Equal(t, 3, v.scrollbarContentHeight())
} }
func TestContainsColoredText(t *testing.T) { func TestContainsColoredText(t *testing.T) {
@ -233,7 +328,7 @@ func TestContainsColoredText(t *testing.T) {
for j, cells := range test.lines { for j, cells := range test.lines {
lines[j] = lineType{cells: cells} lines[j] = lineType{cells: cells}
} }
v := &View{lines: lines} v := &View{buf: &viewBuffer{lines: lines}}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i) assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
} }
} }
@ -248,8 +343,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
// "a", then "skip to row 3" (i.e. one blank row), then "b". // "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n") v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.lines)) got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.lines { for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
@ -269,8 +364,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row. // ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n") v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.lines)) got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.lines { for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
assert.Equal(t, [][]string{ assert.Equal(t, [][]string{
@ -282,6 +377,31 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
}, got) }, got)
} }
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
// Soft-wrap counting has to work in an off-screen render too: the content
// width the parser counts wraps against is set by SetContentWidth before the
// render starts, so the off-screen buffer's parser has to pick it up. If it
// doesn't, no wraps are counted and the CUP below is evaluated against a
// stale row, overshooting into an extra blank line.
v := NewView("name", 0, 0, 30, 30, OutputNormal)
v.SetContentWidth(5)
v.BeginOffscreenRender()
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
// row 2; CUP to row 3 should then skip no rows at all.
v.writeString("aaaaaaa\x1b[3;1Hb\n")
v.SwapInOffscreenRender()
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
{"a", "a", "a", "a", "a", "a", "a"},
{"b"},
}, got)
}
func TestWriteCursorForwardEscape(t *testing.T) { func TestWriteCursorForwardEscape(t *testing.T) {
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX, // ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward // "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
@ -292,8 +412,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
// "a" + ECH 5 + CUF 5 + "b" — visually "a b". // "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n") v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.lines)) got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.lines { for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
@ -312,8 +432,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
v.writeString("abcdefghij\n") v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n") v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.lines)) got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.lines { for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells)) got = append(got, cellsToStrings(l.cells))
} }
assert.Equal(t, [][]string{ assert.Equal(t, [][]string{
@ -344,11 +464,7 @@ func cellsToString(cells []cell) string {
} }
func cellsToStrings(cells []cell) []string { func cellsToStrings(cells []cell) []string {
s := []string{} return lo.Map(cells, func(c cell, _ int) string { return c.chr })
for _, c := range cells {
s = append(s, c.chr)
}
return s
} }
func TestLineWrap(t *testing.T) { func TestLineWrap(t *testing.T) {
@ -534,7 +650,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
// renders with bg=red. The trailing area past "foo" must NOT extend // renders with bg=red. The trailing area past "foo" must NOT extend
// the red bg because '\n' marks the line as cleanly terminated. // the red bg because '\n' marks the line as cleanly terminated.
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n") v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
v.draw() v.draw(true)
// First row: cells 1..3 are "foo" (render with red bg via reverse), // First row: cells 1..3 are "foo" (render with red bg via reverse),
// cells 4..10 are trailing and should be plain default. // cells 4..10 are trailing and should be plain default.
@ -560,7 +676,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
// Reverse + red fg, "foo", no termination. The trailing cells past // Reverse + red fg, "foo", no termination. The trailing cells past
// "foo" should be plain default, NOT a continuation of the red bg. // "foo" should be plain default, NOT a continuation of the red bg.
v.writeString("\x1b[7m\x1b[31mfoo") v.writeString("\x1b[7m\x1b[31mfoo")
v.draw() v.draw(true)
// Cells 4..10 are trailing and should be default with no reverse. // Cells 4..10 are trailing and should be default with no reverse.
for x := 4; x <= 10; x++ { for x := 4; x <= 10; x++ {
@ -583,7 +699,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should // \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
// fill the remaining 8 cells with red. // fill the remaining 8 cells with red.
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n") v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
v.draw() v.draw(true)
// All ten cells at (1..10, 1) should have red bg. // All ten cells at (1..10, 1) should have red bg.
for x := 1; x <= 10; x++ { for x := 1; x <= 10; x++ {
@ -611,7 +727,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area // segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
// must pick up the red fill from \x1b[K. // must pick up the red fill from \x1b[K.
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n") v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
v.draw() v.draw(true)
// All three wrapped rows should have the red fill background across // All three wrapped rows should have the red fill background across
// the full InnerWidth, including the trailing cells past each row's // the full InnerWidth, including the trailing cells past each row's
@ -645,7 +761,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
// last cell red) and segment 2 is "ccc" (green, last cell green). // last cell red) and segment 2 is "ccc" (green, last cell green).
// \x1b[K records the green bg on the source line. // \x1b[K records the green bg on the source line.
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n") v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
v.draw() v.draw(true)
// Row 1's content ends with a red cell at x=7, so trailing columns // Row 1's content ends with a red cell at x=7, so trailing columns
// 8..10 should pick up red rather than the \x1b[K's green. // 8..10 should pick up red rather than the \x1b[K's green.

View file

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

View file

@ -3,6 +3,7 @@ package gui
import ( import (
"sync" "sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
@ -179,11 +180,8 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.helpers.Window.SetWindowContext(c) self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c) self.gui.helpers.Window.MoveToTopOfWindow(c)
oldView := self.gui.c.GocuiGui().CurrentView() inputViewName := c.GetInputViewName()
if oldView != nil && oldView.Name() != viewName { if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
oldView.HighlightInactive = true
}
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
panic(err) panic(err)
} }
@ -198,9 +196,37 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == "" self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.updateSelectionHighlights()
c.HandleFocus(opts) c.HandleFocus(opts)
} }
// updateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
//
// Both of those can change, so this is called wherever they do: from Activate, which
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) updateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
onStack := set.NewFromSlice(lo.Map(self.ContextStack,
func(c types.Context, _ int) types.ContextKey { return c.GetKey() }))
currentKey := self.currentContextWithoutLock().GetKey()
for _, c := range self.allContexts.Flatten() {
// The global context has no view of its own.
if view := c.GetView(); view != nil {
view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent()
view.HighlightInactive = c.GetKey() != currentKey
}
}
}
func (self *ContextMgr) Current() types.Context { func (self *ContextMgr) Current() types.Context {
self.RLock() self.RLock()
defer self.RUnlock() defer self.RUnlock()

View file

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

View file

@ -0,0 +1,22 @@
package context
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
// The config package validates a custom command's context against its own copy of
// these names, being unable to import this package. A name in one list but not the
// other would be either a context that validation rejects although you can bind to
// it, or one it accepts although binding to it exits lazygit.
func TestValidCustomCommandContextsMatchesAllContextKeys(t *testing.T) {
keys := lo.Map(AllContextKeys, func(key types.ContextKey, _ int) string {
return string(key)
})
assert.Equal(t, keys, config.ValidCustomCommandContexts)
}

View file

@ -1,7 +1,5 @@
package context package context
import "github.com/jesseduffield/lazygit/pkg/i18n"
type FilteredListViewModel[T HasID] struct { type FilteredListViewModel[T HasID] struct {
*FilteredList[T] *FilteredList[T]
*ListViewModel[T] *ListViewModel[T]
@ -35,8 +33,3 @@ func (self *FilteredListViewModel[T]) ClearFilter() {
self.SetSelection(unfilteredIndex) self.SetSelection(unfilteredIndex)
} }
// Default implementation of most filterable contexts. Can be overridden if needed.
func (self *FilteredListViewModel[T]) FilterPrefix(tr *i18n.TranslationSet) string {
return tr.FilterPrefix
}

View file

@ -28,10 +28,19 @@ type ListContextTrait struct {
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search // true if we're inside the OnSearchSelect call; in that case we don't want to update the search
// result index. // result index.
inOnSearchSelect bool inOnSearchSelect bool
// If set, this renders the "x of y" footer instead of the default, which puts
// it on the bottom border of the list's own view. A list that is part of a
// composite panel can use this to put it somewhere else; see MenuContext.
renderFooter func(footer string)
} }
func (self *ListContextTrait) IsListContext() {} func (self *ListContextTrait) IsListContext() {}
func (self *ListContextTrait) HasSelectableContent() bool {
return self.list.Len() > 0
}
func (self *ListContextTrait) FocusLine(scrollIntoView bool) { func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.Context.FocusLine(scrollIntoView) self.Context.FocusLine(scrollIntoView)
@ -81,7 +90,13 @@ func (self *ListContextTrait) refreshViewport() {
} }
func (self *ListContextTrait) setFooter() { func (self *ListContextTrait) setFooter() {
self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())) footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())
if self.renderFooter != nil {
self.renderFooter(footer)
return
}
self.GetViewTrait().SetFooter(footer)
} }
func formatListFooter(selectedLineIdx int, length int) string { func formatListFooter(selectedLineIdx int, length int) string {
@ -89,9 +104,7 @@ func formatListFooter(selectedLineIdx int, length int) string {
} }
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
self.FocusLine(opts.ScrollSelectionIntoView) self.FocusLine(!opts.KeepScrollPosition)
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
self.Context.HandleFocus(opts) self.Context.HandleFocus(opts)
} }

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -45,6 +44,13 @@ func NewMenuContext(
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment }, getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
getNonModelItems: viewModel.GetNonModelItems, getNonModelItems: viewModel.GetNonModelItems,
}, },
// While the filter row is showing, its top border covers the menu's bottom
// border, so the footer has to be rendered on the row instead.
renderFooter: func(footer string) {
onFilterRow := viewModel.FilterStarted()
c.Views().Menu.Footer = lo.Ternary(onFilterRow, "", footer)
c.Views().MenuFilterFrame.Footer = lo.Ternary(onFilterRow, footer, "")
},
c: c, c: c,
}, },
} }
@ -58,6 +64,8 @@ type MenuViewModel struct {
columnAlignment []utils.Alignment columnAlignment []utils.Alignment
allowFilteringKeybindings bool allowFilteringKeybindings bool
keybindingsTakePrecedence bool keybindingsTakePrecedence bool
filterAsYouType bool
filterStarted bool
onCancel func() error onCancel func() error
*FilteredListViewModel[*types.MenuItem] *FilteredListViewModel[*types.MenuItem]
} }
@ -128,10 +136,37 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
self.allowFilteringKeybindings = allow self.allowFilteringKeybindings = allow
} }
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
return self.allowFilteringKeybindings
}
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) { func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
self.keybindingsTakePrecedence = value self.keybindingsTakePrecedence = value
} }
// Whether this menu has a filter row that filters the items as the user types,
// instead of being filtered through the search prompt.
func (self *MenuViewModel) SetFilterAsYouType(value bool) {
self.filterAsYouType = value
self.SetFilterStarted(false)
}
func (self *MenuViewModel) FilterAsYouType() bool {
return self.filterAsYouType
}
// Whether the user has started to filter, which is when the filter row appears.
func (self *MenuViewModel) SetFilterStarted(value bool) {
self.filterStarted = value
// As long as there is nothing to type into, printable keys keep driving the
// menu, so that the configured navigation keys work like in any other menu.
self.c.Views().MenuFilter.KeybindOnEdit = !value
}
func (self *MenuViewModel) FilterStarted() bool {
return self.filterStarted
}
// TODO: move into presentation package // TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
menuItems := self.FilteredListViewModel.GetItems() menuItems := self.FilteredListViewModel.GetItems()
@ -209,6 +244,16 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
basicBindings := self.ListContextTrait.GetKeybindings(opts) basicBindings := self.ListContextTrait.GetKeybindings(opts)
if self.filterAsYouType {
// A menu item's keys are shown as a reminder of what they do outside the
// menu, but pressing one types it into the filter rather than executing the
// item, so we don't bind them at all. That leaves the bindings that drive
// the menu itself, and the printable ones among those give way to the filter
// as soon as there is something to type into (see View.KeybindOnEdit).
return basicBindings
}
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool { menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
return len(item.Keys) > 0 return len(item.Keys) > 0
}) })
@ -267,10 +312,13 @@ func (self *MenuContext) RangeSelectEnabled() bool {
return false return false
} }
func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string { // A menu that filters as you type points the keyboard at its filter input, so
if self.allowFilteringKeybindings { // that whatever the user types ends up there. Keys that the input doesn't take
return tr.FilterPrefixMenu // still reach the menu, because the input view is embedded in the menu view.
func (self *MenuContext) GetInputViewName() string {
if self.filterAsYouType {
return self.c.Views().MenuFilter.Name()
} }
return self.FilteredListViewModel.FilterPrefix(tr) return self.GetViewName()
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -138,6 +138,9 @@ func (gui *Gui) resetHelpersAndControllers() {
common := controllers.NewControllerCommon(helperCommon, gui) common := controllers.NewControllerCommon(helperCommon, gui)
listControllerFactory := controllers.NewListControllerFactory(common)
menuListController := listControllerFactory.Create(gui.State.Contexts.Menu)
syncController := controllers.NewSyncController( syncController := controllers.NewSyncController(
common, common,
) )
@ -156,7 +159,7 @@ func (gui *Gui) resetHelpersAndControllers() {
remoteBranchesController := controllers.NewRemoteBranchesController(common) remoteBranchesController := controllers.NewRemoteBranchesController(common)
menuController := controllers.NewMenuController(common) menuController := controllers.NewMenuController(common, menuListController)
localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull) localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
tagsController := controllers.NewTagsController(common) tagsController := controllers.NewTagsController(common)
filesController := controllers.NewFilesController( filesController := controllers.NewFilesController(
@ -359,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Menu, controllers.AttachControllers(gui.State.Contexts.Menu,
menuController, menuController,
menuListController,
) )
controllers.AttachControllers(gui.State.Contexts.CommitMessage, controllers.AttachControllers(gui.State.Contexts.CommitMessage,
@ -412,8 +416,11 @@ func (gui *Gui) resetHelpersAndControllers() {
) )
// this must come last so that we've got our click handlers defined against the context // this must come last so that we've got our click handlers defined against the context
listControllerFactory := controllers.NewListControllerFactory(common)
for _, context := range gui.c.Context().AllList() { for _, context := range gui.c.Context().AllList() {
if context == gui.State.Contexts.Menu {
// already attached above, next to the menu controller that delegates to it
continue
}
controllers.AttachControllers(context, listControllerFactory.Create(context)) controllers.AttachControllers(context, listControllerFactory.Create(context))
} }
} }

View file

@ -157,6 +157,18 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
} }
} }
commitTagsItem := &types.MenuItem{
Label: self.c.Tr.CommitTags,
OnPress: func() error {
return self.copyCommitTagsToClipboard(commit)
},
Keys: menuKey('t'),
}
if len(commit.Tags) == 0 {
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
}
items := []*types.MenuItem{ items := []*types.MenuItem{
{ {
Label: self.c.Tr.CommitHash, Label: self.c.Tr.CommitHash,
@ -207,22 +219,9 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
}, },
Keys: menuKey('a'), Keys: menuKey('a'),
}, },
commitTagsItem,
} }
commitTagsItem := types.MenuItem{
Label: self.c.Tr.CommitTags,
OnPress: func() error {
return self.copyCommitTagsToClipboard(commit)
},
Keys: menuKey('t'),
}
if len(commit.Tags) == 0 {
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
}
items = append(items, &commitTagsItem)
return self.c.Menu(types.CreateMenuOptions{ return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard, Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
Items: items, Items: items,

View file

@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
HandleConfirm: func() error { HandleConfirm: func() error {
commits := self.c.Model().Commits commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
var filePaths []string var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
@ -613,24 +616,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
} }
} }
// pathsForDiff returns the file paths to use for a diff command. When a text
// filter is active and the node is a directory, only the visible (filtered)
// file paths are returned so the diff reflects what the user sees.
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
if !node.IsFile() && self.context().IsFiltering() { return diffPathsForNode(
var paths []string node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
_ = node.ForEachFile(func(file *models.CommitFile) error {
// For a rename we need to pass both paths so that git detects it as
// a rename rather than an unrelated delete and add.
paths = append(paths, file.Names()...)
return nil
})
return paths
}
if file := node.GetFile(); file != nil {
return file.Names()
}
return []string{node.GetPath()}
} }
// NOTE: these functions are identical to those in files_controller.go (except for types) and // NOTE: these functions are identical to those in files_controller.go (except for types) and

View file

@ -0,0 +1,132 @@
package controllers
import (
"path"
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/samber/lo"
)
// Both models.File and models.CommitFile satisfy this. Names returns the file's
// path, plus the path it was renamed from if it is a rename.
type fileWithNames[T any] interface {
*T
GetPath() string
GetPreviousPath() string
Names() []string
}
// diffPathsForNode returns the paths to limit a diff command to for showing the
// changes of the given node. files are all the files that the diff contains,
// while root is the root of the tree the node belongs to, which holds only the
// files matching the text filter when there is one.
func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string {
if file := node.GetFile(); file != nil {
return PT(file).Names()
}
dir := node.GetPath()
if isFiltering {
// Passing the directory would bring back the files that the filter hides,
// so we spell out the ones it leaves.
var paths []string
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
paths = append(paths, PT(file).Names()...)
}
return paths
}
// The directory covers everything below it, but git only pairs up the two
// ends of a rename if both are in the pathspec, and one end can well be
// outside the directory. Without that end we would get an addition or a
// deletion where the diff has a rename.
var outsidePaths []string
for _, f := range filesInDir[T, PT](files, dir) {
file := PT(f)
if p := file.GetPath(); !isInDir(p, dir) {
outsidePaths = append(outsidePaths, p)
}
if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) {
outsidePaths = append(outsidePaths, p)
}
}
return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...))
}
// dropContainedPaths removes the paths that another one of them contains, since
// a pathspec that matches a directory matches everything below it anyway.
func dropContainedPaths(paths []string) []string {
return lo.Filter(paths, func(p string, _ int) bool {
return !lo.SomeBy(paths, func(other string) bool {
return other != p && isInDir(p, other)
})
})
}
// collapseToDirs replaces each of the given paths with the highest directory
// that can stand in for it, so that moving a whole directory elsewhere costs a
// single pathspec rather than one per file. There is a limit to how long a
// command line may get, and a commit can move a great many files at once.
func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string {
if len(paths) == 0 {
return nil
}
// A directory can stand in for the paths under it as long as everything it
// contains ends up in the diff anyway, which is to say as long as all of it
// is in the directory we are diffing too.
canStandIn := make(map[string]bool)
standsIn := func(candidate string) bool {
if result, ok := canStandIn[candidate]; ok {
return result
}
result := lo.EveryBy(files, func(file *T) bool {
return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir)
})
canStandIn[candidate] = result
return result
}
return lo.Uniq(lo.Map(paths, func(p string, _ int) string {
// A directory that can't stand in for the path rules out its parents
// too, since they contain everything it contains. We stop short of the
// repository root: it would leave the command with nothing to say about
// the directory whose diff we are showing.
for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) {
p = candidate
}
return p
}))
}
func filesInTree[T any](root *filetree.Node[T]) []*T {
files := []*T{}
_ = root.ForEachFile(func(file *T) error {
files = append(files, file)
return nil
})
return files
}
// filesInDir returns the files that the given directory contains, either at
// their current or at their previous path.
func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T {
return lo.Filter(files, func(file *T, _ int) bool {
return fileIsInDir[T, PT](file, dir)
})
}
func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool {
file := PT(f)
previousPath := file.GetPreviousPath()
return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir))
}
func isInDir(path string, dir string) bool {
// "." is the root item, which contains every file
return dir == "." || strings.HasPrefix(path, dir+"/")
}

View file

@ -0,0 +1,113 @@
package controllers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestDiffPathsForNode(t *testing.T) {
files := []*models.CommitFile{
{Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"},
{Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"},
{Path: "dir/sub/file3", ChangeStatus: "M"},
{Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"},
{Path: "file5", ChangeStatus: "M"},
}
scenarios := []struct {
testName string
files []*models.CommitFile // defaults to the files above
selectedPath string
isFiltering bool
expectedPaths []string
}{
{
testName: "file",
selectedPath: "dir/sub/file3",
expectedPaths: []string{"dir/sub/file3"},
},
{
testName: "renamed file",
selectedPath: "dir/file1",
expectedPaths: []string{"dir/file1", "file1"},
},
{
testName: "directory: pass the other end of each rename that crosses its boundary",
selectedPath: "dir",
// dir/file2-renamed was renamed within the directory, so both of its
// paths are covered by it already
expectedPaths: []string{"dir", "file1", "file4"},
},
{
testName: "directory without renames crossing its boundary",
selectedPath: "dir/sub",
expectedPaths: []string{"dir/sub", "file4"},
},
{
testName: "root",
selectedPath: ".",
expectedPaths: []string{"."},
},
{
testName: "a whole directory moved into the selected one collapses to that directory",
files: []*models.CommitFile{
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
{Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"},
{Path: "unrelated", ChangeStatus: "M"},
},
selectedPath: "dir",
expectedPaths: []string{"dir", "src"},
},
{
testName: "a directory that stands in for the selected one as well",
files: []*models.CommitFile{
{Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"},
{Path: "a/b/d", ChangeStatus: "M"},
{Path: "unrelated", ChangeStatus: "M"},
},
selectedPath: "a/b",
expectedPaths: []string{"a"},
},
{
testName: "a directory with changes of its own doesn't collapse",
files: []*models.CommitFile{
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
{Path: "src/nested/c", ChangeStatus: "M"},
},
selectedPath: "dir",
// src/nested is left out of it, so that only src/a stays behind
expectedPaths: []string{"dir", "src/a", "src/nested/b"},
},
{
testName: "directory while filtering",
selectedPath: "dir",
isFiltering: true,
expectedPaths: []string{
"dir/file1", "file1",
"dir/file2-renamed", "dir/file2",
"dir/sub/file3",
"file4", "dir/sub/file4",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
files := lo.Ternary(s.files != nil, s.files, files)
cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true)
root := filetree.BuildTreeFromCommitFiles(files, true, cmp)
node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool {
return node.GetPath() == s.selectedPath
})
assert.True(t, found, "no node for path %s", s.selectedPath)
assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering))
})
}
}

View file

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

View file

@ -33,7 +33,17 @@ func (self *FilterController) Context() types.Context {
return self.context return self.context
} }
// A context that filters as the user types has an input field of its own, so it
// has no use for the filter prompt.
type contextThatFiltersAsYouType interface {
FilterAsYouType() bool
}
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
return nil
}
return []*types.Binding{ return []*types.Binding{
{ {
Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
@ -44,5 +54,6 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type
} }
func (self *FilterController) OpenFilterPrompt() error { func (self *FilterController) OpenFilterPrompt() error {
return self.c.Helpers().Search.OpenFilterPrompt(self.context) self.c.Helpers().Search.OpenFilterPrompt(self.context)
return nil
} }

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
) )
type ConfirmationHelper struct { type ConfirmationHelper struct {
@ -323,19 +324,70 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() {
} }
} }
// The rows that a filter row adds to a menu popup: one for the input, and one
// for its bottom border. Its top border is the menu's bottom border.
const menuFilterRowHeight = 2
// The prompts for the filter row, from the most to the least informative. The
// keybindings menu can also filter by keybinding, which is worth spelling out
// when there is room for it.
func (self *ConfirmationHelper) menuFilterPromptCandidates() []string {
if self.c.Contexts().Menu.AllowFilteringKeybindings() {
return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix}
}
return []string{self.c.Tr.FilterPrefix}
}
// Returns the first prompt that still leaves room to type in, or no prompt at
// all if the row is too narrow even for the shortest one.
func menuFilterPrompt(candidates []string, contentWidth int) string {
const minimumInputWidth = 4
for _, candidate := range candidates {
if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth {
return candidate
}
}
return ""
}
func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) { func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) {
menuContext := self.c.Contexts().Menu
// we want the unfiltered length here so that if we're filtering we don't // we want the unfiltered length here so that if we're filtering we don't
// resize the window // resize the window
itemCount := self.c.Contexts().Menu.UnfilteredLen() itemCount := menuContext.UnfilteredLen()
offset := 3 offset := 3
panelWidth := self.getPopupPanelWidth(90) panelWidth := self.getPopupPanelWidth(90)
contentWidth := panelWidth - 2 // minus 2 for the frame contentWidth := panelWidth - 2 // minus 2 for the frame
promptLinesCount := self.layoutMenuPrompt(contentWidth) promptLinesCount := self.layoutMenuPrompt(contentWidth)
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext) // The row is reserved for the whole time the menu is open, even though it only
menuBottom := y1 - offset // becomes visible once the user starts typing, so that revealing it doesn't
// move the menu.
filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0)
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(
contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext)
menuBottom := y1 - offset - filterRowHeight
_, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0) _, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
tooltipTop := menuBottom + 1 tooltipTop := menuBottom + 1
if menuContext.FilterAsYouType() {
filterRowBottom := menuBottom + filterRowHeight
// The row hangs off the bottom of the menu, sharing its bottom border.
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0)
prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth)
self.c.Views().MenuFilterFrame.SetContent(prompt)
// A view's content starts one column inside its bounds, so the input field
// starts one column to the left of where its text is to appear.
inputLeft := x0 + utils.StringWidth(prompt)
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0)
if menuContext.FilterStarted() {
tooltipTop = filterRowBottom + 1
}
}
tooltip := "" tooltip := ""
selectedItem := self.c.Contexts().Menu.GetSelected() selectedItem := self.c.Contexts().Menu.GetSelected()
if selectedItem != nil { if selectedItem != nil {

View file

@ -0,0 +1,31 @@
package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMenuFilterPrompt(t *testing.T) {
longPrompt := "Filter ('@' for keybindings): "
shortPrompt := "Filter: "
tests := []struct {
name string
candidates []string
contentWidth int
expected string
}{
{name: "room for four characters", candidates: []string{shortPrompt}, contentWidth: 12, expected: shortPrompt},
{name: "room for three characters", candidates: []string{shortPrompt}, contentWidth: 11, expected: ""},
{name: "prefers the first candidate", candidates: []string{longPrompt, shortPrompt}, contentWidth: 34, expected: longPrompt},
{name: "falls back to the next one", candidates: []string{longPrompt, shortPrompt}, contentWidth: 33, expected: shortPrompt},
{name: "measures display width", candidates: []string{"篩選: "}, contentWidth: 9, expected: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, menuFilterPrompt(test.candidates, test.contentWidth))
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -111,6 +111,17 @@ type refreshEnv struct {
// persist its refreshed stat cache. // persist its refreshed stat cache.
backgroundRoutine bool backgroundRoutine bool
// Whether the views this refresh updates must keep the scroll position they
// have. Focusing a list scrolls its selection into view, which is what a
// user action should do — but a refresh that no user action is behind must
// leave the viewport wherever the user last scrolled it to. That's the case
// for the unattended background routines, and for the refreshes that merely
// reload state (see RefreshOptions.DontBlockRepoSwitch).
keepScrollPosition bool
// Whether refreshing a side context should leave the main view unchanged.
skipMainViewUpdate bool
// the repo generation captured when the refresh started // the repo generation captured when the refresh started
generation int generation int
@ -220,13 +231,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// against the repo it started in, and the generation guard drops its // against the repo it started in, and the generation guard drops its
// writes. // writes.
env := refreshEnv{ env := refreshEnv{
background: options.Background || options.DontBlockRepoSwitch, background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background, backgroundRoutine: options.Background,
keepScrollPosition: options.Background || options.DontBlockRepoSwitch,
skipMainViewUpdate: options.SkipMainViewUpdate,
} }
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration() env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git() env.git = self.c.Git()
}) }) {
return
}
if options.BatchUIUpdates { if options.BatchUIUpdates {
env.batch = &refreshBounceBatch{} env.batch = &refreshBounceBatch{}
} }
@ -262,6 +277,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// - merge conflicts are part of what the files refresh produces // - merge conflicts are part of what the files refresh produces
// - pull requests are fetched for the tracking branches against the // - pull requests are fetched for the tracking branches against the
// remotes, so refresh both alongside to fetch against fresh data // remotes, so refresh both alongside to fetch against fresh data
// - commits and branches always go together: changing commits changes
// the branches' upstream/downstream counts, and changing branches
// (e.g. checking one out) changes the commits we show. This one comes
// last, so that it also covers the branches the rules above add.
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES) scopeSet.Add(types.COMMITS, types.BRANCHES)
} }
@ -274,6 +293,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.PULL_REQUESTS) { if scopeSet.Includes(types.PULL_REQUESTS) {
scopeSet.Add(types.BRANCHES, types.REMOTES) scopeSet.Add(types.BRANCHES, types.REMOTES)
} }
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
// Capture the refs snapshot now, before we start reading git's state // Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race // below, rather than after. This is important to guard against the race
@ -300,6 +322,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
}) })
} }
// The branches view shows worktrees against branches, so a branches render
// that happens before the refreshed worktrees have landed in the model shows
// stale ones, and rendering again once they land makes the view flicker.
// Refresh the worktrees first, then, and let the branches refresh wait for
// them: waitForWorktrees returns once the worktrees model write is queued,
// so the branches write that follows is queued behind it and the view
// renders once, with both.
worktreesWg := sync.WaitGroup{}
waitForWorktrees := func() { worktreesWg.Wait() }
if scopeSet.Includes(types.WORKTREES) {
worktreesWg.Add(1)
refresh("worktrees", func() {
defer worktreesWg.Done()
self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES))
})
}
branchesAndRemotesWg := sync.WaitGroup{} branchesAndRemotesWg := sync.WaitGroup{}
// The pull-request fetch (below) needs the just-loaded branches and // The pull-request fetch (below) needs the just-loaded branches and
// remotes. Their model writes are bounced onto the UI thread, so the // remotes. Their model writes are bounced onto the UI thread, so the
@ -309,32 +348,49 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// branchesAndRemotesWg gives the fetch the happens-before to read them. // branchesAndRemotesWg gives the fetch the happens-before to read them.
var loadedBranches []*models.Branch var loadedBranches []*models.Branch
var loadedRemotes []*models.Remote var loadedRemotes []*models.Remote
includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) {
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { // Capture the refresh's inputs (model, contexts, modes) on the UI
// whenever we change commits, we should update branches because the upstream/downstream // thread, before the git work is dispatched to a worker, so the worker
// counts can change. Whenever we change branches we should also change commits // computes from an immutable snapshot instead of reading state the UI
// e.g. in the case of switching branches. // thread concurrently mutates. Every scope below does the same.
// Capture the commits, reflog and branches refresh inputs (model,
// contexts, modes) on the UI thread, before the git work is dispatched
// to a worker, so the workers compute from an immutable snapshot
// instead of reading state the UI thread concurrently mutates.
var capturedCommits capturedCommitState var capturedCommits capturedCommitState
var capturedReflog capturedReflogState if !self.captureOnUIThread(calledFromWorker, env.background, func() {
var capturedBranches capturedBranchState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommits = self.captureCommitsState() capturedCommits = self.captureCommitsState()
capturedReflog = self.captureReflogState() }) {
capturedBranches = self.captureBranchState() return
}) }
refresh("commits and commit files", func() { refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
}) })
} else if scopeSet.Includes(types.REBASE_COMMITS) {
// the commits refresh above loads the rebase commits as well, so we only
// need this one when the rebase commits are all that was asked for
var rebaseHashPool *utils.StringPool
var rebaseCommits []*models.Commit
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
}) {
return
}
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
}
if scopeSet.Includes(types.BRANCHES) {
// The reflog is refreshed here rather than in a scope of its own,
// because sorting the branches by recency needs it to be loaded first.
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
}) {
return
}
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
branchesAndRemotesWg.Add(1) branchesAndRemotesWg.Add(1)
refresh("reflog and branches", func() { refresh("reflog and branches", func() {
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env)
branchesAndRemotesWg.Done() branchesAndRemotesWg.Done()
}) })
} else { } else {
@ -343,47 +399,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// Not a recency sort, so branches doesn't depend on the reflog // Not a recency sort, so branches doesn't depend on the reflog
// being fresh; it runs concurrently with the reflog refresh // being fresh; it runs concurrently with the reflog refresh
// below and uses the reflog we captured up front, as it always has. // below and uses the reflog we captured up front, as it always has.
loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env)
branchesAndRemotesWg.Done() branchesAndRemotesWg.Done()
}) })
refresh("reflog", func() { refresh("reflog", func() {
_, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit)
}) })
} }
} else if scopeSet.Includes(types.REBASE_COMMITS) {
// the above block handles rebase commits so we only need to call this one
// if we've asked specifically for rebase commits and not those other things
var rebaseHashPool *utils.StringPool
var rebaseCommits []*models.Commit
self.captureOnUIThread(calledFromWorker, env.background, func() {
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
})
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
} }
if scopeSet.Includes(types.SUB_COMMITS) { if scopeSet.Includes(types.SUB_COMMITS) {
var capturedSubCommits capturedSubCommitState var capturedSubCommits capturedSubCommitState
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedSubCommits = self.captureSubCommitState() capturedSubCommits = self.captureSubCommitState()
}) }) {
return
}
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
} }
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState var capturedCommitFiles capturedCommitFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState() capturedCommitFiles = self.captureCommitFilesState()
}) }) {
return
}
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
} }
fileWg := sync.WaitGroup{} fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) { if scopeSet.Includes(types.FILES) {
var capturedFiles capturedFilesState var capturedFiles capturedFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedFiles = self.captureFilesState() capturedFiles = self.captureFilesState()
}) }) {
return
}
fileWg.Add(1) fileWg.Add(1)
refresh("files", func() { refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env) _ = self.refreshFilesAndSubmodules(capturedFiles, env)
@ -393,9 +446,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.STASH) { if scopeSet.Includes(types.STASH) {
var stashFilterPath string var stashFilterPath string
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath() stashFilterPath = self.c.Modes().Filtering.GetPath()
}) }) {
return
}
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
} }
@ -408,9 +463,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// needs it to keep the remote-branches selection valid, and reading // needs it to keep the remote-branches selection valid, and reading
// the Remotes context off the UI thread races its render. // the Remotes context off the UI thread races its render.
var prevSelectedRemote *models.Remote var prevSelectedRemote *models.Remote
self.captureOnUIThread(calledFromWorker, env.background, func() { if !self.captureOnUIThread(calledFromWorker, env.background, func() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
}) }) {
return
}
branchesAndRemotesWg.Add(1) branchesAndRemotesWg.Add(1)
refresh("remotes", func() { refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
@ -443,10 +500,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
}) })
} }
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
refresh("worktrees", func() { self.refreshWorktrees(env) })
}
if scopeSet.Includes(types.STAGING) { if scopeSet.Includes(types.STAGING) {
refresh("staging", func() { refresh("staging", func() {
fileWg.Wait() fileWg.Wait()
@ -673,17 +726,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState {
} }
} }
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
switch self.c.State().GetRepoState().GetStartupStage() { switch self.c.State().GetRepoState().GetStartupStage() {
case types.INITIAL: case types.INITIAL:
// Return the immediate (non-recency) load's branches; the recency-sorted // Return the immediate (non-recency) load's branches; the recency-sorted
// reload below runs on its own worker after we return. Both hold the same // reload below runs on its own worker after we return. Both hold the same
// set of branches, which is all the caller (the PR fetch) needs. // set of branches, which is all the caller (the PR fetch) needs.
branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
self.onWorker(env.background, func(_ gocui.Task) error { self.onWorker(env.background, func(_ gocui.Task) error {
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false)
self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) // The load above already waited for the worktrees, so this one has
// nothing left to wait for.
self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env)
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
return nil return nil
}) })
@ -692,7 +747,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo
case types.COMPLETE: case types.COMPLETE:
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit)
return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env)
} }
return nil return nil
@ -810,13 +865,16 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
var selectionRange *localCommitSelectionRange var selectionRange *localCommitSelectionRange
var newConflictedCommitIdx *int
if commitSelection == types.KeepCommitSelectionByHash { if commitSelection == types.KeepCommitSelectionByHash {
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits)
} }
self.c.Model().BisectInfo = bisectInfo self.c.Model().BisectInfo = bisectInfo
self.c.Model().Commits = commits self.c.Model().Commits = commits
self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != ""
self.RefreshAuthors(commits) self.RefreshAuthors(commits)
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
if checkedOutRef != nil { if checkedOutRef != nil {
@ -825,33 +883,23 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.c.Model().CheckedOutBranch = "" self.c.Model().CheckedOutBranch = ""
} }
scrollSelectionIntoView := false
switch commitSelection { switch commitSelection {
case types.SelectHeadCommit: case types.SelectHeadCommit:
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
scrollSelectionIntoView = true
} }
case types.KeepCommitSelectionByHash: case types.KeepCommitSelectionByHash:
if selectionRange != nil { if newConflictedCommitIdx != nil {
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx)
} else if selectionRange != nil {
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange)
if found { if found {
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
scrollSelectionIntoView = didMove
} }
} }
case types.KeepCommitSelectionIndex: case types.KeepCommitSelectionIndex:
// The caller set the selection index deliberately; leave it untouched. // The caller set the selection index deliberately; leave it untouched.
} }
if scrollSelectionIntoView {
// Enqueued from within this bounce so it runs after refreshView's
// render below (which was enqueued first), matching the previous
// ordering where FocusLine ran after the view was re-rendered.
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Contexts().LocalCommits.FocusLine(true)
})
}
}) })
self.refreshView(self.c.Contexts().LocalCommits, env) self.refreshView(self.c.Contexts().LocalCommits, env)
@ -863,8 +911,6 @@ type localCommitSelectionRange struct {
selectedIsTODO bool selectedIsTODO bool
rangeStartHash string rangeStartHash string
rangeStartIsTODO bool rangeStartIsTODO bool
selectedIdx int
rangeStartIdx int
mode traits.RangeSelectMode mode traits.RangeSelectMode
} }
@ -883,8 +929,6 @@ func captureLocalCommitSelectionRange(
selectedIsTODO: commits[selectedIdx].IsTODO(), selectedIsTODO: commits[selectedIdx].IsTODO(),
rangeStartHash: commits[rangeStartIdx].Hash(), rangeStartHash: commits[rangeStartIdx].Hash(),
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
mode: mode, mode: mode,
} }
} }
@ -892,17 +936,16 @@ func captureLocalCommitSelectionRange(
func findLocalCommitSelectionRange( func findLocalCommitSelectionRange(
commits []*models.Commit, commits []*models.Commit,
selectionRange *localCommitSelectionRange, selectionRange *localCommitSelectionRange,
) (int, int, bool, bool) { ) (int, int, bool) {
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
if !foundSelected || !foundRangeStart { if !foundSelected || !foundRangeStart {
return 0, 0, false, false return 0, 0, false
} }
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx return selectedIdx, rangeStartIdx, true
return selectedIdx, rangeStartIdx, didMove, true
} }
// findCommitByHashPreferringTODOStatus finds the commit with the given hash. // findCommitByHashPreferringTODOStatus finds the commit with the given hash.
@ -933,6 +976,24 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
} }
// Returns the index of the conflicted commit in the new commits slice, if there is one and it has a
// different hash than the one before had (or there wasn't one before). Otherwise returns nil.
func findNewConflictedCommit(previousCommits []*models.Commit, commits []*models.Commit) *int {
previousConflictedCommit, _ := lo.Find(previousCommits, func(commit *models.Commit) bool {
return commit.Status == models.StatusConflicted
})
newConflictedCommit, idx, hasConflict := lo.FindIndexOf(commits, func(commit *models.Commit) bool {
return commit.Status == models.StatusConflicted
})
if hasConflict && (previousConflictedCommit == nil || previousConflictedCommit.Hash() != newConflictedCommit.Hash()) {
return &idx
}
return nil
}
// capturedSubCommitState holds the sub-commits refresh's model/context/mode // capturedSubCommitState holds the sub-commits refresh's model/context/mode
// inputs, gathered on the UI thread (see captureSubCommitState) before the git // inputs, gathered on the UI thread (see captureSubCommitState) before the git
// work is dispatched to a worker. // work is dispatched to a worker.
@ -1073,7 +1134,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
// self.refreshStatus is called at the end of this because that's when we can // self.refreshStatus is called at the end of this because that's when we can
// be sure there is a State.Model.Branches array to pick the current branch from // be sure there is a State.Model.Branches array to pick the current branch from
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
loadSeq := self.branchLoadSeq.Add(1) loadSeq := self.branchLoadSeq.Add(1)
branches, err := env.git.Loaders.BranchLoader.Load( branches, err := env.git.Loaders.BranchLoader.Load(
@ -1107,10 +1168,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
self.c.Log.Error(err) self.c.Log.Error(err)
} }
var worktrees []*models.Worktree // Render only once the refreshed worktrees are in the model; the branches
if refreshWorktrees { // view shows them against the branches (see performRefresh).
worktrees = self.loadWorktrees(env) waitForWorktrees()
}
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
// Drop this write if a branch load that started later has already applied // Drop this write if a branch load that started later has already applied
@ -1133,11 +1193,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
// the branches we just wrote, on the UI thread. // the branches we just wrote, on the UI thread.
self.rebuildPullRequestsMap() self.rebuildPullRequestsMap()
if refreshWorktrees {
self.c.Model().Worktrees = worktrees
self.refreshView(self.c.Contexts().Worktrees, env)
}
// Setting the selection here, in the same bounce that writes the list, // Setting the selection here, in the same bounce that writes the list,
// keeps it on the UI thread and keeps the list and selection updating in // keeps it on the UI thread and keeps the list and selection updating in
// the same frame. // the same frame.
@ -1153,10 +1208,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
} }
} }
case types.SelectCheckedOutBranch: case types.SelectCheckedOutBranch:
// The checked-out branch is always at the top of the list. Setting // The checked-out branch is always at the top of the list.
// the selection doesn't scroll the view, so also reset the origin.
self.c.Contexts().Branches.SetSelectedLineIdx(0) self.c.Contexts().Branches.SetSelectedLineIdx(0)
self.c.Contexts().Branches.GetView().SetOriginY(0)
} }
// Need to re-render the commits view because the visualization of local // Need to re-render the commits view because the visualization of local
@ -1247,21 +1300,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// waiting for a callback that only it can run), and capturing inline also // waiting for a callback that only it can run), and capturing inline also
// guarantees the snapshot reflects the state at the moment Refresh was called, // guarantees the snapshot reflects the state at the moment Refresh was called,
// before the calling handler regains control and can mutate it. // before the calling handler regains control and can mutate it.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) { //
// It returns false when fn didn't run because the app is shutting down, in
// which case the caller must abandon the refresh rather than compute from a
// snapshot that was never taken.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool {
if !calledFromWorker { if !calledFromWorker {
fn() fn()
return return true
} }
wrapped := func() error {
fn()
return nil
}
if background { if background {
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped) return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil
} else {
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
} }
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
} }
// capturedFilesState holds the files refresh's context/model inputs, gathered // capturedFilesState holds the files refresh's context/model inputs, gathered
@ -1304,7 +1356,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// process working directory, which may already point at another // process working directory, which may already point at another
// repo if the user switched while this refresh was in flight. // repo if the user switched while this refresh was in flight.
hasConflicts, err := mergeconflicts.FileHasConflictMarkers( hasConflicts, err := mergeconflicts.FileHasConflictMarkers(
filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path)) filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path),
file.ConflictMarkerSize)
if err != nil { if err != nil {
self.c.Log.Error(err) self.c.Log.Error(err)
} else if !hasConflicts { } else if !hasConflicts {
@ -1327,12 +1380,9 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
Background: env.backgroundRoutine, Background: env.backgroundRoutine,
}) })
conflictFileCount := 0 conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) {
for _, file := range files { return file.Path, file.HasMergeConflicts
if file.HasMergeConflicts { })
conflictFileCount++
}
}
repoState := self.c.State().GetRepoState() repoState := self.c.State().GetRepoState()
workingTreeState := env.git.Status.WorkingTreeState() workingTreeState := env.git.Status.WorkingTreeState()
@ -1342,7 +1392,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
repoState.SetMergeOrRebaseStartedInLazygit(false) repoState.SetMergeOrRebaseStartedInLazygit(false)
} }
if workingTreeState.Any() && conflictFileCount == 0 { if workingTreeState.Any() && len(conflictedPaths) == 0 {
if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() { if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() {
// The conflicts of an operation we started have just been resolved // The conflicts of an operation we started have just been resolved
// (e.g. in the user's editor). Offer to continue it. We only do this // (e.g. in the user's editor). Offer to continue it. We only do this
@ -1380,24 +1430,61 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
// only taking over the filter if it hasn't already been set by the user. // only taking over the filter if it hasn't already been set by the user.
if conflictFileCount > 0 && prevConflictFileCount == 0 { if len(conflictedPaths) > 0 && prevConflictFileCount == 0 {
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
} }
} else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { } else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll)
self.c.Contexts().Files.GetView().Subtitle = "" self.c.Contexts().Files.GetView().Subtitle = ""
} }
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.RememberConflictedPaths(conflictedPaths)
}
self.c.Model().Submodules = submoduleConfigs self.c.Model().Submodules = submoduleConfigs
self.c.Model().Files = files self.c.Model().Files = files
markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath())
fileTreeViewModel.SetTree() fileTreeViewModel.SetTree()
}) })
return nil return nil
} }
// markWorktreeFiles marks the files that are linked worktrees of this repo, so
// that the files view can render them as such. `git status` reports a worktree
// as an untracked directory, i.e. with a trailing slash, which we take off:
// keeping it would build a directory node with a nameless file inside it.
//
// It must run on the UI thread, as it works on the model. Both models it needs
// are written by refreshes of their own, so it is called after either of them
// lands; it reports whether it changed anything.
func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool {
changed := false
for _, file := range files {
absPath := filepath.Join(worktreePath, file.Path)
isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool {
return worktree.Path == absPath
})
if isWorktree != file.IsWorktree {
file.IsWorktree = isWorktree
changed = true
}
if isWorktree {
if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path {
file.Path = trimmed
changed = true
}
}
}
return changed
}
// the reflogs panel is the only panel where we cache data, in that we only // the reflogs panel is the only panel where we cache data, in that we only
// load entries that have been created since we last ran the call. This means // load entries that have been created since we last ran the call. This means
// we need to be more careful with how we use this, and to ensure we're emptying // we need to be more careful with how we use this, and to ensure we're emptying
@ -1447,11 +1534,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
self.c.Model().ReflogCommits = reflogCommits self.c.Model().ReflogCommits = reflogCommits
self.c.Model().FilteredReflogCommits = filteredReflogCommits self.c.Model().FilteredReflogCommits = filteredReflogCommits
// Setting the selection here, in the same bounce that writes the list, // Setting the selection here, in the same bounce that writes the list,
// keeps it on the UI thread and atomic with the list update. Setting the // keeps it on the UI thread and atomic with the list update.
// selection doesn't scroll the view, so also reset the origin.
if selectTopEntry { if selectTopEntry {
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
} }
}) })
@ -1501,16 +1586,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {
return worktrees return worktrees
} }
func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) {
worktrees := self.loadWorktrees(env) worktrees := self.loadWorktrees(env)
self.onUIThreadUnlessRepoChanged(env, func() { self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Worktrees = worktrees self.c.Model().Worktrees = worktrees
// A worktree inside our working tree is one of the files, so the files
// view has to be told about the ones we just loaded (see
// markWorktreeFiles). Rebuild the tree because a file's path can change.
if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) {
self.c.Contexts().Files.FileTreeViewModel.SetTree()
self.refreshView(self.c.Contexts().Files, env)
}
}) })
// need to refresh branches because the branches view shows worktrees against // The branches view shows worktrees against branches, so it needs to be
// branches // rendered again as well. When the branches are being refreshed too, they
self.refreshView(self.c.Contexts().Branches, env) // render after waiting for the write above, so leave it to them.
if !branchesAreRefreshing {
self.refreshView(self.c.Contexts().Branches, env)
}
self.refreshView(self.c.Contexts().Worktrees, env) self.refreshView(self.c.Contexts().Worktrees, env)
} }
@ -1578,7 +1674,10 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
// the filtered list model is up to date for rendering. // the filtered list model is up to date for rendering.
self.searchHelper.ReApplyFilter(context) self.searchHelper.ReApplyFilter(context)
self.c.PostRefreshUpdate(context) self.c.PostRefreshUpdateWithOptions(context, types.OnFocusOpts{
KeepScrollPosition: env.keepScrollPosition,
SkipMainViewUpdate: env.skipMainViewUpdate,
})
self.c.AfterLayout(func() error { self.c.AfterLayout(func() error {
// Re-applying the search must be done after re-rendering the view though, // Re-applying the search must be done after re-rendering the view though,
@ -1754,7 +1853,11 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
// the branches and remotes as they are on the UI thread, after their // the branches and remotes as they are on the UI thread, after their
// own refreshes' bounces have applied. // own refreshes' bounces have applied.
self.rebuildPullRequestsMap() self.rebuildPullRequestsMap()
self.c.PostRefreshUpdate(self.c.Contexts().Branches) // This lands whenever the network call happens to return, and only
// changes how the branches are rendered, not which one is selected, so
// it has no business moving the viewport.
self.c.PostRefreshUpdateWithOptions(self.c.Contexts().Branches,
types.OnFocusOpts{KeepScrollPosition: true})
}) })
} }

View file

@ -1,6 +1,7 @@
package helpers package helpers
import ( import (
"path/filepath"
"testing" "testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
@ -28,8 +29,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) {
expected: &localCommitSelectionRange{ expected: &localCommitSelectionRange{
selectedHash: "b", selectedHash: "b",
rangeStartHash: "a", rangeStartHash: "a",
selectedIdx: 1,
rangeStartIdx: 0,
mode: traits.RangeSelectModeSticky, mode: traits.RangeSelectModeSticky,
}, },
}, },
@ -74,15 +73,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
type expectation struct { type expectation struct {
selectedIdx int selectedIdx int
rangeStartIdx int rangeStartIdx int
moved bool
found bool found bool
} }
selectionRange := localCommitSelectionRange{ selectionRange := localCommitSelectionRange{
selectedHash: "b", selectedHash: "b",
rangeStartHash: "c", rangeStartHash: "c",
selectedIdx: 1,
rangeStartIdx: 2,
mode: traits.RangeSelectModeSticky, mode: traits.RangeSelectModeSticky,
} }
@ -97,7 +93,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 2, selectedIdx: 2,
rangeStartIdx: 3, rangeStartIdx: 3,
moved: true,
found: true, found: true,
}, },
}, },
@ -126,7 +121,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 2, selectedIdx: 2,
rangeStartIdx: 3, rangeStartIdx: 3,
moved: true,
found: true, found: true,
}, },
}, },
@ -139,7 +133,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{ expected: expectation{
selectedIdx: 0, selectedIdx: 0,
rangeStartIdx: 1, rangeStartIdx: 1,
moved: true,
found: true, found: true,
}, },
}, },
@ -147,11 +140,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
actual := expectation{ actual := expectation{
selectedIdx: selectedIdx, selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx, rangeStartIdx: rangeStartIdx,
moved: moved,
found: found, found: found,
} }
@ -160,6 +152,62 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
} }
} }
func TestFindNewConflictedCommit(t *testing.T) {
testCases := []struct {
name string
previousCommits []*models.Commit
commits []*models.Commit
expectedIdx *int
}{
{
name: "finds a newly conflicted commit",
previousCommits: makeCommits("a", "b"),
commits: []*models.Commit{
makeCommits("a")[0],
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(1),
},
{
name: "finds a different conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(0),
},
{
name: "ignores the same conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("a"),
},
expectedIdx: nil,
},
{
name: "reports not found when there is no conflict",
previousCommits: makeCommits("a"),
commits: makeCommits("a", "b"),
expectedIdx: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits)
assert.Equal(t, testCase.expectedIdx != nil, idx != nil)
if idx != nil {
assert.Equal(t, *testCase.expectedIdx, *idx)
}
})
}
}
func TestGetGithubBaseRemote(t *testing.T) { func TestGetGithubBaseRemote(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@ -252,6 +300,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) {
}, callsByHost) }, callsByHost)
} }
func TestMarkWorktreeFiles(t *testing.T) {
worktreePath := filepath.Join("/", "path", "to", "repo")
worktrees := []*models.Worktree{
{Path: worktreePath},
{Path: filepath.Join(worktreePath, "worktree1")},
{Path: filepath.Join(worktreePath, "dir", "worktree2")},
{Path: filepath.Join("/", "path", "to", "worktree3")},
}
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
files := []*models.File{
{Path: "file"},
{Path: "worktree1/"},
{Path: "dir/worktree2/"},
{Path: "dir/"},
}
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
assert.Equal(t, []*models.File{
{Path: "file"},
{Path: "worktree1", IsWorktree: true},
{Path: "dir/worktree2", IsWorktree: true},
{Path: "dir/"},
}, files)
})
t.Run("reports no change when there is nothing to mark", func(t *testing.T) {
files := []*models.File{{Path: "file"}, {Path: "dir/"}}
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
})
t.Run("unmarks a file whose worktree is gone", func(t *testing.T) {
files := []*models.File{{Path: "worktree1", IsWorktree: true}}
assert.True(t, markWorktreeFiles(files, nil, worktreePath))
assert.Equal(t, []*models.File{{Path: "worktree1"}}, files)
})
}
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo { func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
return lo.Map(names, func(name string, _ int) githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo {
return makeGithubRemoteInfo(name, name) return makeGithubRemoteInfo(name, name)
@ -288,3 +376,7 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit {
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit { func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action}) return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
} }
func makeConflictedCommit(hash string) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted})
}

View file

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

View file

@ -29,14 +29,14 @@ func NewSearchHelper(
} }
} }
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error { func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) {
state := self.searchState() state := self.searchState()
state.PrevSearchIndex = -1 state.PrevSearchIndex = -1
state.Context = context state.Context = context
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView() promptView := self.promptView()
promptView.ClearTextArea() promptView.ClearTextArea()
self.OnPromptContentChanged("") self.OnPromptContentChanged("")
@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings() self.c.ResetKeybindings()
} }
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error { func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) {
state := self.searchState() state := self.searchState()
state.PrevSearchIndex = -1 state.PrevSearchIndex = -1
@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) err
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings() self.c.ResetKeybindings()
} }
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) { func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext)
state.Context = context state.Context = context
searchString := context.GetFilter() searchString := context.GetFilter()
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView() promptView := self.promptView()
keybindingConfig := self.c.UserConfig().Keybinding keybindingConfig := self.c.UserConfig().Keybinding
@ -103,10 +103,11 @@ func (self *SearchHelper) promptContent() string {
return self.c.Contexts().Search.GetView().TextArea.GetContent() return self.c.Contexts().Search.GetView().TextArea.GetContent()
} }
func (self *SearchHelper) Confirm() error { func (self *SearchHelper) Confirm() {
state := self.searchState() state := self.searchState()
if self.promptContent() == "" { if self.promptContent() == "" {
return self.CancelPrompt() self.CancelPrompt()
return
} }
switch state.SearchType() { switch state.SearchType() {
@ -118,7 +119,7 @@ func (self *SearchHelper) Confirm() error {
self.c.Context().Pop() self.c.Context().Pop()
} }
return self.c.ResetKeybindings() self.c.ResetKeybindings()
} }
func (self *SearchHelper) ConfirmFilter() { func (self *SearchHelper) ConfirmFilter() {
@ -175,12 +176,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition
return context.ModelSearchResults(normalizedSearchStr, caseSensitive) return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
} }
func (self *SearchHelper) CancelPrompt() error { func (self *SearchHelper) CancelPrompt() {
self.Cancel() self.Cancel()
self.c.Context().Pop() self.c.Context().Pop()
return self.c.ResetKeybindings() self.c.ResetKeybindings()
} }
func (self *SearchHelper) ScrollHistory(scrollIncrement int) { func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
@ -224,10 +225,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
state := self.searchState() state := self.searchState()
switch context := state.Context.(type) { switch context := state.Context.(type) {
case types.IFilterableContext: case types.IFilterableContext:
context.SetSelection(0) self.ApplyFilter(context, searchString)
context.GetView().SetOriginY(0)
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
case types.ISearchableContext: case types.ISearchableContext:
// do nothing // do nothing
default: default:
@ -235,12 +233,21 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
} }
} }
func (self *SearchHelper) ApplyFilter(context types.IFilterableContext, filter string) {
context.SetSelection(0)
context.SetFilter(filter, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
}
func (self *SearchHelper) ReApplyFilter(context types.Context) { func (self *SearchHelper) ReApplyFilter(context types.Context) {
filterableContext, ok := context.(types.IFilterableContext) filterableContext, ok := context.(types.IFilterableContext)
if ok { if ok {
state := self.searchState() state := self.searchState()
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() { if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
filterableContext.SetSelection(0) filterableContext.SetSelection(0)
// This runs as part of a refresh, and a refresh that no user action
// is behind keeps the scroll position, which would leave the view
// scrolled somewhere the filtered list no longer has anything at.
filterableContext.GetView().SetOriginY(0) filterableContext.GetView().SetOriginY(0)
} }
filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,26 @@
package controllers package controllers
import ( import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
) )
type MenuController struct { type MenuController struct {
baseController baseController
*ListControllerTrait[*types.MenuItem] *ListControllerTrait[*types.MenuItem]
c *ControllerCommon c *ControllerCommon
// for delegating navigation to, see physicalKeyBindings
listController *ListController
} }
var _ types.IController = &MenuController{} var _ types.IController = &MenuController{}
func NewMenuController( func NewMenuController(
c *ControllerCommon, c *ControllerCommon,
listController *ListController,
) *MenuController { ) *MenuController {
return &MenuController{ return &MenuController{
baseController: baseController{}, baseController: baseController{},
@ -24,12 +30,13 @@ func NewMenuController(
c.Contexts().Menu.GetSelected, c.Contexts().Menu.GetSelected,
c.Contexts().Menu.GetSelectedItems, c.Contexts().Menu.GetSelectedItems,
), ),
c: c, c: c,
listController: listController,
} }
} }
// NOTE: if you add a new keybinding here, you'll also need to add it to // NOTE: if you add a new keybinding here, you'll also need to add it to
// `reservedKeys` in `pkg/gui/context/menu_context.go` // `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it
func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{ bindings := []*types.Binding{
{ {
@ -52,6 +59,51 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
}, },
} }
if self.context().FilterAsYouType() {
bindings = append(bindings, self.physicalKeyBindings(opts)...)
}
return bindings
}
// In a menu that filters as you type, the keys configured for driving the menu
// may all be printable, and printable keys become filter text once the user
// starts typing. These keys can't, so binding them on top guarantees that the
// menu stays usable no matter how the keybindings are configured.
func (self *MenuController) physicalKeyBindings(opts types.KeybindingsOpts) []*types.Binding {
candidates := []struct {
key gocui.Key
configured config.Keybinding
binding *types.Binding
}{
{
key: gocui.NewKeyName(gocui.KeyEnter),
configured: opts.Config.Universal.ConfirmMenu,
binding: &types.Binding{
Handler: self.withItem(self.press),
GetDisabledReason: self.require(self.singleItemSelected()),
},
},
{gocui.NewKeyName(gocui.KeyEsc), opts.Config.Universal.Return, &types.Binding{Handler: self.close}},
{gocui.NewKeyName(gocui.KeyArrowUp), opts.Config.Universal.PrevItem, &types.Binding{Handler: self.listController.HandlePrevLine}},
{gocui.NewKeyName(gocui.KeyArrowDown), opts.Config.Universal.NextItem, &types.Binding{Handler: self.listController.HandleNextLine}},
{gocui.NewKeyName(gocui.KeyPgup), opts.Config.Universal.PrevPage, &types.Binding{Handler: self.listController.HandlePrevPage}},
{gocui.NewKeyName(gocui.KeyPgdn), opts.Config.Universal.NextPage, &types.Binding{Handler: self.listController.HandleNextPage}},
{gocui.NewKeyName(gocui.KeyHome), opts.Config.Universal.GotoTop, &types.Binding{Handler: self.listController.HandleGotoTop}},
{gocui.NewKeyName(gocui.KeyEnd), opts.Config.Universal.GotoBottom, &types.Binding{Handler: self.listController.HandleGotoBottom}},
}
bindings := []*types.Binding{}
for _, candidate := range candidates {
if lo.Contains(opts.GetKeys(candidate.configured), candidate.key) {
// this key is the configured one, so it drives the menu already
continue
}
candidate.binding.Keys = []gocui.Key{candidate.key}
bindings = append(bindings, candidate.binding)
}
return bindings return bindings
} }
@ -73,6 +125,11 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
} }
func (self *MenuController) close() error { func (self *MenuController) close() error {
if self.context().FilterStarted() {
self.stopFiltering()
return nil
}
if self.context().IsFiltering() { if self.context().IsFiltering() {
self.c.Helpers().Search.Cancel() self.c.Helpers().Search.Cancel()
return nil return nil
@ -81,6 +138,17 @@ func (self *MenuController) close() error {
return self.context().OnMenuPress(nil) return self.context().OnMenuPress(nil)
} }
// Hides the filter row again and puts the menu back the way it was, keeping the
// item that was selected. It takes another escape to close the menu.
func (self *MenuController) stopFiltering() {
self.c.Views().MenuFilter.ClearTextArea()
self.c.Views().MenuFilter.RenderTextArea()
self.context().SetFilterStarted(false)
self.context().ClearFilter()
self.c.PostRefreshUpdate(self.context())
}
func (self *MenuController) context() *context.MenuContext { func (self *MenuController) context() *context.MenuContext {
return self.c.Contexts().Menu return self.c.Contexts().Menu
} }

View file

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

View file

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

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