diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index d63058d8252..5b4a7270fd9 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -222,6 +222,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Search the current view by text | | ## Main panel (patch building) @@ -327,6 +328,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Search the current view by text | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index d9b87d747cc..33471a1c7c3 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -192,6 +192,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | +| `` `` | Show/hide selection | | | `` / `` | 現在のビューをテキストで検索 | | ## タグ @@ -305,6 +306,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | +| `` `` | Show/hide selection | | | `` / `` | 現在のビューをテキストで検索 | | ## メニュー diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 089543c5fb9..d7539eada0b 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -83,6 +83,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 검색 시작 | | ## Stash @@ -161,6 +162,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 검색 시작 | | ## 메인 패널 (Patch Building) diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 1715c597efc..fb71cba8879 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -230,6 +230,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Start met zoeken | | ## Patch bouwen @@ -305,6 +306,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Start met zoeken | | ## Staging diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index b032a660604..9d036af4d4c 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Drzewa pracy @@ -200,6 +201,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel główny (scalanie) diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index c19619191c8..c0637c99400 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Pesquisar na visualização atual por texto | | ## Painel Principal (preparação) @@ -336,6 +337,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Pesquisar na visualização atual por texto | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index c802678b3eb..79675b94103 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -73,6 +73,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Найти | | ## Главная панель (Индексирование) @@ -105,6 +106,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Найти | | ## Главная панель (Слияние) diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9cb7d5186a7..c5b0c00b702 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -285,6 +285,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | +| `` `` | Show/hide selection | | | `` / `` | 开始搜索 | | ## 正在合并 @@ -333,6 +334,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | +| `` `` | Show/hide selection | | | `` / `` | 开始搜索 | | ## 状态 diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index d6526b5b2b6..a9665eefe23 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -81,6 +81,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 搜尋 | | ## 主面板(合併) @@ -362,6 +363,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 搜尋 | | ## 狀態 diff --git a/focused-main-view-notes.md b/focused-main-view-notes.md new file mode 100644 index 00000000000..f7ca609641e --- /dev/null +++ b/focused-main-view-notes.md @@ -0,0 +1,614 @@ +# Focused main view — session notes + +A working document capturing everything we discussed, built, and learned in this +session. It is meant as a **starting point for future sessions**, which might: + +1. **Continue** solving the problem we were in the middle of (restoring scroll + + selection when escaping back to a focused main view) — still at prototype + quality. +2. **Enhance** the prototype with a few more missing pieces. +3. **Productionize** the whole thing: better quality, a clean commit history, and + tests. (We did *not* make that plan; this doc gives a future session enough + context to make it.) + +> Status at end of **session 3** (the latest): branch +> `use-delta-hyperlinks-for-clicking-in-diff`. The escape scroll/selection +> restore is **implemented and committed** — at normal speed there is **no +> visible flicker**. Session 3 implemented §6's proposed fix (a cmd/pty analogue +> of `RenderStringWithScrollTask`), discovered and fixed a *second* cause of the +> top-flicker (the scroll-reset loop in `refreshMainViews` ran *before* +> `CopyContent`), and corrected session 2's belief that the `onNewKey` +> suppression could be dropped (it can't — it's folded into the same mechanism). +> Full story in the new **§11**, which supersedes §6's "correct fix" subsection. +> The working tree is now **clean** (everything committed); the tree builds, is +> `gofumpt`-clean, unit tests pass, and `e2e-all` is green except one +> pre-existing **direnv-environmental** worktree test. +> +> **What's left before productionizing:** under `LAZYGIT_SLOW_RENDER` a few +> imperfect intermediate frames still appear *occasionally* — real timing races +> we agreed to investigate and eliminate (not paper over with "fine at normal +> speed"). See §11 "Remaining timing races". Memory: +> `focused-main-view-flicker-timing-races`. + +--- + +## 1. The big picture: what this feature is + +lazygit has a "focused main view": you press `0` (`Universal.FocusMainView`), +or click, to move focus from a side panel (files, commits, commit-files, +stash, branches, …) **into the main view** that shows its diff, so you can +scroll and interact with the diff itself. The branch builds this out into a +real interaction model: + +- A **selection** can be shown in the focused main view (a highlighted line), + toggled on demand. +- With a selection showing you can: + - **`enter` / double-click** → dive into staging (files) or patch building + (commits / commit-files) **for the clicked line**. + - **`e`** → edit that line in your editor (like the staging view's `e`). + - **`G`** → open the selected line in the current branch's GitHub PR diff + (so you can comment on it). +- **Clicking** sets the selection at the clicked line; **double-click** + activates (dives in). `0` focuses *without* a selection (scroll mode). + +This all relies on `delta` emitting `lazygit-edit://:` OSC-8 +hyperlinks in the rendered diff (hence the branch name); lazygit parses those +to know which file/line a view line corresponds to. + +--- + +## 2. Branch state + +Branch: `use-delta-hyperlinks-for-clicking-in-diff` (off lazygit master). **The +working tree is clean — everything is committed.** The branch was **rebased** +in session 3 (SHAs below are current): the `LAZYGIT_SLOW_RENDER` knob was moved +to the **base** of the branch (so it can be tested against master), and the +`SetOriginX/Y` chokepoint refactor was squashed into one commit. + +### Current commit list (most recent first), `master..HEAD`: + +``` +625e7dbad Restore scroll and selection seamlessly when escaping to a focused main view ← session 3 +054d139fe Let a cmd/pty task restore a saved scroll position at its first paint ← session 3 +7f547a5a3 Reset other main views' scroll after copying content, not before ← session 3 +fe79d18b6 Route all view origin writes through SetOriginX and SetOriginY ← session 3 (chokepoint refactor; candidate for master) +89e6f6b14 Session notes: corrected flicker diagnosis and the 3 bug fixes +86f4b3486 Fire queued ReadToEnd callbacks when the initial read reaches EOF ← session 2 bug fix +b7470af27 Don't scroll a view up to fill blank space while its content is loading ← session 2 bug fix +788d959ad Lock the view and guard the line index when reading a hyperlink ← session 2 bug fix +63221c3dd Session notes +5f500893a WIP FocusedMainViewSnapshot approach ← WIP (needs rework) +207927e0d WIP New click behavior ← WIP (needs rework) +385d2e9dd Open a browser at the selected line in the diff of the current branch's PR +c5dd8ddc6 Press `e` in focused main view (when selection is showing) to edit that line +55922f81a Replace gui.showSelectionInFocusedMainView config with on-demand selection +877812c6a WIP After going straight to patch building from main view, esc goes all the way back out ← WIP (needs rework) +0088f26c1 Press enter in main view of commits panel to enter patch building for clicked line +ec50f3122 Extract some functions from CommitFilesController to a new CommitFilesHelper +ed2015cac Press enter in main view of files/commitFiles to enter staging/patch-building +1e5f31dd6 Select line that is in the middle of the screen +fff7a0d19 Press enter in focused main view when user config is on +8a26bebbb Add user config gui.showSelectionInFocusedMainView +ed48988a9 Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames ← base; candidate for master +``` + +The three **`WIP`** commits and the heavily-iterated `FocusedMainViewSnapshot` +machinery will need re-sequencing for productionization (see §8). The two +clearly-standalone, master-worthy commits (`ed48988a9` slow-render at the base, +`fe79d18b6` the `SetOriginX/Y` chokepoint) are deliberately isolated so they can +be cherry-picked off. + +--- + +## 3. Architecture primer (what we learned about lazygit internals) + +### Contexts, the stack, and `NextInStack` + +- Each panel/view is a **context**. The `ContextMgr` keeps a **stack** + (`pkg/gui/context.go`). `Push`/`Pop` manage it. Kinds: `SIDE_CONTEXT`, + `MAIN_CONTEXT`, popups, etc. +- Pushing a `SIDE_CONTEXT` **wipes the stack** down to just it. Pushing a + `MAIN_CONTEXT` **evicts other main contexts** but keeps non-main ones beneath. + Only **one main context** is ever on the stack at a time. +- A focused main view's "side panel" is found via + **`ContextMgr.NextInStack(ctx)`** — the entry just below it on the stack. + This was introduced on master in commit `bbd17abc43a` + ("Add ContextMgr.NextInStack…") specifically to **stop abusing the + parent-context mechanism** for this. Earlier prototype code on this branch + assumed the focused main view's *parent context* was its side panel; that + assumption is gone now — use `NextInStack`. (Memory: + `worktree-path-vs-repo-path` is unrelated; this is a different gotcha.) + +### The focused main view contexts vs. the patch-explorer contexts + +`pkg/gui/context/setup.go`: + +- `Normal` → `Main` view, window `"main"`; `NormalSecondary` → `Secondary` + view, window `"secondary"`. These are `MainContext` (a `SimpleContext`). + **This is the focused main view.** +- `Staging` → `Staging` view, window `"main"`; `StagingSecondary` → + `StagingSecondary` view, window `"secondary"`; `CustomPatchBuilder` → + `PatchBuilding` view, window `"main"`. These are `PatchExplorerContext` + (also `MAIN_CONTEXT`). +- **Crucial:** `Normal` and `Staging`/`CustomPatchBuilder` share the same + *window* but are **separate gocui views**. Only one view per window is shown + at a time; the others are hidden **but retain their buffer (content, scroll, + selection)**. So entering staging *hides* the `Main` view rather than + overwriting it — its scroll/selection survive **unless something explicitly + re-renders the `Main` view** (see "the clobber" below). + +### Dispatch: `GetOnClickFocusedMainView` + +- Controllers expose `GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error`. +- `pkg/gui/controllers/attach.go` registers it on the context + (`AddOnClickFocusedMainViewFn`). +- `MainViewController.enterForLine` / `onClickInAlreadyFocusedView` call + `NextInStack(self.context).GetOnClickFocusedMainView()(viewName, lineIdx)`. +- Implementers: `FilesController` (→ staging), `CommitFilesController` (→ patch + building), `SwitchToDiffFilesController` (commits/stash → patch building). +- The line/file is resolved from the `lazygit-edit://` hyperlink via + `StagingHelper.GetFileAndLineForClickedDiffLine(viewName, lineIdx)` — this + reads the hyperlink on the given **view line** (so it accounts for wrapping) + and parses `lazygit-edit://:`. + +### The async render-task system (`pkg/tasks/tasks.go`) — the crux of our blocker + +Rendering a diff into a view is **asynchronous** and **lazy**: + +- A view has a `ViewBufferManager`. `RenderToMainViews` → a **cmd task** keyed + on the **command string**. +- The initial render reads only **`linesToReadFromCmdTask(view)` lines (one + screenful, ~37)**, then the task **waits** on its `readLines` channel for + more (e.g. when you scroll down, `ViewSelectionController` requests more). +- `ViewBufferManager.ReadToEnd(then)` sends `{Total:-1, Then:then}` to + `readLines`; the loop reads to EOF, runs `onEndOfInput`, then calls `then`. + **But** if `self.readLines == nil` (no live task), `ReadToEnd` calls `then()` + **immediately/synchronously** — this is a premature-fire trap. +- A task's `readLines` is created **inside the task goroutine** (async), so + right after `Push`/render the channel may not exist yet. +- `onNewKey` (`view.SetOrigin(0,0)`) runs at task start **iff the key changed**. + Same command/key ⇒ origin preserved; different key ⇒ origin reset to top. +- `view.Reset()` (beforeStart) rewinds the write pointer; it does **not** reset + origin. `onEndOfInput` clamps origin if the new content is shorter. +- `MainViewController.openSearch` is the existing precedent that uses + `GetViewBufferManagerForView(view).ReadToEnd(func(){ OnUIThread(...) })` + — but it does so on a view that's **already focused with a live task**, which + is exactly the precondition we keep failing to establish. + +### Gocui view bits we used + +- `view.OriginY()` / `view.SetOrigin(x,y)` — scroll. `SetOrigin` clamps `<0` + only (not to content length). +- `view.SelectedLineIdx()` = `OriginY + CursorY` (absolute view-line). +- `view.FocusPoint(cx, cy, scrollIntoView)` — sets cursor to absolute `cy` + (`v.cy = cy - v.oy`); with `scrollIntoView` it adjusts origin via + `calculateNewOrigin`. **Returns early if `cy < 0 || cy > lineCount`** — so it + silently no-ops if the content isn't loaded that far. (This is why a deep + selection "doesn't take" when only a screenful is loaded.) +- `view.Highlight` / `view.HighlightInactive` — whether/how the selection is + drawn. `SimpleContext.HandleFocusLost` sets `Highlight=false` (so the + focused-main selection is cleared whenever the view loses focus). We added + `MainViewController.GetOnFocus` to reset `HighlightInactive=false` on the way + back in. + +--- + +## 4. The decided UX (don't relitigate without reason) + +- **Click = point at a line ⇒ select it.** Single-click sets/moves the + selection to the clicked line and does nothing else. **Double-click** = the + "activate/open" gesture ⇒ dive into staging/patch building for that line. + Clicking an unfocused view focuses **and** selects (one click → ready for + `e`/`G`/enter). `0` focuses with **no** selection (scroll mode) — because it + doesn't point at a line. +- **Escape from staging/patch-building should return to the focused main view + you came from**, showing the **same main-view content** again (fresh, not + stale), with the **same scroll position and selection**, and with the **main + view focused** (not the side panel). One `enter` in → one `esc` out. +- For commits/stash, "the same content" means the **whole-commit diff** you were + looking at — **not** a different focused main view (e.g. not the + commit-files file diff). Landing on a *different* focused main view was + explicitly rejected. +- "Stale content is out of the question" — when the underlying file changed + (e.g. after staging), the returned main view must re-render fresh. (We accept + that the selection may then be slightly off, since the diff changed — no fix + planned.) + +### Keybindings (focused main view, when a selection is showing) + +In `MainViewController.GetKeybindings`: `Universal.Select` (space) toggles +selection; `Universal.GoInto` (enter) dives in; `Universal.Edit` (`e`) edits; +`Commits.OpenPullRequestInBrowser` (`G`) opens the PR line; +`Universal.Return` (esc) hides selection / exits. `<`/`>` are goto top/bottom +(so `G` is free). + +--- + +## 5. The GitHub PR-line feature (working, committed `77157c5ad`) + +`MainViewController.openPullRequestForSelectedLine`: + +- URL form: `/changes/#diff-R`. + - `` = `DiffableContext.RefForAdjustingLineNumberInDiff()` of the + side panel (selected commit / the commit-files "to" ref). Using the + specific commit's view means the right-side line numbers match what's shown, + so **no `AdjustLineNumber` needed** here (unlike `e`). + - `relPath` = repo-relative path via + `filepath.Rel(RepoPaths.WorktreePath(), abs)` then `filepath.ToSlash`. + **The anchor is `sha256(relPath)` — exact bytes, forward slashes, original + case, no trailing newline.** (Verified empirically; the `#diff-…` hash is + SHA-256 of the new-file path. `R` = right/new side; `L` = left/old.) +- Branch resolution (`branchForPullRequest`): `commits` → `CheckedOutBranch`; + `subCommits` → `SubCommits.GetRef().RefName()`; `commitFiles` → recurse into + its parent context. GitHub-only (driven by `Model().PullRequestsMap`). + +### GOTCHA recorded to memory + +`WorktreePath()` vs `RepoPath()`: to make a working-tree path repo-relative use +`RepoPaths.WorktreePath()`, **not** `RepoPath()` — they differ in **linked +worktrees** (this dev setup uses `.worktrees/scratch`), and `RepoPath()` +silently produced the wrong relative path → wrong `sha256` anchor. See memory +`worktree-path-vs-repo-path`. + +--- + +## 6. THE IN-PROGRESS PROBLEM (where to resume) + +**Goal:** escaping staging/patch building that was entered from a focused main +view should return to that focused main view, fresh content, **scroll + +selection restored**, main view focused. + +### The mechanism (now committed + a little still uncommitted) + +- `types.FocusedMainViewSnapshot { SidePanel, SidePanelSelectedLineIdx, + MainView, OriginY, SelectedLineIdx }` (`pkg/gui/types/context.go`). +- Stored on `PatchExplorerContext.focusedMainViewSnapshot` (`nil` ⇒ entered the + normal way ⇒ plain `Pop()`), captured at entry in `focusedMainViewSnapshot(…)` + (`main_view_controller.go`), threaded through `FilesController.EnterFile` / + `CommitFilesHelper.EnterCommitFile`. All of this is committed in `d901a9711`. +- **Escape**: `helpers.EscapeFromPatchExplorer(c, ctx)` restores the side panel's + selection, `Push(SidePanel)`, `Push(MainView)`, then restores origin + + selection. The current version of this (the `ReadToEnd`-based restore plus the + `keepOrigin` machinery below) is the **uncommitted** part left in the working + tree — it's WIP because the flicker isn't fully solved. + +### Where session 2 landed: the flicker is fully diagnosed; 3 bug fixes committed + +Restoring scroll + selection on escape **works** (the final state is correct). +What remained was a **flicker on the way in**: a brief intermediate frame before +the view settles at the saved position. Chasing it uncovered three genuine, +independent bugs (all now committed on top of `e5326c3a6`): + +1. **`6c7d9a295` Lock the view + guard the line index in `HyperLinkInLine`.** + It read `v.lines`/`v.viewLines` with no `writeMutex`, racing a concurrent + re-render, and indexed `v.lines[viewLines[y].linesY]` after only checking `y` + against `len(viewLines)`. Because `refreshViewLinesIfNeeded` overwrites + `viewLines` *in place without truncating*, the tail keeps stale entries + whose `linesY` points past a shrunk `v.lines` → out-of-range panic on `enter` + while a shorter diff was still loading. +2. **`3b31cfe01` Don't scroll a view up to fill blank space while loading.** + The layout's scroll-up clamp ([`layout.go`], added in `6114f69ee5ef`) clamps + a view's origin to `TotalContentHeight()` — which for a main view is just the + **lines loaded so far**. During an async re-render that's a fraction of the + eventual content, so it yanked the view to the top. Fix: a synchronously-set + `ViewBufferManager.loading` flag (set in the cmd/pty wrappers *before* the + layout pass, cleared at EOF but **not** on stop), and the layout skips the + clamp while loading. +3. **`a4b72a6f6` Fire queued `ReadToEnd` callbacks when the initial read hits + EOF.** The read loop processes one request at a time; the initial request has + no `Then` and a large line count, so if the content is shorter it hits EOF on + that request and `break`s out, abandoning any queued `ReadToEnd` request in + the channel → its `Then` silently dropped (this was session 1's "ReadToEnd's + `then` never fired" mystery!). Fix: drain queued requests and fire their + `Then`s on EOF. + +### Corrected diagnosis (session 1's §6 diagnosis was WRONG in its mechanism) + +Session 1 said "on restore only the initial screenful (`height=37`) is loaded, +so `FocusPoint` returns early." **That was inaccurate.** The truth, confirmed by +instrumenting **every** write to the main view's `oy` (see Debug tooling §10): + +- `linesToReadFromCmdTask` reads `height*(height-1)+oy` lines (≈1332+, capped at + 5000) — **not** one screenful. For typical diffs the whole thing loads quickly. +- The scroll wasn't failing because content was unloaded at *restore* time (the + `ReadToEnd` restore, once the drain fix above made it fire, sets the final + position correctly). It was failing because the **layout clamp** (bug #2) was + resetting `oy` to 0 on *every layout pass* during the async load, until the + content caught up. That is the real cause of "scroll resets to the top." + +### The full origin-reset chain on escape (and how each is handled now) + +Tracing every `oy` write during a commits-scrolled-down escape, three different +things were all moving the origin off the saved value: + +1. **`onNewKey`** (`tasks_adapter.go`) resets `oy` to 0 when the re-render's + command key differs from the last one (it does, because the commit-files + render clobbered the main view on entry). → handled by + `ViewBufferManager.KeepOriginForNextTask()` (uncommitted feature machinery), + which suppresses that one reset. +2. **`CopyContent`** (`view.go`, via `moveMainContextToTop`) copies the + *previous top view's* buffer **and origin** into the main view to avoid a + blank frame. → handled by re-asserting `SetOrigin(saved)` after the pushes. +3. **The layout scroll-up clamp** → handled by bug fix #2 (the `loading` flag). + +### The one remaining flicker (and the correct fix — IMPLEMENTED in session 3, see §11) + +> **Update (session 3):** the fix described below was implemented, but the +> diagnosis here was *incomplete* in two ways that §11 corrects: (a) the +> `onNewKey` suppression could **not** be dropped, and (b) there was a **second** +> source of the top-flicker — the scroll-reset loop in `refreshMainViews`. Read +> §11 as the current truth; the text below is session 2's understanding. + +With all three handled, the *scroll no longer jumps*. But there's still a brief +intermediate frame, and we found exactly what it is: **`CopyContent` seeds the +main view with the patch-building view's buffer**, and since we set the origin +to the saved position (far down) while that placeholder is shorter, the draw +shows the placeholder's *last line* at the top with blank below — until the pty +task finishes loading the real diff and repaints at the saved position. (It +"appears scrolled up by a varying amount" purely because what shows at the saved +`oy` depends on the patch's *length*, via `min(oy, patchLines-1)`.) + +**NOTE — a rejected red herring:** "avoid clobbering the main view on entry" +does **not** fix this. `CopyContent` overwrites the main view's buffer +regardless of what was there, so preserving the original commit diff on entry +wouldn't change the placeholder frame. + +**The correct fix (user's conclusion, agreed):** we're applying the saved origin +*too early*. It must be applied *exactly* when the pty task does its first +repaint (when it has read enough to fill the view at the saved scroll). The +catch: `InitialRefreshAfter` — which decides *when* that first repaint happens — +is computed from the view's `OriginY` **at task-creation time**. So the target +origin must be known at creation (so the task reads enough), but the view must +keep showing the placeholder until that first paint, and only snap to the saved +position *as part of* that paint. Concretely: **a cmd/pty analogue of +`RenderStringWithScrollTask`** — "render this command and scroll to Y once +you've read enough" — applying the origin at the `InitialRefreshAfter` refresh +rather than up front. This is the concrete next step; it's bounded but real +work, and likely lets us drop the `keepOrigin` + after-push `SetOrigin` +machinery (they'd be subsumed by the task setting the origin itself). + +--- + +## 7. Prototype enhancements still missing (for an "enhance" session) + +- **Directory case follow-up:** entering staging from a files/commit-files + **directory** selection expands the tree and changes the selection to the + clicked file. We restore the side panel's selected line on exit + (`SidePanelSelectedLineIdx`) so the main view shows the directory's combined + diff again — but we **don't restore the tree's expanded/collapsed state**, so + the panel comes back more expanded than it was. Decide whether to restore that + too. Also, the directory case shares the scroll/selection-restore bug above. +- **`onClickInOtherViewOfMainViewPair`** (clicking the other pane of a main + view pair) now also selects + double-click-stages for consistency; double-check + this is desired and that the secondary-pane paths behave. +- **Stale selection after stage/unstage:** explicitly accepted as out of scope; + no fix planned. +- **No integration tests** exist for any of the focused-main-view interactions + (click/double-click/`enter`/`e`/`G`/escape-restore). They were skipped on + purpose during prototyping. + +--- + +## 8. Productionization notes (for a future planning session — do NOT plan yet) + +Context a planning session will need: + +- **Commit history needs rework.** Two `WIP` commits (`673b90c10` "esc goes all + the way back out", `30e625a8d` "New click behavior") plus the large + uncommitted escape/restore change. AGENTS.md (this repo) mandates: small, + self-contained, compiling, `gofumpt`-clean commits; "why not what" messages; + prep-refactors split from behavior changes; `fixup!`/`amend!` against the + right commit and `git rebase --autosquash`; no conventional-commit prefixes. + The escape/restore work especially will want to be re-sequenced into clean + commits (and the `escapeContext` → `FocusedMainViewSnapshot` evolution + collapsed, since it was iterated heavily). +- **Demonstrate-bugs-before-fixing** pattern (AGENTS.md) with `EXPECTED`/`ACTUAL` + — relevant if any of this lands as bug-fix-shaped commits. +- **Tests:** integration tests live under `pkg/integration/tests/...`; conventions + in AGENTS.md (chain `t.Views().()` fluently, no local view vars; use + `stretchr/testify`). A unit-testable seam worth noting: the scroll/selection + restore and the GitHub-anchor URL builder + (`githubPullRequestLineURL`) are pure-ish and could be unit-tested; the patch + index↔view line wrapping logic lives in `pkg/gui/patch_exploring/state.go`. +- **Config:** `gui.showSelectionInFocusedMainView` was added then **removed** + (`c4aba31c9`) in favor of on-demand selection — don't reintroduce a config + toggle for this without reason. +- **Commands:** use the `justfile` recipes (`just generate` regenerates the test + list + cheatsheets and CI fails if stale; `just format`, `just build`, + `just unit-test`, `just e2e-all`, `just lint`). Prefer `just` over `make`. + Adding/renaming a keybinding ⇒ run `just generate` and commit the result + (note: gated descriptions — the focused-main bindings use empty descriptions + when no selection is shown, so they don't appear in cheatsheets, matching the + existing `enter` binding). +- The unrelated `M AGENTS.md` in the working tree is the "Common commands" + section documenting `just` — keep or commit separately. + +--- + +## 9. Key files (quick map) + +- `pkg/gui/controllers/main_view_controller.go` — the focused main view + controller: keybindings, `toggleSelection`, `enter`/`enterForLine`, `editLine`, + `openPullRequestForSelectedLine`, `branchForPullRequest`, click handlers, + `showSelectionAtLine`, `focusedMainViewContextForViewName`, + `focusedMainViewSnapshot`, `githubPullRequestLineURL`. +- `pkg/gui/controllers/switch_to_focused_main_view_controller.go` — focuses the + main view from a side panel (`0` / click); click passes a line so it selects, + `0` passes -1 so it doesn't. +- `pkg/gui/controllers/switch_to_diff_files_controller.go` — commits/stash → + patch building entry (`GetOnClickFocusedMainView`, `enter`). +- `pkg/gui/controllers/files_controller.go` — files → staging entry + (`GetOnClickFocusedMainView`, `EnterFile`). +- `pkg/gui/controllers/commits_files_controller.go` — commit-files → patch + building entry. +- `pkg/gui/controllers/helpers/commit_files_helper.go` — `EnterCommitFile`. +- `pkg/gui/controllers/helpers/patch_building_helper.go` — `Escape` + + `EscapeFromPatchExplorer` (the shared escape/restore logic). +- `pkg/gui/controllers/staging_controller.go` — `Escape` (calls + `EscapeFromPatchExplorer`). +- `pkg/gui/context/patch_explorer_context.go` — `FocusedMainViewSnapshot` + storage. +- `pkg/gui/types/context.go` — `FocusedMainViewSnapshot`, `IPatchExplorerContext` + additions. +- `pkg/gui/controllers/helpers/staging_helper.go` — + `GetFileAndLineForClickedDiffLine` (hyperlink parsing). +- `pkg/tasks/tasks.go` — the async render-task system (`ViewBufferManager`, + `ReadToEnd`, the read loop). Session 3 added `ScrollToOriginYForNextTask` / + `GetScrollToOriginYForNextTask`, `LinesToRead.ApplyInitialScroll`, the + first-paint apply in the read loop, and the `onNewKey` suppression (§11). Also + hosts the committed `LAZYGIT_SLOW_RENDER` knob. +- `pkg/gui/tasks_adapter.go` + `pkg/gui/pty.go` — cmd/pty task wrappers; both now + peek the manager's pending scroll and pass it to + `linesToReadFromCmdTask(view, targetOriginY)` (`view_helpers.go`). +- `pkg/gui/main_panels.go` — `refreshMainViews` (the scroll-reset loop, **now + after** `moveMainContextPairToTop`, §11) and `moveMainContextToTop` → + `CopyContent`. +- `pkg/gui/layout.go` — the scroll-up-to-fill clamp (`setViewFromDimensions`); + skipped while a view's task `IsLoading()`. +- `pkg/gocui/view.go` — `SetOriginX`/`SetOriginY` are now the **single + chokepoints** for all `ox`/`oy` writes (`fe79d18b6`); ideal breakpoint spot. + +--- + +## 10. Debug tooling + +### Slow down rendering (`LAZYGIT_SLOW_RENDER=`) — now COMMITTED + +This is no longer a paste-back snippet: it's committed at the **base** of the +branch (`ed48988a9`). Sleeps `` after each line written to a view, so the +frames of an async re-render become visible. No effect when unset. Run as +`LAZYGIT_SLOW_RENDER=40 just debug` (with `just print-log` in another tab). +**This is the tool that makes the remaining timing races (§11) visible** — they +are essentially invisible at normal speed. + +### Trace every change to a view's scroll position — now a single chokepoint + +Session 3's `SetOriginX`/`SetOriginY` refactor (`fe79d18b6`) routed **every** +write to `v.oy`/`v.ox` through `SetOriginY`/`SetOriginX`. So you no longer need +to scatter the tracer across `SetOrigin`/`CopyContent`/`FocusPoint`/`draw`/ +`ScrollUp`/`ScrollDown` — **set one breakpoint (or one log line) inside +`SetOriginY` in `pkg/gocui/view.go`** and you catch all of them, with the +`bt`/Call-Stack giving the caller. (This is exactly how session 3 found the +`refreshMainViews` reset-loop cause — see §11.) The old multi-site +`debugMainOriginReset(v, newY)` helper still works if you want a `/tmp` log with +a trimmed call stack; drop it into `SetOriginY` and filter by `v.name`: + +```go +func debugMainOriginReset(v *View, newY int) { + if v.name != "main" || newY == v.oy { + return + } + pc := make([]uintptr, 6) + n := runtime.Callers(3, pc) + frames := runtime.CallersFrames(pc[:n]) + var b strings.Builder + for i := 0; i < 4; i++ { + fr, more := frames.Next() + fmt.Fprintf(&b, " <- %s:%d", fr.File[strings.LastIndex(fr.File, "/")+1:], fr.Line) + if !more { + break + } + } + if f, err := os.OpenFile("/tmp/fmvs_origin.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { + fmt.Fprintf(f, "main oy %d->%d%s\n", v.oy, newY, b.String()) + f.Close() + } +} +``` + +--- + +## 11. Session 3: the flicker fix (implemented) + remaining timing races + +Session 3 turned §6's proposal into working, committed code, and corrected the +diagnosis twice along the way. At **normal speed the escape is now flicker-free**. + +### What "applying the saved scroll at first paint" became (commit `054d139fe`) + +The cmd/pty analogue of `RenderStringWithScrollTask`, driven by one field on +`ViewBufferManager`: + +- **`ScrollToOriginYForNextTask(originY int)`** sets `scrollToOriginYForNextTask + *int`. The escape calls it on the main view's manager **before** the re-render + is triggered. It has *two* effects on the next cmd/pty task: + 1. **Suppresses the start-of-task origin reset** (`onNewKey`) — so the + `CopyContent` placeholder keeps showing at *its* scroll instead of being + yanked to the top. (This is the part session 2 thought we could drop. We + can't — see below.) + 2. **Sizes the initial read to `originY`** (`linesToReadFromCmdTask(view, + targetOriginY *int)` uses it instead of the view's current `OriginY`) **and + scrolls there at the first refresh** via a new `LinesToRead.ApplyInitialScroll` + callback, applied once (guarded by `sync.Once`) — at the `InitialRefreshAfter` + point, and in the EOF branch *before* `onEndOfInput` (so a now-shorter diff + gets clamped back into range). +- The field is **peeked** (`GetScrollToOriginYForNextTask`) by the cmd/pty + wrappers (`tasks_adapter.go`, `pty.go`) to size the read, and **cleared in + `NewTask`** after the `onNewKey` decision — so it survives long enough to drive + both effects, and applies to exactly one task. (Per-view managers, so the + secondary view isn't affected.) +- Behaviour-preserving until a caller sets it. + +### Escape wiring simplified (commit `625e7dbad`) + +`EscapeFromPatchExplorer` now just calls `ScrollToOriginYForNextTask(snapshot.OriginY)` +before the pushes, and restores the **selection only** (`FocusPoint` + highlight) +via `ReadToEnd` once the diff is fully loaded. The session-2 dance — up-front +`SetOrigin`, after-push `SetOrigin`, and `KeepOriginForNextTask` — is **gone**; +the task owns the scroll now. + +### Correction #1: `onNewKey` suppression could NOT be dropped + +§6 predicted the new mechanism would let us drop the `onNewKey` suppression. It +didn't. `CopyContent`'s entire purpose is that the newly-revealed view keeps +showing the previous view's content **at its scroll** ("as if nothing changed") +until the real content paints. Letting `onNewKey` reset that to the top *is* a +flicker. So the suppression is kept — folded into the same +`scrollToOriginYForNextTask` field (effect #1 above) rather than a separate +`keepOrigin` flag. + +### Correction #2: there was a SECOND cause of the top-flicker — `refreshMainViews` (commit `7f547a5a3`) + +Even with `onNewKey` suppressed, the placeholder still flicked to the top under +slow render. A `SetOriginY` breakpoint (trivial now, thanks to the chokepoint +refactor) caught it: `refreshMainViews` (`main_panels.go`) reset the scroll of +every *other* main view at the **top** of the function — i.e. it zeroed the +patch-building view's origin **before** `moveMainContextPairToTop` → +`CopyContent` copied that view (now at origin 0) into the Normal view. So the 0 +came from the reset feeding `CopyContent`, *independent of* `onNewKey`. + +**Fix:** move the reset loop to **after** `moveMainContextPairToTop`. End state is +unchanged (every other main still ends at 0, and the destination always +re-renders), but `CopyContent` now copies the source at its real scroll, so the +placeholder stays put. This also makes *every* cross-pair transition's +placeholder seamless, not just our escape. + +### Verification + +- `just build` / `just lint` / `just unit-test` all green. (`TestNewCmdTaskInstantStop` + is a **pre-existing timing flake** that only trips under the full suite's + parallel load; passes 10/10 in isolation, and the session-3 task changes are + inert on its instant-stop path.) +- `just e2e-all`: green **except** `worktree/associate_branch_rebase`, which + fails *environmentally* — `cd`-ing into the linked worktree triggers lazygit's + direnv integration to pop a "Press to run 'direnv allow'" confirmation + (this checkout's `.envrc` is blocked), stealing focus from the `.Focus()` + assertion. Run `direnv allow` (or confirm it fails the same on `master`). + +### Remaining timing races (DO THIS before productionizing) + +At normal speed there's no visible flicker, but under `LAZYGIT_SLOW_RENDER` +**occasional** imperfect intermediate frames remain. The user's explicit call: +these point to **real timing races** in the async render/scroll path, and we +should *eliminate* them rather than rely on normal timing masking them. Not yet +characterised — next session should: + +- Reproduce under `LAZYGIT_SLOW_RENDER` (try a range of values; the races are + intermittent) across the three transitions (files→staging, commit→patch-building, + and the escape), all while **scrolled down**. +- Use the single `SetOriginY` chokepoint + `bt` and/or the §10 tracer, plus the + `ReadToEnd`/`InitialRefreshAfter`/`ApplyInitialScroll` ordering, to pin which + interleavings produce a bad frame. Suspects worth scrutinising: the ordering + between the task's first `ApplyInitialScroll` paint and the `ReadToEnd`-driven + selection restore; the `afterLayout`-deferred pty task creation racing a layout + pass; and `CopyContent` vs. the task's first write. +- Memory: `focused-main-view-flicker-timing-races`. diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go index fbbf3c93554..e197b8f36ef 100644 --- a/pkg/commands/patch/patch.go +++ b/pkg/commands/patch/patch.go @@ -114,6 +114,38 @@ func (self *Patch) LineNumberOfLine(idx int) int { return hunk.newStart + offset } +// Takes a line number in the new file and returns the line index in the patch. +// This is the opposite of LineNumberOfLine. +// If the line number is not contained in any of the hunks, it returns the +// closest position. +func (self *Patch) PatchLineForLineNumber(lineNumber int) int { + if len(self.hunks) == 0 { + return len(self.header) + } + + for hunkIdx, hunk := range self.hunks { + if lineNumber <= hunk.newStart { + return self.HunkStartIdx(hunkIdx) + } + + if lineNumber < hunk.newStart+hunk.newLength() { + lines := hunk.bodyLines + offset := lineNumber - hunk.newStart + for i, line := range lines { + if offset == 0 { + return self.HunkStartIdx(hunkIdx) + i + 1 + } + + if line.Kind == ADDITION || line.Kind == CONTEXT { + offset-- + } + } + } + } + + return self.LineCount() - 1 +} + // Returns hunk index containing the line at the given patch line index func (self *Patch) HunkContainingLine(idx int) int { for hunkIdx, hunk := range self.hunks { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 166cb0e2cf8..eb4fb001124 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -383,7 +383,7 @@ func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) { if scrollIntoView { height := v.InnerHeight() - v.oy = calculateNewOrigin(cy, v.oy, lineCount, height) + v.SetOriginY(calculateNewOrigin(cy, v.oy, lineCount, height)) } v.cx = cx @@ -645,15 +645,8 @@ func (v *View) CursorY() int { // implement Horizontal and Vertical scrolling with just incrementing // or decrementing ox and oy. func (v *View) SetOrigin(x, y int) { - if x < 0 { - x = 0 - } - if y < 0 { - y = 0 - } - - v.ox = x - v.oy = y + v.SetOriginX(x) + v.SetOriginY(y) } func (v *View) SetOriginX(x int) { @@ -1062,8 +1055,8 @@ func (v *View) CopyContent(from *View) { v.lines = from.lines v.viewLines = from.viewLines - v.ox = from.ox - v.oy = from.oy + v.SetOriginX(from.ox) + v.SetOriginY(from.oy) v.cx = from.cx v.cy = from.cy } @@ -1228,14 +1221,14 @@ func (v *View) draw() { if maxX == 0 { return } - v.ox = 0 + v.SetOriginX(0) } v.refreshViewLinesIfNeeded() visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines() if v.Autoscroll && visibleViewLinesHeight > maxY { - v.oy = visibleViewLinesHeight - maxY + v.SetOriginY(visibleViewLinesHeight - maxY) } if len(v.viewLines) == 0 { @@ -1510,6 +1503,35 @@ func (v *View) Word(x, y int) (string, bool) { return str[nl:nr], true } +func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { + // Take the lock so we don't race a concurrent re-render that is rebuilding the + // buffer. + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + + if y < 0 || y >= len(v.viewLines) { + return "", false + } + + // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, + // so while a shorter re-render is loading, the tail of viewLines can still + // hold stale entries pointing past the (shrunk) v.lines. Guard against that. + linesY := v.viewLines[y].linesY + if linesY >= len(v.lines) { + return "", false + } + + for _, c := range v.lines[linesY] { + if strings.HasPrefix(c.hyperlink, urlScheme) { + return c.hyperlink, true + } + } + + return "", false +} + // indexFunc allows to split lines by words taking into account spaces // and 0. func indexFunc(r rune) bool { @@ -1855,7 +1877,7 @@ func (v *View) ScrollUp(amount int) { } if amount != 0 { - v.oy -= amount + v.SetOriginY(v.oy - amount) v.cy += amount v.clearHover() @@ -1867,7 +1889,7 @@ func (v *View) ScrollUp(amount int) { func (v *View) ScrollDown(amount int) { adjustedAmount := v.adjustDownwardScrollAmount(amount) if adjustedAmount > 0 { - v.oy += adjustedAmount + v.SetOriginY(v.oy + adjustedAmount) v.cy -= adjustedAmount v.clearHover() @@ -1881,7 +1903,7 @@ func (v *View) ScrollLeft(amount int) { newOx = 0 } if newOx != v.ox { - v.ox = newOx + v.SetOriginX(newOx) v.clearHover() } @@ -1889,7 +1911,7 @@ func (v *View) ScrollLeft(amount int) { // not applying any limits to this func (v *View) ScrollRight(amount int) { - v.ox += amount + v.SetOriginX(v.ox + amount) v.clearHover() } diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 334c2e374bc..d1a935d5406 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -20,6 +20,11 @@ type PatchExplorerContext struct { // true if we're inside the OnSelectItem callback; in that case we don't want to update the // search result index. inOnSelectItemCallback bool + + // Set when this patch explorer was entered from a focused main view, so that + // escaping returns there; nil for the normal flow. See + // types.FocusedMainViewSnapshot. + focusedMainViewSnapshot *types.FocusedMainViewSnapshot } var ( @@ -60,6 +65,14 @@ func NewPatchExplorerContext( func (self *PatchExplorerContext) IsPatchExplorerContext() {} +func (self *PatchExplorerContext) GetFocusedMainViewSnapshot() *types.FocusedMainViewSnapshot { + return self.focusedMainViewSnapshot +} + +func (self *PatchExplorerContext) SetFocusedMainViewSnapshot(snapshot *types.FocusedMainViewSnapshot) { + self.focusedMainViewSnapshot = snapshot +} + func (self *PatchExplorerContext) GetState() *patch_exploring.State { return self.state } diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 51e240a5d55..a1d6eb12d3b 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -52,8 +52,9 @@ func (gui *Gui) resetHelpersAndControllers() { gpgHelper := helpers.NewGpgHelper(helperCommon) viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts) + windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper) patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon) - stagingHelper := helpers.NewStagingHelper(helperCommon) + stagingHelper := helpers.NewStagingHelper(helperCommon, windowHelper) mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon) searchHelper := helpers.NewSearchHelper(helperCommon) @@ -73,7 +74,6 @@ func (gui *Gui) resetHelpersAndControllers() { rebaseHelper, ) bisectHelper := helpers.NewBisectHelper(helperCommon) - windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper) modeHelper := helpers.NewModeHelper( helperCommon, diffHelper, @@ -108,6 +108,7 @@ func (gui *Gui) resetHelpersAndControllers() { FixupHelper: helpers.NewFixupHelper(helperCommon), Commits: commitsHelper, SuspendResume: helpers.NewSuspendResumeHelper(helperCommon), + CommitFiles: helpers.NewCommitFilesHelper(helperCommon, patchBuildingHelper), Snake: helpers.NewSnakeHelper(helperCommon), Diff: diffHelper, Repos: reposHelper, diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index eed9d02b91c..a6f76eaf9ad 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -442,7 +442,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm toggle := func() error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error { if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { + if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil { return err } } @@ -485,7 +485,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm }) } - from, to, reverse := self.currentFromToReverseForPatchBuilding() + from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding() mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ Title: self.c.Tr.DiscardPatch, @@ -505,68 +505,8 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) return self.toggleForPatch([]*filetree.CommitFileNode{root}) } -func (self *CommitFilesController) startPatchBuilder() error { - commitFilesContext := self.context() - - canRebase := commitFilesContext.GetCanRebase() - from, to, reverse := self.currentFromToReverseForPatchBuilding() - - self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase) - return nil -} - -func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) { - commitFilesContext := self.context() - - from, to := commitFilesContext.GetFromAndToForDiff() - from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - return from, to, reverse -} - func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) -} - -func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { - if node.File == nil { - return self.handleToggleCommitFileDirCollapsed(node) - } - - if self.c.UserConfig().Git.DiffContextSize == 0 { - return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - } - - from, to, reverse := self.currentFromToReverseForPatchBuilding() - mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) - return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ - Title: self.c.Tr.DiscardPatch, - Prompt: self.c.Tr.DiscardPatchConfirm, - HandleConfirm: func() error { - if mustDiscardPatch { - self.c.Git().Patch.PatchBuilder.Reset() - } - - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { - return err - } - } - - self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) - self.c.Helpers().PatchBuilding.ShowHunkStagingHint() - - return nil - }, - }) -} - -func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error { - self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) - - self.c.PostRefreshUpdate(self.context()) - - return nil + return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } // NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics @@ -595,11 +535,39 @@ func (self *CommitFilesController) expandAll() error { func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { + // Capture before any mutation below that might re-render the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx) + + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + line = -1 + } + node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) + if node == nil { + return nil } - return nil + + if !node.IsFile() && ok { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + self.context().CommitFileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(self.context()) + + idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath) + if ok { + self.context().SetSelectedLineIdx(idx) + self.context().GetViewTrait().FocusPoint( + self.context().ModelIndexToViewIndex(idx), false) + node = self.context().GetSelected() + } + } + + // Entered from the focused main view, so escaping returns there. + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 09f654e2bd9..b637cf68047 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -361,11 +361,37 @@ func (self *FilesController) GetOnDoubleClick() func() error { func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { - node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) + // Capture before any mutation below that might re-render the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx) + + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + line = -1 } - return nil + + node := self.context().GetSelected() + if node == nil { + return nil + } + + if !node.IsFile() && ok { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + self.context().FileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(self.context()) + + idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath) + if ok { + self.context().SetSelectedLineIdx(idx) + self.context().GetViewTrait().FocusPoint( + self.context().ModelIndexToViewIndex(idx), false) + } + } + + return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) } } @@ -652,7 +678,7 @@ func (self *FilesController) getSelectedFile() *models.File { } func (self *FilesController) enter() error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) + return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } func (self *FilesController) collapseAll() error { @@ -671,7 +697,11 @@ func (self *FilesController) expandAll() error { return nil } -func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { +// focusedMainViewSnapshot records the focused main view to return to when +// escaping the staging view, for the case where we're entering it straight from +// there; it's nil for the normal flow that goes through the files panel. See +// types.FocusedMainViewSnapshot. +func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error { node := self.context().GetSelected() if node == nil { return nil @@ -697,6 +727,9 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { } context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) + // Set on every entry (so it can't leak from a previous main-view entry into a + // subsequent normal one), right as we push the staging view. + context.SetFocusedMainViewSnapshot(focusedMainViewSnapshot) self.c.Context().Push(context, opts) self.c.Helpers().PatchBuilding.ShowHunkStagingHint() @@ -1360,7 +1393,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro } func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) + return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) } func (self *FilesController) fetch() error { diff --git a/pkg/gui/controllers/helpers/commit_files_helper.go b/pkg/gui/controllers/helpers/commit_files_helper.go new file mode 100644 index 00000000000..be0df21d997 --- /dev/null +++ b/pkg/gui/controllers/helpers/commit_files_helper.go @@ -0,0 +1,94 @@ +package helpers + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type CommitFilesHelper struct { + c *HelperCommon + + patchBuildingHelper *PatchBuildingHelper +} + +func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHelper) *CommitFilesHelper { + return &CommitFilesHelper{ + c: c, + patchBuildingHelper: patchBuildingHelper, + } +} + +// focusedMainViewSnapshot records the focused main view to return to when +// escaping the patch builder, for the case where we're entering it straight from +// there; it's nil for the normal flow that goes through the commit files panel. +// See types.FocusedMainViewSnapshot. +func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error { + if node.File == nil { + self.handleToggleCommitFileDirCollapsed(node) + return nil + } + + if self.c.UserConfig().Git.DiffContextSize == 0 { + return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) + } + + from, to, reverse := self.CurrentFromToReverseForPatchBuilding() + mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) + return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ + Title: self.c.Tr.DiscardPatch, + Prompt: self.c.Tr.DiscardPatchConfirm, + HandleConfirm: func() error { + if mustDiscardPatch { + self.c.Git().Patch.PatchBuilder.Reset() + } + + if !self.c.Git().Patch.PatchBuilder.Active() { + if err := self.StartPatchBuilder(); err != nil { + return err + } + } + + // Set on every entry (so it can't leak from a previous main-view + // entry into a subsequent normal one), right as we push the patch + // builder. + self.c.Contexts().CustomPatchBuilder.SetFocusedMainViewSnapshot(focusedMainViewSnapshot) + + self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) + self.patchBuildingHelper.ShowHunkStagingHint() + + return nil + }, + }) +} + +func (self *CommitFilesHelper) context() *context.CommitFilesContext { + return self.c.Contexts().CommitFiles +} + +func (self *CommitFilesHelper) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) { + self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) + + self.c.PostRefreshUpdate(self.context()) +} + +func (self *CommitFilesHelper) StartPatchBuilder() error { + commitFilesContext := self.context() + + canRebase := commitFilesContext.GetCanRebase() + from, to, reverse := self.CurrentFromToReverseForPatchBuilding() + + self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase) + return nil +} + +func (self *CommitFilesHelper) CurrentFromToReverseForPatchBuilding() (string, string, bool) { + commitFilesContext := self.context() + + from, to := commitFilesContext.GetFromAndToForDiff() + from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + return from, to, reverse +} diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d81..4ff7f24c0d9 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -36,6 +36,7 @@ type Helpers struct { FixupHelper *FixupHelper Commits *CommitsHelper SuspendResume *SuspendResumeHelper + CommitFiles *CommitFilesHelper Snake *SnakeHelper // lives in context package because our contexts need it to render to main Diff *DiffHelper @@ -74,6 +75,7 @@ func NewStubHelpers() *Helpers { AmendHelper: &AmendHelper{}, FixupHelper: &FixupHelper{}, Commits: &CommitsHelper{}, + CommitFiles: &CommitFilesHelper{}, Snake: &SnakeHelper{}, Diff: &DiffHelper{}, Repos: &ReposHelper{}, diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index 9369cca93fb..6255fb27adb 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -32,9 +32,76 @@ func (self *PatchBuildingHelper) ShowHunkStagingHint() { } } -// takes us from the patch building panel back to the commit files panel +// takes us from the patch building panel back to the commit files panel, or to +// the focused main view if that's where we entered it from func (self *PatchBuildingHelper) Escape() { - self.c.Context().Pop() + EscapeFromPatchExplorer(self.c, self.c.Contexts().CustomPatchBuilder) +} + +// EscapeFromPatchExplorer returns from a patch explorer context (staging or +// patch building). If we entered it from a focused main view, we go back to +// where we came from (re-rendering the side panel's content into the main view, +// like the plain escape does), then focus the main view and restore its scroll +// position and selection. Otherwise we just pop to the side panel. +func EscapeFromPatchExplorer(c *HelperCommon, context types.IPatchExplorerContext) { + snapshot := context.GetFocusedMainViewSnapshot() + if snapshot == nil { + c.Context().Pop() + return + } + + context.SetFocusedMainViewSnapshot(nil) + + // Restore the side panel's selection before we render it, so it shows the + // same content the main view had (diving into staging can change it, e.g. + // from a directory to a file in the files panel). + if listContext, ok := snapshot.SidePanel.(types.IListContext); ok && snapshot.SidePanelSelectedLineIdx >= 0 { + listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx) + } + + view := snapshot.MainView.GetView() + + // Ask the upcoming re-render to restore the scroll position. Pushing the side + // panel re-renders its content into the main view via a cmd/pty task. Until + // that content is ready, the main view keeps showing the placeholder that + // CopyContent left in it (the view we're leaving) at its current scroll; the + // task then scrolls to the saved position as part of the first paint that + // shows the real content. Doing it this way (rather than setting the origin up + // front) avoids both a jump to the top and a misplaced placeholder frame. + if manager := c.GetViewBufferManagerForView(view); manager != nil { + manager.ScrollToOriginYForNextTask(snapshot.OriginY) + } + + // Land on the side panel first (this re-renders the original content into the + // main view), then focus the main view on top of it. + c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{}) + c.Context().Push(snapshot.MainView, types.OnFocusOpts{}) + + restore := func() { + view.FocusPoint(0, snapshot.SelectedLineIdx, false) + view.Highlight = true + view.HighlightInactive = false + } + + // The scroll position is handled by the re-render above, but the selection + // still needs the content loaded down to the selected line, which happens + // asynchronously. Wait until the diff has been fully read before restoring + // it. We do this on the next UI tick, by which point the re-render task is + // live and ReadToEnd can hook into it. + c.OnUIThread(func() error { + manager := c.GetViewBufferManagerForView(view) + if manager == nil { + restore() + return nil + } + manager.ReadToEnd(func() { + c.OnUIThread(func() error { + restore() + return nil + }) + }) + return nil + }) } // kills the custom patch and returns us back to the commit files panel if needed @@ -56,8 +123,10 @@ func (self *PatchBuildingHelper) Reset() error { func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) { selectedLineIdx := -1 + selectedRealLineIdx := -1 if opts.ClickedWindowName == "main" { selectedLineIdx = opts.ClickedViewLineIdx + selectedRealLineIdx = opts.ClickedViewRealLineIdx } if !self.c.Git().Patch.PatchBuilder.Active() { @@ -89,7 +158,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt oldState := context.GetState() - state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView) + state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode) context.SetState(state) if state == nil { self.Escape() diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 55b9c133bd0..16b21e7773c 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -1,20 +1,26 @@ package helpers import ( + "regexp" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" ) type StagingHelper struct { - c *HelperCommon + c *HelperCommon + windowHelper *WindowHelper } func NewStagingHelper( c *HelperCommon, + windowHelper *WindowHelper, ) *StagingHelper { return &StagingHelper{ - c: c, + c: c, + windowHelper: windowHelper, } } @@ -30,12 +36,16 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { } mainSelectedLineIdx := -1 + mainSelectedRealLineIdx := -1 secondarySelectedLineIdx := -1 + secondarySelectedRealLineIdx := -1 if focusOpts.ClickedViewLineIdx > 0 { if secondaryFocused { secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx + secondarySelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx } else { mainSelectedLineIdx = focusOpts.ClickedViewLineIdx + mainSelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx } } @@ -64,11 +74,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView mainContext.SetState( - patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode), + patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode), ) secondaryContext.SetState( - patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode), + patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode), ) mainState := mainContext.GetState() @@ -125,3 +135,20 @@ func (self *StagingHelper) secondaryStagingFocused() bool { func (self *StagingHelper) mainStagingFocused() bool { return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey() } + +func (self *StagingHelper) GetFileAndLineForClickedDiffLine(windowName string, lineIdx int) (string, int, bool) { + v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName)) + hyperlink, ok := v.HyperLinkInLine(lineIdx, "lazygit-edit:") + if !ok { + return "", 0, false + } + + re := regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`) + matches := re.FindStringSubmatch(hyperlink) + if matches == nil { + return "", 0, false + } + filepath := matches[1] + lineNumber := utils.MustConvertToInt(matches[2]) + return filepath, lineNumber, true +} diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 6eb6c86e370..3dd4876f7f3 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -1,9 +1,16 @@ package controllers import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type MainViewController struct { @@ -30,6 +37,24 @@ func NewMainViewController( } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + // When a selection is shown, we surface the bindings that act on it + // (enter to dive into staging, e to edit the selected line, G to open the + // line in the branch's pull request, escape to hide the selection). + selectionShown := self.context.GetView().Highlight + + var enterDescription string + var editDescription string + var editTooltip string + var openPullRequestDescription string + var openPullRequestTooltip string + if selectionShown { + enterDescription = self.c.Tr.EnterStaging + editDescription = self.c.Tr.EditFile + editTooltip = self.c.Tr.EditFileTooltip + openPullRequestDescription = self.c.Tr.OpenPullRequestForSelectedLine + openPullRequestTooltip = self.c.Tr.OpenPullRequestForSelectedLineTooltip + } + return []*types.Binding{ { Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), @@ -44,6 +69,30 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: self.c.Tr.ExitFocusedMainView, DisplayOnScreen: true, }, + { + Keys: opts.GetKeys(opts.Config.Universal.Select), + Handler: self.toggleSelection, + Description: self.c.Tr.ToggleSelectionInFocusedMainView, + DisplayOnScreen: !selectionShown, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.GoInto), + Handler: self.enter, + Description: enterDescription, + DisplayOnScreen: selectionShown, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.Edit), + Handler: self.editLine, + Description: editDescription, + Tooltip: editTooltip, + }, + { + Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), + Handler: self.openPullRequestForSelectedLine, + Description: openPullRequestDescription, + Tooltip: openPullRequestTooltip, + }, { // overriding this because we want to read all of the task's output before we start searching Keys: opts.GetKeys(opts.Config.Universal.StartSearch), @@ -75,6 +124,17 @@ func (self *MainViewController) Context() types.Context { return self.context } +// Transient focus shifts (popups, search) leave HighlightInactive=true on our +// view (set by ContextMgr.Activate when a different view becomes current). Our +// context's highlightOnFocus is false, so SimpleContext.HandleFocus never +// resets it. Reset it here on the way back in, so that if we still hold a +// selection it's drawn as active. The flag is a no-op when Highlight is false. +func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) { + return func(types.OnFocusOpts) { + self.context.GetView().HighlightInactive = false + } +} + func (self *MainViewController) togglePanel() error { if self.otherContext.GetView().Visible { self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) @@ -84,24 +144,204 @@ func (self *MainViewController) togglePanel() error { } func (self *MainViewController) escape() error { + v := self.context.GetView() + if v.Highlight { + v.Highlight = false + return nil + } self.c.Context().Pop() return nil } -func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { +func (self *MainViewController) toggleSelection() error { + v := self.context.GetView() + if v.Highlight { + v.Highlight = false + return nil + } + // Start the selection in the middle of the visible area. + showSelectionAtLine(v, v.OriginY()+v.InnerHeight()/2) + return nil +} + +func (self *MainViewController) enter() error { + if !self.context.GetView().Highlight { + return nil + } + return self.enterForLine(self.context.GetView().SelectedLineIdx()) +} + +// enterForLine dives into staging/patch-building for the given line, by +// delegating to the side panel beneath the focused main view (the same handler +// used when clicking). +func (self *MainViewController) enterForLine(lineIdx int) error { sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { - return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y) + return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), lineIdx) } return nil } -func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { - self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, - }) +// showSelectionAtLine turns on the focused main view's selection and moves it to +// the given view line, clamped to the content. +func showSelectionAtLine(view *gocui.View, lineIdx int) { + view.Highlight = true + view.HighlightInactive = false + lineIdx = lo.Clamp(lineIdx, 0, view.ViewLinesHeight()-1) + view.FocusPoint(0, lineIdx, false) +} +// focusedMainViewContextForViewName maps a focused main view's view name (as +// passed to GetOnClickFocusedMainView) to its context. +func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context { + if viewName == c.Contexts().NormalSecondary.GetViewName() { + return c.Contexts().NormalSecondary + } + return c.Contexts().Normal +} + +// focusedMainViewSnapshot captures where a focused main view is (scroll + +// selected line) when diving into a patch explorer from it, so escaping can +// return there with the main view focused. sidePanel is the panel to land on +// first (which re-renders the content); for commits/stash it's the originating +// panel, skipping the commit files panel we pass through. selectedLineIdx is the +// view line that was selected in the focused main view. Call this before any +// mutation that might re-render the main view. +func focusedMainViewSnapshot(c *ControllerCommon, mainViewName string, sidePanel types.Context, selectedLineIdx int) *types.FocusedMainViewSnapshot { + mainView := focusedMainViewContextForViewName(c, mainViewName) + sidePanelSelectedLineIdx := -1 + if listContext, ok := sidePanel.(types.IListContext); ok { + sidePanelSelectedLineIdx = listContext.GetList().GetSelectedLineIdx() + } + return &types.FocusedMainViewSnapshot{ + SidePanel: sidePanel, + SidePanelSelectedLineIdx: sidePanelSelectedLineIdx, + MainView: mainView, + OriginY: mainView.GetView().OriginY(), + SelectedLineIdx: selectedLineIdx, + } +} + +func (self *MainViewController) editLine() error { + if !self.context.GetView().Highlight { + return nil + } + // Figure out the clicked file and line the same way entering staging does. + path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( + self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + if !ok { + return nil + } + lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName()) + return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) +} + +func (self *MainViewController) openPullRequestForSelectedLine() error { + if !self.context.GetView().Highlight { + return nil + } + + sidePanelContext := self.c.Context().NextInStack(self.context) + if sidePanelContext == nil { + return nil + } + + // The branch whose PR to open depends on where we navigated from: the + // checked-out branch when looking at its own commits, but the branch we + // drilled into when in the sub-commits or commit-files panels. + branchName, ok := self.branchForPullRequest(sidePanelContext) + if !ok { + return nil + } + + pr, ok := self.c.Model().PullRequestsMap[branchName] + if !ok { + return errors.New(self.c.Tr.NoPullRequestForBranch) + } + + // The diff shown is the diff of a particular commit, so we deep-link into + // that commit's view of the PR; its right-side line numbers match what we're + // showing, so (unlike editLine) no line-number adjustment is needed. + diffableContext, ok := sidePanelContext.(types.DiffableContext) + if !ok { + return nil + } + commitSha := diffableContext.RefForAdjustingLineNumberInDiff() + if commitSha == "" { + return nil + } + + // Figure out the clicked file and line the same way entering staging does. + path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( + self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + if !ok { + return nil + } + + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path) + if err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.OpenPullRequest) + return self.c.OS().OpenLink( + githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), lineNumber)) +} + +// branchForPullRequest returns the local branch whose pull request applies to +// the diff currently shown in the focused main view, given the side panel +// beneath it. It returns false for contexts that don't map to a local branch +// (e.g. the working-tree files panel, stashes, tags, or remote branches). +func (self *MainViewController) branchForPullRequest(sidePanelContext types.Context) (string, bool) { + switch sidePanelContext.GetKey() { + case context.LOCAL_COMMITS_CONTEXT_KEY: + return self.c.Model().CheckedOutBranch, true + case context.SUB_COMMITS_CONTEXT_KEY: + ref := self.c.Contexts().SubCommits.GetRef() + if ref == nil { + return "", false + } + return ref.RefName(), true + case context.COMMIT_FILES_CONTEXT_KEY: + // The commit files panel doesn't itself know which branch it belongs to; + // that's determined by the panel we entered it from. + parent := self.c.Contexts().CommitFiles.GetParentContext() + if parent == nil { + return "", false + } + return self.branchForPullRequest(parent) + default: + return "", false + } +} + +// githubPullRequestLineURL builds a URL that opens the given line of a file in +// the diff of a specific commit within a GitHub pull request. The file is +// identified by the SHA-256 of its repo-relative path, and R targets the +// right (new) side of the diff. See +// https://github.com/orgs/community/discussions/55764. +func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, lineNumber int) string { + pathHash := sha256.Sum256([]byte(relativePath)) + anchor := fmt.Sprintf("diff-%sR%d", hex.EncodeToString(pathHash[:]), lineNumber) + return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor) +} + +func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { + // A click points at a line, so it sets the selection there; a double-click + // additionally dives into staging/patch-building for that line. + showSelectionAtLine(self.context.GetView(), opts.Y) + if opts.IsDoubleClick { + return self.enterForLine(opts.Y) + } + return nil +} + +func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { + self.c.Context().Push(self.context, types.OnFocusOpts{}) + showSelectionAtLine(self.context.GetView(), opts.Y) + if opts.IsDoubleClick { + return self.enterForLine(opts.Y) + } return nil } diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index aa5fd54bb56..d98b65fabf8 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -141,9 +141,15 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO return self.withRenderAndFocus(self.HandleMouseDown)() } + _, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(self.context.GetWindowName(), opts.Y) + if !ok { + line = -1 + } + self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, + ClickedWindowName: self.context.GetWindowName(), + ClickedViewLineIdx: opts.Y, + ClickedViewRealLineIdx: line, }) return nil diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acdae2..c3360173be9 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -175,7 +176,7 @@ func (self *StagingController) Escape() error { return nil } - self.c.Context().Pop() + helpers.EscapeFromPatchExplorer(self.c.HelperCommon, self.context) return nil } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index c2ff4d6747d..a9de6f37845 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -4,6 +4,8 @@ import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/models" + + "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -50,6 +52,46 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp return bindings } +func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { + return func(mainViewName string, clickedLineIdx int) error { + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + return nil + } + + // Capture before self.enter() pushes the commit files panel, which + // re-renders the main view. We escape "all the way out" to this side + // panel (skipping the commit files panel), then focus the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context, clickedLineIdx) + + if err := self.enter(); err != nil { + return err + } + + context := self.c.Contexts().CommitFiles + var node *filetree.CommitFileNode + + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + context.CommitFileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(context) + + idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath) + if !ok { + return nil + } + + context.SetSelectedLineIdx(idx) + context.GetViewTrait().FocusPoint( + context.ModelIndexToViewIndex(idx), false) + node = context.GetSelected() + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) + } +} + func (self *SwitchToDiffFilesController) Context() types.Context { return self.context } diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 5606a0bab59..c7888135cef 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -61,21 +61,27 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context { } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().Normal) + return self.focusMainView(self.c.Contexts().Normal, opts.Y) } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().NormalSecondary) + return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y) } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { - return self.focusMainView(self.c.Contexts().Normal) + // Focusing by keyboard doesn't point at any particular line, so we don't + // show a selection; the user is free to scroll. Clicking does point at a + // line, so it selects it (see focusMainView's clickedLineIdx). + return self.focusMainView(self.c.Contexts().Normal, -1) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error { +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context, clickedLineIdx int) error { if context, ok := mainViewContext.(types.ISearchableContext); ok { context.ClearSearchString() } self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) + if clickedLineIdx >= 0 { + showSelectionAtLine(mainViewContext.GetView(), clickedLineIdx) + } return nil } diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 31cbd36956c..6120875817c 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type ViewSelectionControllerFactory struct { @@ -57,10 +58,21 @@ func (self *ViewSelectionController) handleLineChange(delta int) { } v := self.Context().GetView() - if delta < 0 { - v.ScrollUp(-delta) + if self.context.GetView().Highlight { + lineIdxBefore := v.CursorY() + v.OriginY() + lineIdxAfter := lo.Clamp(lineIdxBefore+delta, 0, v.ViewLinesHeight()-1) + if delta == -1 { + checkScrollUp(self.Context().GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) + } else if delta == 1 { + checkScrollDown(self.Context().GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) + } + v.FocusPoint(0, lineIdxAfter, true) } else { - v.ScrollDown(delta) + if delta < 0 { + v.ScrollUp(-delta) + } else { + v.ScrollDown(delta) + } } } @@ -86,7 +98,11 @@ func (self *ViewSelectionController) handleNextPage() error { func (self *ViewSelectionController) handleGotoTop() error { v := self.Context().GetView() - self.handleLineChange(-v.ViewLinesHeight()) + if self.context.GetView().Highlight { + v.FocusPoint(0, 0, true) + } else { + self.handleLineChange(-v.ViewLinesHeight()) + } return nil } @@ -95,7 +111,11 @@ func (self *ViewSelectionController) handleGotoBottom() error { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { v := self.Context().GetView() - self.handleLineChange(v.ViewLinesHeight()) + if self.context.GetView().Highlight { + v.FocusPoint(0, v.ViewLinesHeight()-1, true) + } else { + self.handleLineChange(v.ViewLinesHeight()) + } return nil }) }) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index dacd93f68bc..4e310a32411 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -83,7 +83,13 @@ func (gui *Gui) layout(g *gocui.Gui) error { if !view.CanScrollPastBottom { maxOriginY -= newHeight - 1 } - if oldOriginY := view.OriginY(); oldOriginY > maxOriginY { + // Don't scroll up while the view's content is still being loaded: its + // height only reflects what has been read so far, so clamping to it now + // would yank the view to the top even though more content is on the way + // (e.g. when re-rendering a diff the user was scrolled into). + manager := gui.getViewBufferManagerForView(view) + stillLoading := manager != nil && manager.IsLoading() + if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading { view.ScrollUp(oldOriginY - maxOriginY) // the view might not have scrolled actually (if it was at the limit // already), so we need to check if it did diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 82f4fcac0bc..a90c68bca7a 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair { } func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { - // need to reset scroll positions of all other main views - for _, pair := range gui.allMainContextPairs() { - if pair.Main != opts.Pair.Main { - pair.Main.GetView().SetOrigin(0, 0) - } - if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { - pair.Secondary.GetView().SetOrigin(0, 0) - } - } - if opts.Main != nil { gui.RefreshMainView(opts.Main, opts.Pair.Main) } @@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { gui.moveMainContextPairToTop(opts.Pair) + // Reset the scroll positions of all the other main views. We do this after + // moving this pair to the top (which copies the previously-shown view's + // content into the now-visible one to avoid a blank frame): resetting first + // would zero that source view's scroll before it gets copied, forcing the + // placeholder to the top instead of leaving it where the screen already was. + for _, pair := range gui.allMainContextPairs() { + if pair.Main != opts.Pair.Main { + pair.Main.GetView().SetOrigin(0, 0) + } + if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { + pair.Secondary.GetView().SetOrigin(0, 0) + } + } + gui.splitMainPanel(opts.Secondary != nil) } diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 5f1a29e6171..d87291c5cd3 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -45,7 +45,7 @@ const ( HUNK ) -func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State { +func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State { if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 { // if we're here then we can return the old state. If selectedLineIdx was not -1 // then that would mean we were trying to click and potentially drag a range, which @@ -61,6 +61,14 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat viewLineIndices, patchLineIndices := wrapPatchLines(diff, view) + if selectedRealLineIdx != -1 { + // PatchLineForLineNumber returns a patch line index, but selectedLineIdx + // is in view-line (wrapped) space, so convert it. Without this the + // landing line is off by the number of wrapped lines above it. + patchLineIdx := patch.PatchLineForLineNumber(selectedRealLineIdx) + selectedLineIdx = viewLineIndices[lo.Clamp(patchLineIdx, 0, len(viewLineIndices)-1)] + } + rangeStartLineIdx := 0 if oldState != nil { rangeStartLineIdx = oldState.rangeStartLineIdx @@ -76,14 +84,28 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat userEnabledHunkMode = oldState.userEnabledHunkMode } - // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line + // A non-negative line index means we were given a specific line to select: + // either by clicking or pressing enter on a line in a focused main view, or + // by clicking directly on the patch explorer view to focus it. if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of // bounds if a custom pager is being used which produces more lines - selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) - - selectMode = RANGE - rangeStartLineIdx = selectedLineIdx + selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1) + + if selectLineInDefaultMode { + // Diving in from a focused main view: keep the default select mode + // computed above. In hunk mode the selection covers the block of + // changes around the line, so snap to a change line if the given one + // is a context line (just as toggling hunk mode does). + if selectMode == HUNK { + selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(patchLineIndices[selectedLineIdx])] + } + } else { + // Clicking directly on the view starts a range selection that can be + // extended by dragging. + selectMode = RANGE + rangeStartLineIdx = selectedLineIdx + } } else if oldState != nil { // if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default) if oldState.selectMode != RANGE { diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index f6356b9c0a4..a4ef9cc44e2 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -54,6 +54,17 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return gui.newCmdTask(view, cmd, prefix) } + // Mark the view as loading synchronously now, before the layout pass: the + // actual task is created in afterLayout (below), which runs after layout, so + // without this the next layout pass would clamp the scroll position to the + // not-yet-loaded content. + gui.getManager(view).StartLoading() + + // Read any requested scroll-restore now so we can size the initial read to it + // in afterLayout; the task itself clears the request and applies the scroll at + // its first paint. + targetOriginY := gui.getManager(view).GetScrollToOriginYForNextTask() + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager again because the layout might have @@ -96,7 +107,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.Mutexes.PtyMutex.Unlock() } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr) }) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3bfc6410002..214f1399e6a 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -17,6 +17,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error ).Debug("RunCommand") manager := gui.getManager(view) + // Mark the view as loading synchronously (before the task's goroutine runs + // and before the next layout pass) so the layout doesn't clamp the scroll + // position to the not-yet-loaded content. + manager.StartLoading() + + // If a caller asked us to restore a scroll position for this render, size the + // initial read to it (below) and let the task scroll there at its first paint. + // The task clears the request and suppresses the origin reset when it starts. + targetOriginY := manager.GetScrollToOriginYForNextTask() var r io.ReadCloser start := func() (*exec.Cmd, io.Reader) { @@ -42,7 +51,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error } } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil { gui.c.Log.Error(err) } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 416b39b957f..0e2d551c23a 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -205,6 +205,34 @@ type IPatchExplorerContext interface { NavigateTo(selectedLineIdx int) GetMutex() *deadlock.Mutex IsPatchExplorerContext() // used for type switch + + // See FocusedMainViewSnapshot. Nil unless this patch explorer was entered + // from a focused main view. + GetFocusedMainViewSnapshot() *FocusedMainViewSnapshot + SetFocusedMainViewSnapshot(*FocusedMainViewSnapshot) +} + +// FocusedMainViewSnapshot records where a focused main view was when we dived +// into a patch explorer (staging or patch building) from it, so that escaping +// returns us to the same place with the main view focused again. It is nil when +// the patch explorer was entered the normal way (through a side panel), in which +// case escape just pops to that side panel. +type FocusedMainViewSnapshot struct { + // The side panel to land on first; pushing it re-renders the original + // content into the main view. For commits/stash this is the originating side + // panel (skipping the commit files panel we passed through), preserving the + // pre-existing "escape all the way out" behavior. + SidePanel Context + // The side panel's selected line, to restore before re-rendering it. Diving + // into staging can change the side panel's selection (e.g. from a directory + // to a file in the files panel); restoring it makes the main view show the + // same content again. -1 if the side panel isn't a list. + SidePanelSelectedLineIdx int + // The focused main view context to focus afterwards. + MainView Context + // The scroll position and selected line to restore in the main view. + OriginY int + SelectedLineIdx int } type IViewTrait interface { @@ -227,8 +255,20 @@ type IViewTrait interface { } type OnFocusOpts struct { - ClickedWindowName string - ClickedViewLineIdx int + ClickedWindowName string + ClickedViewLineIdx int + + // If not -1, takes precedence over ClickedViewLineIdx. + ClickedViewRealLineIdx int + + // When entering a patch explorer (staging or patch building) by clicking or + // pressing enter on a line in a focused main view, we select that line using + // the default select mode (hunk or line, per the UseHunkModeInStagingView + // config), the same as when entering through the side panel. Clicking + // directly on the patch explorer view instead starts a range selection that + // can be extended by dragging. + SelectLineInDefaultMode bool + ScrollSelectionIntoView bool } diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 453ccd6c932..b760bdac7f7 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -19,9 +19,22 @@ func (gui *Gui) resetViewOrigin(v *gocui.View) { // Returns the number of lines that we should read initially from a cmd task so // that the scrollbar has the correct size, along with the number of lines after // which the view is filled and we can do a first refresh. -func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { +// +// If targetOriginY is non-nil, the read is sized to that scroll position rather +// than the view's current one, and the returned LinesToRead carries an +// ApplyInitialScroll that scrolls the view there at the first refresh. This is +// used when re-rendering content the user was already scrolled into, so the +// saved position is applied exactly when the content first paints. +func (gui *Gui) linesToReadFromCmdTask(v *gocui.View, targetOriginY *int) tasks.LinesToRead { height := v.InnerHeight() oy := v.OriginY() + var applyInitialScroll func() + if targetOriginY != nil { + oy = *targetOriginY + applyInitialScroll = func() { + v.SetOrigin(v.OriginX(), *targetOriginY) + } + } linesForFirstRefresh := height + oy + 10 @@ -37,6 +50,7 @@ func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { return tasks.LinesToRead{ Total: linesToReadForAccurateScrollbar, InitialRefreshAfter: linesForFirstRefresh, + ApplyInitialScroll: applyInitialScroll, } } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d112c037925..f0a1f90ef3d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -530,8 +530,12 @@ type TranslationSet struct { EmptyPatchError string EnterCommitFile string EnterCommitFileTooltip string + EnterStaging string ExitCustomPatchBuilder string ExitFocusedMainView string + ToggleSelectionInFocusedMainView string + OpenPullRequestForSelectedLine string + OpenPullRequestForSelectedLineTooltip string EnterUpstream string InvalidUpstream string NewRemote string @@ -1132,588 +1136,592 @@ to your lazygit config.` // exporting this so we can use it in tests func EnglishTranslationSet() *TranslationSet { return &TranslationSet{ - NotEnoughSpace: "Not enough space to render panels", - DiffTitle: "Diff", - FilesTitle: "Files", - BranchesTitle: "Branches", - CommitsTitle: "Commits", - StashTitle: "Stash", - SnakeTitle: "Snake", - EasterEgg: "Easter egg", - UnstagedChanges: "Unstaged changes", - StagedChanges: "Staged changes", - StagingTitle: "Main panel (staging)", - MergingTitle: "Main panel (merging)", - NormalTitle: "Main panel (normal)", - LogTitle: "Log", - LogXOfYTitle: "Log (%d of %d)", - CommitSummary: "Commit summary", - CredentialsUsername: "Username", - CredentialsPassword: "Password", - CredentialsPassphrase: "Enter passphrase for SSH key", - CredentialsPIN: "Enter PIN for SSH key", - CredentialsToken: "Enter Token for SSH key", - PassUnameWrong: "Password, passphrase and/or username wrong", - Commit: "Commit", - CommitTooltip: "Commit staged changes.", - AmendLastCommit: "Amend last commit", - AmendLastCommitTitle: "Amend last commit", - SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.", - NoCommitToAmend: "There's no commit to amend.", - CommitChangesWithEditor: "Commit changes using git editor", - FindBaseCommitForFixup: "Find base commit for fixup", - FindBaseCommitForFixupTooltip: "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: ", - NoBaseCommitsFound: "No base commits found", - MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)", - MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)", - BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch", - BaseCommitIsNotInCurrentView: "Base commit is not in current view", - HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", - StatusTitle: "Status", - Execute: "Execute", - Stage: "Stage", - StageTooltip: "Toggle staged for selected file.", - ToggleStagedAll: "Stage all", - ToggleStagedAllTooltip: "Toggle staged/unstaged for all files in working tree.", - ToggleTreeView: "Toggle file tree view", - ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", - OpenDiffTool: "Open external diff tool (git difftool)", - OpenMergeTool: "Open external merge tool", - Refresh: "Refresh", - RefreshTooltip: "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`.", - Push: "Push", - PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", - Pull: "Pull", - PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", - MergeConflictsTitle: "Merge conflicts", - MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", - MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", - MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').", - MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.", - MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.", - MergeConflictIncomingDiff: "Incoming changes:", - MergeConflictCurrentDiff: "Current changes:", - MergeConflictPressEnterToResolve: "Press %s to resolve.", - MergeConflictKeepFile: "Keep file", - MergeConflictDeleteFile: "Delete file", - Checkout: "Checkout", - CheckoutTooltip: "Checkout selected item.", - CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", - TagCheckoutTooltip: "Checkout the selected tag as a detached HEAD.", - RemoteBranchCheckoutTooltip: "Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head.", - CantPullOrPushSameBranchTwice: "You cannot push or pull a branch while it is already being pushed or pulled", - FileFilter: "Filter files by status", - CopyToClipboardMenu: "Copy to clipboard", - CopyFileName: "File name", - CopyRelativeFilePath: "Relative path", - CopyAbsoluteFilePath: "Absolute path", - CopyFileDiffTooltip: "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.", - CopySelectedDiff: "Diff of selected file", - CopyAllFilesDiff: "Diff of all files", - CopyFileContent: "Content of selected file", - NoContentToCopyError: "Nothing to copy", - FileNameCopiedToast: "File name copied to clipboard", - FilePathCopiedToast: "File path copied to clipboard", - FileDiffCopiedToast: "File diff copied to clipboard", - AllFilesDiffCopiedToast: "All files diff copied to clipboard", - FileContentCopiedToast: "File content copied to clipboard", - FilterStagedFiles: "Show only staged files", - FilterUnstagedFiles: "Show only unstaged files", - FilterTrackedFiles: "Show only tracked files", - FilterUntrackedFiles: "Show only untracked files", - NoFilter: "No filter", - FilterLabelStagedFiles: "(only staged)", - FilterLabelUnstagedFiles: "(only unstaged)", - FilterLabelTrackedFiles: "(only tracked)", - FilterLabelUntrackedFiles: "(only untracked)", - FilterLabelConflictingFiles: "(only conflicting)", - NoChangedFiles: "No changed files", - SoftReset: "Soft reset", - AlreadyCheckedOutBranch: "You have already checked out this branch", - SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes", - ForceCheckoutBranch: "Force checkout branch", - BranchName: "Branch name", - NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", - CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", - DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", - DeleteBranchesTitle: "Delete selected branches?", - DeleteLocalBranch: "Delete local branch", - DeleteLocalBranches: "Delete local branches", - DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", - DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", - DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", - DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", - ForceDeleteBranchTitle: "Force delete branch", - ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", - ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", - RebaseBranch: "Rebase", - RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", - CantRebaseOntoSelf: "You cannot rebase a branch onto itself", - CantMergeBranchIntoItself: "You cannot merge a branch into itself", - ForceCheckout: "Force checkout", - ForceCheckoutTooltip: "Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch.", - CheckoutByName: "Checkout by name", - CheckoutByNameTooltip: "Checkout by name. In the input box you can enter '-' to switch to the previous branch.", - CheckoutPreviousBranch: "Checkout previous branch", - RemoteBranchCheckoutTitle: "Checkout {{.branchName}}", - RemoteBranchCheckoutPrompt: "How would you like to check out this branch?", - CheckoutTypeNewBranch: "New local branch", - CheckoutTypeNewBranchTooltip: "Checkout the remote branch as a local branch, tracking the remote branch.", - CheckoutTypeDetachedHead: "Detached head", - CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", - NewBranch: "New branch", - NewBranchFromStashTooltip: "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.", - MoveCommitsToNewBranch: "Move commits to new branch", - MoveCommitsToNewBranchTooltip: "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.\n\nNote 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).", - MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch to its upstream branch. Do you want to continue?", - MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", - MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", - MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", - CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", - CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", - CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", - CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", - NoBranchesThisRepo: "No branches for this repo", - CommitWithoutMessageErr: "You cannot commit without a commit message", - Close: "Close", - CloseCancel: "Close/Cancel", - Confirm: "Confirm", - Quit: "Quit", - SquashTooltip: "Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it.", - NoCommitsThisBranch: "No commits for this branch", - UpdateRefHere: "Update branch '{{.ref}}' here", - ExecCommandHere: "Execute the following command here:", - CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", - CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit", - Fixup: "Fixup", - FixupKeepMessage: "Fixup and use this commit's message", - FixupKeepMessageTooltip: "Squash the selected commit into the commit below, using this commit's message, discarding the message of the commit below.", - SetFixupMessage: "Set fixup message", - SetFixupMessageTooltip: "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.", - FixupDiscardMessage: "Fixup and discard this commit's message", - FixupDiscardMessageTooltip: "Squash the selected commit into the commit below, discarding this commit's message.", - SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", - Squash: "Squash", - PickCommitTooltip: "Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase.", - Pick: "Pick", - Edit: "Edit", - Revert: "Revert", - RevertCommitTooltip: "Create a revert commit for the selected commit, which applies the selected commit's changes in reverse.", - Reword: "Reword", - CommitRewordTooltip: "Reword the selected commit's message.", - DropCommit: "Drop", - DropCommitTooltip: "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.", - MoveDownCommit: "Move commit down one", - MoveUpCommit: "Move commit up one", - CannotMoveAnyFurther: "Cannot move any further", - CannotMoveMergeCommit: "Cannot move a merge commit", - EditCommit: "Edit (start interactive rebase)", - EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", - AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", - Amend: "Amend", - ResetAuthor: "Reset author", - ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", - SetAuthor: "Set author", - SetAuthorTooltip: "Set the author based on a prompt", - AddCoAuthor: "Add co-author", - AmendCommitAttribute: "Amend commit attribute", - AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", - SetAuthorPromptTitle: "Set author (must look like 'Name ')", - AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", - AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", - RewordCommitEditor: "Reword with editor", - Error: "Error", - PickHunk: "Pick hunk", - PickAllHunks: "Pick all hunks", - Undo: "Undo", - UndoReflog: "Undo", - RedoReflog: "Redo", - UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", - RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", - UndoMergeResolveTooltip: "Undo last merge conflict resolution.", - DiscardAllTooltip: "Discard both staged and unstaged changes in '{{.path}}'.", - DiscardUnstagedTooltip: "Discard unstaged changes in '{{.path}}'.", - DiscardUnstagedDisabled: "The selected items don't have both staged and unstaged changes.", - Pop: "Pop", - StashPopTooltip: "Apply the stash entry to your working directory and remove the stash entry.", - Drop: "Drop", - StashDropTooltip: "Remove the stash entry from the stash list.", - Apply: "Apply", - StashApplyTooltip: "Apply the stash entry to your working directory.", - NoStashEntries: "No stash entries", - StashDrop: "Stash drop", - SureDropStashEntry: "Are you sure you want to drop the selected stash entry(ies)?", - StashPop: "Stash pop", - SurePopStashEntry: "Are you sure you want to pop this stash entry?", - StashApply: "Stash apply", - SureApplyStashEntry: "Are you sure you want to apply this stash entry?", - NoTrackedStagedFilesStash: "You have no tracked/staged files to stash", - NoFilesToStash: "You have no files to stash", - StashChanges: "Stash changes", - RenameStash: "Rename stash", - RenameStashPrompt: "Rename stash: {{.stashName}}", - OpenConfig: "Open config file", - EditConfig: "Edit config file", - ForcePush: "Force push", - ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", - ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", - UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", - UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", - CheckForUpdate: "Check for update", - CheckingForUpdates: "Checking for updates...", - UpdateAvailableTitle: "Update available!", - UpdateAvailable: "Download and install version {{.newVersion}}?", - UpdateInProgressWaitingStatus: "Updating", - UpdateCompletedTitle: "Update completed!", - UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.", - FailedToRetrieveLatestVersionErr: "Failed to retrieve version information", - OnLatestVersionErr: "You already have the latest version", - MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})", - CouldNotFindBinaryErr: "Could not find any binary at {{.url}}", - UpdateFailedErr: "Update failed: {{.errMessage}}", - ConfirmQuitDuringUpdateTitle: "Currently updating", - ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?", - IntroPopupMessage: englishIntroPopupMessage, - NonReloadableConfigWarningTitle: "Config changed", - NonReloadableConfigWarning: englishNonReloadableConfigWarning, - GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`, - EditFile: `Edit file`, - EditFileTooltip: "Open file in external editor.", - OpenFile: `Open file`, - OpenFileTooltip: "Open file in default application.", - OpenInEditor: "Open in editor", - IgnoreFile: `Add to .gitignore`, - ExcludeFile: `Add to .git/info/exclude`, - RefreshFiles: `Refresh files`, - FocusMainView: "Focus main view", - Merge: `Merge`, - MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", - RegularMergeFastForward: "Regular merge (fast-forward)", - RegularMergeFastForwardTooltip: "Fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}' without creating a merge commit.", - CannotFastForwardMerge: "Cannot fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}'", - RegularMergeNonFastForward: "Regular merge (with merge commit)", - RegularMergeNonFastForwardTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}', creating a merge commit.", - SquashMergeUncommitted: "Squash merge and leave uncommitted", - SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", - SquashMergeCommitted: "Squash merge and commit", - SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", - ConfirmQuit: `Are you sure you want to quit?`, - SwitchRepo: `Switch to a recent repo`, - AllBranchesLogGraph: `Show/cycle all branch logs`, - AllBranchesLogGraphReverse: `Show/cycle all branch logs (reverse)`, - UnsupportedGitService: `Unsupported git service`, - CreatePullRequest: `Create pull request`, - CopyPullRequestURL: `Copy pull request URL to clipboard`, - OpenPullRequestInBrowser: `Open pull request in browser`, - NoPullRequestForBranch: `No pull request found for this branch`, - NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, - Fetch: `Fetch`, - FetchTooltip: "Fetch changes from remote.", - CollapseAll: "Collapse all files", - CollapseAllTooltip: "Collapse all directories in the files tree", - ExpandAll: "Expand all files", - ExpandAllTooltip: "Expand all directories in the file tree", - DisabledInFlatView: "Not available in flat view", - FileEnter: `Stage lines / Collapse directory`, - FileEnterTooltip: "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.", - StageSelectionTooltip: `Toggle selection staged / unstaged.`, - DiscardSelection: `Discard`, - DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", - ToggleRangeSelect: "Toggle range select", - DismissRangeSelect: "Dismiss range select", - ToggleSelectHunk: "Toggle hunk selection", - SelectHunk: "Select hunks", - SelectLineByLine: "Select line-by-line", - ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.", - HunkStagingHint: englishHunkStagingHint, - ToggleSelectionForPatch: `Toggle lines in patch`, - RemoveSelectionFromPatch: `Remove lines from commit`, - RemoveSelectionFromPatchTooltip: "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.", - EditHunk: `Edit hunk`, - EditHunkTooltip: "Edit selected hunk in external editor.", - ToggleStagingView: "Switch view", - ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", - ReturnToFilesPanel: `Return to files panel`, - FastForward: `Fast-forward`, - FastForwardTooltip: "Fast-forward selected branch from its upstream.", - FastForwarding: "Fast-forwarding", - FoundConflictsTitle: "Conflicts!", - ViewConflictsMenuItem: "View conflicts", - AbortMenuItem: "Abort the %s", - ViewMergeRebaseOptions: "View merge/rebase options", - ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", - ViewMergeOptions: "View merge options", - ViewRebaseOptions: "View rebase options", - ViewCherryPickOptions: "View cherry-pick options", - ViewRevertOptions: "View revert options", - NotMergingOrRebasing: "You are currently neither rebasing nor merging", - AlreadyRebasing: "Can't perform this action during a rebase", - NotMidRebase: "This action only works during an interactive rebase", - MustSelectFixupCommit: "This action only works on fixup commits", - RecentRepos: "Recent repositories", - MergeOptionsTitle: "Merge options", - RebaseOptionsTitle: "Rebase options", - CherryPickOptionsTitle: "Cherry-pick options", - RevertOptionsTitle: "Revert options", - CommitSummaryTitle: "Commit summary", - CommitDescriptionTitle: "Commit description", - CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", - CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit", - CommitHooksDisabledSubTitle: "(hooks disabled)", - LocalBranchesTitle: "Local branches", - SearchTitle: "Search", - TagsTitle: "Tags", - MenuTitle: "Menu", - CommitMenuTitle: "Commit Menu", - RemotesTitle: "Remotes", - RemoteBranchesTitle: "Remote branches", - PatchBuildingTitle: "Main panel (patch building)", - InformationTitle: "Information", - SecondaryTitle: "Secondary", - ReflogCommitsTitle: "Reflog", - GlobalTitle: "Global keybindings", - ConflictsResolved: "All merge conflicts resolved. Continue the %s?", - Continue: "Continue", - UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", - Keybindings: "Keybindings", - KeybindingsMenuSectionLocal: "Local", - KeybindingsMenuSectionGlobal: "Global", - KeybindingsMenuSectionNavigation: "Navigation", - KeybindingsTooltip: "Keybindings: ", - RebasingTitle: "Rebase '{{.checkedOutBranch}}'", - RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", - SimpleRebase: "Simple rebase onto '{{.ref}}'", - InteractiveRebase: "Interactive rebase onto '{{.ref}}'", - RebaseOntoBaseBranch: "Rebase onto base branch ({{.baseBranch}})", - InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", - RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", - MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", - FwdNoUpstream: "Cannot fast-forward a branch with no upstream", - FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", - FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", - PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", - ErrorOccurred: "An error occurred! Please create an issue at", - ConflictLabel: "CONFLICT", - PendingRebaseTodosSectionHeader: "Pending rebase todos", - PendingCherryPicksSectionHeader: "Pending cherry-picks", - PendingRevertsSectionHeader: "Pending reverts", - CommitsSectionHeader: "Commits", - YouDied: "YOU DIED!", - RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", - ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", - NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", - PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing", - DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", - CherryPickCopy: "Copy (cherry-pick)", - CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.", - PasteCommits: "Paste (cherry-pick)", - SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", - CherryPick: "Cherry-pick", - CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", - Donate: "Donate", - AskQuestion: "Ask Question", - PrevHunk: "Go to previous hunk", - NextHunk: "Go to next hunk", - PrevConflict: "Previous conflict", - NextConflict: "Next conflict", - SelectPrevHunk: "Previous hunk", - SelectNextHunk: "Next hunk", - ScrollDown: "Scroll down", - ScrollUp: "Scroll up", - ScrollUpMainWindow: "Scroll up main window", - ScrollDownMainWindow: "Scroll down main window", - SuspendApp: "Suspend the application", - CannotSuspendApp: "Suspending the application is not supported on Windows", - AmendCommitTitle: "Amend commit", - AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", - AmendCommitWithConflictsMenuPrompt: "WARNING: you are about to amend the last finished commit with your resolved conflicts. This is very unlikely to be what you want at this point. More likely, you simply want to continue the rebase instead.\n\nDo you still want to amend the previous commit?", - AmendCommitWithConflictsContinue: "No, continue rebase", - AmendCommitWithConflictsAmend: "Yes, amend previous commit", - DropCommitTitle: "Drop commit", - DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", - DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.", - DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", - PullingStatus: "Pulling", - PushingStatus: "Pushing", - FetchingStatus: "Fetching", - SquashingStatus: "Squashing", - FixingStatus: "Fixing up", - DeletingStatus: "Deleting", - DroppingStatus: "Dropping", - MovingStatus: "Moving", - RebasingStatus: "Rebasing", - MergingStatus: "Merging", - LowercaseRebasingStatus: "rebasing", // lowercase because it shows up in parentheses - LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses - LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses - LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses - AmendingStatus: "Amending", - CherryPickingStatus: "Cherry-picking", - UndoingStatus: "Undoing", - RedoingStatus: "Redoing", - CheckingOutStatus: "Checking out", - CommittingStatus: "Committing", - RewordingStatus: "Rewording", - RevertingStatus: "Reverting", - CreatingFixupCommitStatus: "Creating fixup commit", - MovingCommitsToNewBranchStatus: "Moving commits to new branch", - CommitFiles: "Commit files", - SubCommitsDynamicTitle: "Commits (%s)", - CommitFilesDynamicTitle: "Diff files (%s)", - RemoteBranchesDynamicTitle: "Remote branches (%s)", - ViewItemFiles: "View files", - CommitFilesTitle: "Commit files", - CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", - CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.", - CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", - CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits", - Remove: "Remove", - DiscardOldFileChangeTooltip: "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.", - DiscardFileChangesTitle: "Discard file changes", - DiscardFileChangesPrompt: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.", - DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", - DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", - CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", - BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", - InitialBranch: "Branch name? (leave empty for git's default): ", - NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", - IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", - AutoStashTitle: "Autostash?", - AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)", - AutoStashForUndo: "Auto-stashing changes for undoing to %s", - AutoStashForCheckout: "Auto-stashing changes for checking out %s", - AutoStashForNewBranch: "Auto-stashing changes for creating new branch %s", - AutoStashForMovingPatchToIndex: "Auto-stashing changes for moving custom patch to index from %s", - AutoStashForCherryPicking: "Auto-stashing changes for cherry-picking commits", - AutoStashForReverting: "Auto-stashing changes for reverting commits", - Discard: "Discard", - DiscardFileChangesTooltip: "View options for discarding changes to the selected file.", - DiscardChangesTitle: "Discard changes", - Cancel: "Cancel", - DiscardAllChanges: "Discard all changes", - DiscardUnstagedChanges: "Discard unstaged changes", - DiscardAllChangesToAllFiles: "Nuke working tree", - DiscardAnyUnstagedChanges: "Discard unstaged changes", - DiscardUntrackedFiles: "Discard untracked files", - DiscardStagedChanges: "Discard staged changes", - HardReset: "Hard reset", - BranchDeleteTooltip: "View delete options for local/remote branch.", - TagDeleteTooltip: "View delete options for local/remote tag.", - Delete: "Delete", - Reset: "Reset", - ResetTooltip: "View reset options (soft/mixed/hard) for resetting onto selected item.", - ResetSoftTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as staged changes.", - ResetMixedTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as unstaged changes.", - ResetHardTooltip: "Reset HEAD to the chosen commit, and discard all changes between the current and chosen commit, as well as all current modifications in the working tree.", - ResetHardConfirmation: "Are you sure you want to do a hard reset? This will discard all uncommitted changes (both staged and unstaged), which is not undoable.", - ViewResetOptions: `Reset`, - FileResetOptionsTooltip: "View reset options for working tree (e.g. nuking the working tree).", - FixupTooltip: "Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded.", - CreateFixupCommit: "Create fixup commit", - CreateFixupCommitTooltip: "Create 'fixup!' commit for the selected commit. Later on, you can press `{{.squashAbove}}` on this same commit to apply all above fixup commits.", - CreateAmendCommit: `Create "amend!" commit`, - FixupMenu_Fixup: "fixup! commit", - FixupMenu_FixupTooltip: "Lets you fixup another commit and keep the original commit's message.", - FixupMenu_AmendWithChanges: "amend! commit with changes", - FixupMenu_AmendWithChangesTooltip: "Lets you fixup another commit and also change its commit message.", - FixupMenu_AmendWithoutChanges: "amend! commit without changes (pure reword)", - FixupMenu_AmendWithoutChangesTooltip: "Lets you change the commit message of another commit without changing its content.", - SquashAboveCommits: "Apply fixup commits", - SquashAboveCommitsTooltip: `Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash).`, - SquashCommitsAboveSelectedTooltip: `Squash all 'fixup!' commits above the selected commit (autosquash).`, - SquashCommitsInCurrentBranchTooltip: `Squash all 'fixup!' commits in the current branch (autosquash).`, - SquashCommitsInCurrentBranch: "In current branch", - SquashCommitsAboveSelectedCommit: "Above the selected commit", - CannotSquashCommitsInCurrentBranch: "Cannot squash commits in current branch: the HEAD commit is a merge commit or is present on the main branch.", - ExecuteShellCommand: "Execute shell command", - ExecuteShellCommandTooltip: "Bring up a prompt where you can enter a shell command to execute.", - ShellCommand: "Shell command:", - CommitChangesWithoutHook: "Commit changes without pre-commit hook", - ResetTo: `Reset to`, - PressEnterToReturn: "Press enter to return to lazygit", - ViewStashOptions: "View stash options", - ViewStashOptionsTooltip: "View stash options (e.g. stash all, stash staged, stash unstaged).", - Stash: "Stash", - StashTooltip: "Stash all changes. For other variations of stashing, use the view stash options keybinding.", - StashAllChanges: "Stash all changes", - StashStagedChanges: "Stash staged changes", - StashAllChangesKeepIndex: "Stash all changes and keep index", - StashUnstagedChanges: "Stash unstaged changes", - StashIncludeUntrackedChanges: "Stash all changes including untracked files", - StashOptions: "Stash options", - NotARepository: "Error: must be run inside a git repository", - WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", - ScrollLeft: "Scroll left", - ScrollRight: "Scroll right", - DiscardPatch: "Discard patch", - DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?", - CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state", - ToggleAddToPatch: "Toggle file included in patch", - ToggleAddToPatchTooltip: "Toggle whether the file is included in the custom patch. See {{.doc}}.", - ToggleAllInPatch: "Toggle all files", - ToggleAllInPatchTooltip: "Add/remove all commit's files to custom patch. See {{.doc}}.", - UpdatingPatch: "Updating patch", - ViewPatchOptions: "View custom patch options", - PatchOptionsTitle: "Patch options", - NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", - EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", - EnterCommitFile: "Enter file / Toggle directory collapsed", - EnterCommitFileTooltip: "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.", - ExitCustomPatchBuilder: `Exit custom patch builder`, - ExitFocusedMainView: "Exit back to side panel", - EnterUpstream: `Enter upstream as ' '`, - InvalidUpstream: "Invalid upstream. Must be in the format ' '", - NewRemote: `New remote`, - NewRemoteName: `New remote name:`, - NewRemoteUrl: `New remote url:`, - AddForkRemoteUsername: `Fork owner (username/org). Use username:branch to check out a branch`, - AddForkRemote: `Add fork remote`, - AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, - IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different URL`, - NoOriginRemote: "Action needs 'origin' remote", - ViewBranches: "View branches", - EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, - EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, - RemoveRemote: `Remove remote`, - RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, - RemoveRemotePrompt: "Are you sure you want to remove remote?", - DeleteRemoteBranch: "Delete remote branch", - DeleteRemoteBranches: "Delete remote branches", - DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", - DeleteLocalAndRemoteBranch: "Delete local and remote branch", - DeleteLocalAndRemoteBranches: "Delete local and remote branches", - SetAsUpstream: "Set as upstream", - SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", - SetUpstream: "Set upstream of selected branch", - UnsetUpstream: "Unset upstream of selected branch", - ViewDivergenceFromUpstream: "View divergence from upstream", - ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", - CouldNotDetermineBaseBranch: "Couldn't determine base branch", - DivergenceSectionHeaderLocal: "Local", - DivergenceSectionHeaderRemote: "Remote", - ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", - ViewUpstreamResetOptionsTooltip: "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream.", - ViewUpstreamRebaseOptions: "Rebase checked-out branch onto {{.upstream}}", - ViewUpstreamRebaseOptionsTooltip: "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebase the checked-out branch onto the upstream.", - UpstreamGenericName: "upstream of selected branch", - SetUpstreamTitle: "Set upstream branch", - SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'?", - EditRemoteTooltip: "Edit the selected remote's name or URL.", - TagCommit: "Tag commit", - TagCommitTooltip: "Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description.", - TagNameTitle: "Tag name", - TagMessageTitle: "Tag description", - AnnotatedTag: "Annotated tag", - LightweightTag: "Lightweight tag", - DeleteTagTitle: "Delete tag '{{.tagName}}'?", - DeleteLocalTag: "Delete local tag", - DeleteRemoteTag: "Delete remote tag", - DeleteLocalAndRemoteTag: "Delete local and remote tag", - RemoteTagDeletedMessage: "Remote tag deleted", - SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", - DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", - DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", - PushTagTitle: "Remote to push tag '{{.tagName}}' to:", + NotEnoughSpace: "Not enough space to render panels", + DiffTitle: "Diff", + FilesTitle: "Files", + BranchesTitle: "Branches", + CommitsTitle: "Commits", + StashTitle: "Stash", + SnakeTitle: "Snake", + EasterEgg: "Easter egg", + UnstagedChanges: "Unstaged changes", + StagedChanges: "Staged changes", + StagingTitle: "Main panel (staging)", + MergingTitle: "Main panel (merging)", + NormalTitle: "Main panel (normal)", + LogTitle: "Log", + LogXOfYTitle: "Log (%d of %d)", + CommitSummary: "Commit summary", + CredentialsUsername: "Username", + CredentialsPassword: "Password", + CredentialsPassphrase: "Enter passphrase for SSH key", + CredentialsPIN: "Enter PIN for SSH key", + CredentialsToken: "Enter Token for SSH key", + PassUnameWrong: "Password, passphrase and/or username wrong", + Commit: "Commit", + CommitTooltip: "Commit staged changes.", + AmendLastCommit: "Amend last commit", + AmendLastCommitTitle: "Amend last commit", + SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.", + NoCommitToAmend: "There's no commit to amend.", + CommitChangesWithEditor: "Commit changes using git editor", + FindBaseCommitForFixup: "Find base commit for fixup", + FindBaseCommitForFixupTooltip: "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: ", + NoBaseCommitsFound: "No base commits found", + MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)", + MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)", + BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch", + BaseCommitIsNotInCurrentView: "Base commit is not in current view", + HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", + StatusTitle: "Status", + Execute: "Execute", + Stage: "Stage", + StageTooltip: "Toggle staged for selected file.", + ToggleStagedAll: "Stage all", + ToggleStagedAllTooltip: "Toggle staged/unstaged for all files in working tree.", + ToggleTreeView: "Toggle file tree view", + ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", + OpenDiffTool: "Open external diff tool (git difftool)", + OpenMergeTool: "Open external merge tool", + Refresh: "Refresh", + RefreshTooltip: "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`.", + Push: "Push", + PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", + Pull: "Pull", + PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", + MergeConflictsTitle: "Merge conflicts", + MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", + MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", + MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').", + MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.", + MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.", + MergeConflictIncomingDiff: "Incoming changes:", + MergeConflictCurrentDiff: "Current changes:", + MergeConflictPressEnterToResolve: "Press %s to resolve.", + MergeConflictKeepFile: "Keep file", + MergeConflictDeleteFile: "Delete file", + Checkout: "Checkout", + CheckoutTooltip: "Checkout selected item.", + CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", + TagCheckoutTooltip: "Checkout the selected tag as a detached HEAD.", + RemoteBranchCheckoutTooltip: "Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head.", + CantPullOrPushSameBranchTwice: "You cannot push or pull a branch while it is already being pushed or pulled", + FileFilter: "Filter files by status", + CopyToClipboardMenu: "Copy to clipboard", + CopyFileName: "File name", + CopyRelativeFilePath: "Relative path", + CopyAbsoluteFilePath: "Absolute path", + CopyFileDiffTooltip: "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.", + CopySelectedDiff: "Diff of selected file", + CopyAllFilesDiff: "Diff of all files", + CopyFileContent: "Content of selected file", + NoContentToCopyError: "Nothing to copy", + FileNameCopiedToast: "File name copied to clipboard", + FilePathCopiedToast: "File path copied to clipboard", + FileDiffCopiedToast: "File diff copied to clipboard", + AllFilesDiffCopiedToast: "All files diff copied to clipboard", + FileContentCopiedToast: "File content copied to clipboard", + FilterStagedFiles: "Show only staged files", + FilterUnstagedFiles: "Show only unstaged files", + FilterTrackedFiles: "Show only tracked files", + FilterUntrackedFiles: "Show only untracked files", + NoFilter: "No filter", + FilterLabelStagedFiles: "(only staged)", + FilterLabelUnstagedFiles: "(only unstaged)", + FilterLabelTrackedFiles: "(only tracked)", + FilterLabelUntrackedFiles: "(only untracked)", + FilterLabelConflictingFiles: "(only conflicting)", + NoChangedFiles: "No changed files", + SoftReset: "Soft reset", + AlreadyCheckedOutBranch: "You have already checked out this branch", + SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes", + ForceCheckoutBranch: "Force checkout branch", + BranchName: "Branch name", + NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", + CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", + DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", + DeleteBranchesTitle: "Delete selected branches?", + DeleteLocalBranch: "Delete local branch", + DeleteLocalBranches: "Delete local branches", + DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", + DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", + DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", + DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", + ForceDeleteBranchTitle: "Force delete branch", + ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", + ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", + RebaseBranch: "Rebase", + RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", + CantRebaseOntoSelf: "You cannot rebase a branch onto itself", + CantMergeBranchIntoItself: "You cannot merge a branch into itself", + ForceCheckout: "Force checkout", + ForceCheckoutTooltip: "Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch.", + CheckoutByName: "Checkout by name", + CheckoutByNameTooltip: "Checkout by name. In the input box you can enter '-' to switch to the previous branch.", + CheckoutPreviousBranch: "Checkout previous branch", + RemoteBranchCheckoutTitle: "Checkout {{.branchName}}", + RemoteBranchCheckoutPrompt: "How would you like to check out this branch?", + CheckoutTypeNewBranch: "New local branch", + CheckoutTypeNewBranchTooltip: "Checkout the remote branch as a local branch, tracking the remote branch.", + CheckoutTypeDetachedHead: "Detached head", + CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", + NewBranch: "New branch", + NewBranchFromStashTooltip: "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.", + MoveCommitsToNewBranch: "Move commits to new branch", + MoveCommitsToNewBranchTooltip: "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.\n\nNote 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).", + MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch to its upstream branch. Do you want to continue?", + MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", + MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", + MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", + CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", + CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", + CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", + CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", + NoBranchesThisRepo: "No branches for this repo", + CommitWithoutMessageErr: "You cannot commit without a commit message", + Close: "Close", + CloseCancel: "Close/Cancel", + Confirm: "Confirm", + Quit: "Quit", + SquashTooltip: "Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it.", + NoCommitsThisBranch: "No commits for this branch", + UpdateRefHere: "Update branch '{{.ref}}' here", + ExecCommandHere: "Execute the following command here:", + CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", + CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit", + Fixup: "Fixup", + FixupKeepMessage: "Fixup and use this commit's message", + FixupKeepMessageTooltip: "Squash the selected commit into the commit below, using this commit's message, discarding the message of the commit below.", + SetFixupMessage: "Set fixup message", + SetFixupMessageTooltip: "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.", + FixupDiscardMessage: "Fixup and discard this commit's message", + FixupDiscardMessageTooltip: "Squash the selected commit into the commit below, discarding this commit's message.", + SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", + Squash: "Squash", + PickCommitTooltip: "Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase.", + Pick: "Pick", + Edit: "Edit", + Revert: "Revert", + RevertCommitTooltip: "Create a revert commit for the selected commit, which applies the selected commit's changes in reverse.", + Reword: "Reword", + CommitRewordTooltip: "Reword the selected commit's message.", + DropCommit: "Drop", + DropCommitTooltip: "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.", + MoveDownCommit: "Move commit down one", + MoveUpCommit: "Move commit up one", + CannotMoveAnyFurther: "Cannot move any further", + CannotMoveMergeCommit: "Cannot move a merge commit", + EditCommit: "Edit (start interactive rebase)", + EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", + AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", + Amend: "Amend", + ResetAuthor: "Reset author", + ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", + SetAuthor: "Set author", + SetAuthorTooltip: "Set the author based on a prompt", + AddCoAuthor: "Add co-author", + AmendCommitAttribute: "Amend commit attribute", + AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", + SetAuthorPromptTitle: "Set author (must look like 'Name ')", + AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", + AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", + RewordCommitEditor: "Reword with editor", + Error: "Error", + PickHunk: "Pick hunk", + PickAllHunks: "Pick all hunks", + Undo: "Undo", + UndoReflog: "Undo", + RedoReflog: "Redo", + UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", + RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", + UndoMergeResolveTooltip: "Undo last merge conflict resolution.", + DiscardAllTooltip: "Discard both staged and unstaged changes in '{{.path}}'.", + DiscardUnstagedTooltip: "Discard unstaged changes in '{{.path}}'.", + DiscardUnstagedDisabled: "The selected items don't have both staged and unstaged changes.", + Pop: "Pop", + StashPopTooltip: "Apply the stash entry to your working directory and remove the stash entry.", + Drop: "Drop", + StashDropTooltip: "Remove the stash entry from the stash list.", + Apply: "Apply", + StashApplyTooltip: "Apply the stash entry to your working directory.", + NoStashEntries: "No stash entries", + StashDrop: "Stash drop", + SureDropStashEntry: "Are you sure you want to drop the selected stash entry(ies)?", + StashPop: "Stash pop", + SurePopStashEntry: "Are you sure you want to pop this stash entry?", + StashApply: "Stash apply", + SureApplyStashEntry: "Are you sure you want to apply this stash entry?", + NoTrackedStagedFilesStash: "You have no tracked/staged files to stash", + NoFilesToStash: "You have no files to stash", + StashChanges: "Stash changes", + RenameStash: "Rename stash", + RenameStashPrompt: "Rename stash: {{.stashName}}", + OpenConfig: "Open config file", + EditConfig: "Edit config file", + ForcePush: "Force push", + ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", + ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", + UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", + UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", + CheckForUpdate: "Check for update", + CheckingForUpdates: "Checking for updates...", + UpdateAvailableTitle: "Update available!", + UpdateAvailable: "Download and install version {{.newVersion}}?", + UpdateInProgressWaitingStatus: "Updating", + UpdateCompletedTitle: "Update completed!", + UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.", + FailedToRetrieveLatestVersionErr: "Failed to retrieve version information", + OnLatestVersionErr: "You already have the latest version", + MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})", + CouldNotFindBinaryErr: "Could not find any binary at {{.url}}", + UpdateFailedErr: "Update failed: {{.errMessage}}", + ConfirmQuitDuringUpdateTitle: "Currently updating", + ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?", + IntroPopupMessage: englishIntroPopupMessage, + NonReloadableConfigWarningTitle: "Config changed", + NonReloadableConfigWarning: englishNonReloadableConfigWarning, + GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`, + EditFile: `Edit file`, + EditFileTooltip: "Open file in external editor.", + OpenFile: `Open file`, + OpenFileTooltip: "Open file in default application.", + OpenInEditor: "Open in editor", + IgnoreFile: `Add to .gitignore`, + ExcludeFile: `Add to .git/info/exclude`, + RefreshFiles: `Refresh files`, + FocusMainView: "Focus main view", + Merge: `Merge`, + MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", + RegularMergeFastForward: "Regular merge (fast-forward)", + RegularMergeFastForwardTooltip: "Fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}' without creating a merge commit.", + CannotFastForwardMerge: "Cannot fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}'", + RegularMergeNonFastForward: "Regular merge (with merge commit)", + RegularMergeNonFastForwardTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}', creating a merge commit.", + SquashMergeUncommitted: "Squash merge and leave uncommitted", + SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", + SquashMergeCommitted: "Squash merge and commit", + SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", + ConfirmQuit: `Are you sure you want to quit?`, + SwitchRepo: `Switch to a recent repo`, + AllBranchesLogGraph: `Show/cycle all branch logs`, + AllBranchesLogGraphReverse: `Show/cycle all branch logs (reverse)`, + UnsupportedGitService: `Unsupported git service`, + CreatePullRequest: `Create pull request`, + CopyPullRequestURL: `Copy pull request URL to clipboard`, + OpenPullRequestInBrowser: `Open pull request in browser`, + NoPullRequestForBranch: `No pull request found for this branch`, + NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, + Fetch: `Fetch`, + FetchTooltip: "Fetch changes from remote.", + CollapseAll: "Collapse all files", + CollapseAllTooltip: "Collapse all directories in the files tree", + ExpandAll: "Expand all files", + ExpandAllTooltip: "Expand all directories in the file tree", + DisabledInFlatView: "Not available in flat view", + FileEnter: `Stage lines / Collapse directory`, + FileEnterTooltip: "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.", + StageSelectionTooltip: `Toggle selection staged / unstaged.`, + DiscardSelection: `Discard`, + DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", + ToggleRangeSelect: "Toggle range select", + DismissRangeSelect: "Dismiss range select", + ToggleSelectHunk: "Toggle hunk selection", + SelectHunk: "Select hunks", + SelectLineByLine: "Select line-by-line", + ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.", + HunkStagingHint: englishHunkStagingHint, + ToggleSelectionForPatch: `Toggle lines in patch`, + RemoveSelectionFromPatch: `Remove lines from commit`, + RemoveSelectionFromPatchTooltip: "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.", + EditHunk: `Edit hunk`, + EditHunkTooltip: "Edit selected hunk in external editor.", + ToggleStagingView: "Switch view", + ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", + ReturnToFilesPanel: `Return to files panel`, + FastForward: `Fast-forward`, + FastForwardTooltip: "Fast-forward selected branch from its upstream.", + FastForwarding: "Fast-forwarding", + FoundConflictsTitle: "Conflicts!", + ViewConflictsMenuItem: "View conflicts", + AbortMenuItem: "Abort the %s", + ViewMergeRebaseOptions: "View merge/rebase options", + ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", + ViewMergeOptions: "View merge options", + ViewRebaseOptions: "View rebase options", + ViewCherryPickOptions: "View cherry-pick options", + ViewRevertOptions: "View revert options", + NotMergingOrRebasing: "You are currently neither rebasing nor merging", + AlreadyRebasing: "Can't perform this action during a rebase", + NotMidRebase: "This action only works during an interactive rebase", + MustSelectFixupCommit: "This action only works on fixup commits", + RecentRepos: "Recent repositories", + MergeOptionsTitle: "Merge options", + RebaseOptionsTitle: "Rebase options", + CherryPickOptionsTitle: "Cherry-pick options", + RevertOptionsTitle: "Revert options", + CommitSummaryTitle: "Commit summary", + CommitDescriptionTitle: "Commit description", + CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", + CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit", + CommitHooksDisabledSubTitle: "(hooks disabled)", + LocalBranchesTitle: "Local branches", + SearchTitle: "Search", + TagsTitle: "Tags", + MenuTitle: "Menu", + CommitMenuTitle: "Commit Menu", + RemotesTitle: "Remotes", + RemoteBranchesTitle: "Remote branches", + PatchBuildingTitle: "Main panel (patch building)", + InformationTitle: "Information", + SecondaryTitle: "Secondary", + ReflogCommitsTitle: "Reflog", + GlobalTitle: "Global keybindings", + ConflictsResolved: "All merge conflicts resolved. Continue the %s?", + Continue: "Continue", + UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", + Keybindings: "Keybindings", + KeybindingsMenuSectionLocal: "Local", + KeybindingsMenuSectionGlobal: "Global", + KeybindingsMenuSectionNavigation: "Navigation", + KeybindingsTooltip: "Keybindings: ", + RebasingTitle: "Rebase '{{.checkedOutBranch}}'", + RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", + SimpleRebase: "Simple rebase onto '{{.ref}}'", + InteractiveRebase: "Interactive rebase onto '{{.ref}}'", + RebaseOntoBaseBranch: "Rebase onto base branch ({{.baseBranch}})", + InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", + RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", + MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", + FwdNoUpstream: "Cannot fast-forward a branch with no upstream", + FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", + FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", + PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", + ErrorOccurred: "An error occurred! Please create an issue at", + ConflictLabel: "CONFLICT", + PendingRebaseTodosSectionHeader: "Pending rebase todos", + PendingCherryPicksSectionHeader: "Pending cherry-picks", + PendingRevertsSectionHeader: "Pending reverts", + CommitsSectionHeader: "Commits", + YouDied: "YOU DIED!", + RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", + ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", + NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", + PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing", + DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", + CherryPickCopy: "Copy (cherry-pick)", + CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.", + PasteCommits: "Paste (cherry-pick)", + SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", + CherryPick: "Cherry-pick", + CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", + Donate: "Donate", + AskQuestion: "Ask Question", + PrevHunk: "Go to previous hunk", + NextHunk: "Go to next hunk", + PrevConflict: "Previous conflict", + NextConflict: "Next conflict", + SelectPrevHunk: "Previous hunk", + SelectNextHunk: "Next hunk", + ScrollDown: "Scroll down", + ScrollUp: "Scroll up", + ScrollUpMainWindow: "Scroll up main window", + ScrollDownMainWindow: "Scroll down main window", + SuspendApp: "Suspend the application", + CannotSuspendApp: "Suspending the application is not supported on Windows", + AmendCommitTitle: "Amend commit", + AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", + AmendCommitWithConflictsMenuPrompt: "WARNING: you are about to amend the last finished commit with your resolved conflicts. This is very unlikely to be what you want at this point. More likely, you simply want to continue the rebase instead.\n\nDo you still want to amend the previous commit?", + AmendCommitWithConflictsContinue: "No, continue rebase", + AmendCommitWithConflictsAmend: "Yes, amend previous commit", + DropCommitTitle: "Drop commit", + DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", + DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.", + DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", + PullingStatus: "Pulling", + PushingStatus: "Pushing", + FetchingStatus: "Fetching", + SquashingStatus: "Squashing", + FixingStatus: "Fixing up", + DeletingStatus: "Deleting", + DroppingStatus: "Dropping", + MovingStatus: "Moving", + RebasingStatus: "Rebasing", + MergingStatus: "Merging", + LowercaseRebasingStatus: "rebasing", // lowercase because it shows up in parentheses + LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses + LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses + LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses + AmendingStatus: "Amending", + CherryPickingStatus: "Cherry-picking", + UndoingStatus: "Undoing", + RedoingStatus: "Redoing", + CheckingOutStatus: "Checking out", + CommittingStatus: "Committing", + RewordingStatus: "Rewording", + RevertingStatus: "Reverting", + CreatingFixupCommitStatus: "Creating fixup commit", + MovingCommitsToNewBranchStatus: "Moving commits to new branch", + CommitFiles: "Commit files", + SubCommitsDynamicTitle: "Commits (%s)", + CommitFilesDynamicTitle: "Diff files (%s)", + RemoteBranchesDynamicTitle: "Remote branches (%s)", + ViewItemFiles: "View files", + CommitFilesTitle: "Commit files", + CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", + CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.", + CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", + CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits", + Remove: "Remove", + DiscardOldFileChangeTooltip: "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.", + DiscardFileChangesTitle: "Discard file changes", + DiscardFileChangesPrompt: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.", + DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", + DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", + CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", + BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", + InitialBranch: "Branch name? (leave empty for git's default): ", + NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", + IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", + AutoStashTitle: "Autostash?", + AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)", + AutoStashForUndo: "Auto-stashing changes for undoing to %s", + AutoStashForCheckout: "Auto-stashing changes for checking out %s", + AutoStashForNewBranch: "Auto-stashing changes for creating new branch %s", + AutoStashForMovingPatchToIndex: "Auto-stashing changes for moving custom patch to index from %s", + AutoStashForCherryPicking: "Auto-stashing changes for cherry-picking commits", + AutoStashForReverting: "Auto-stashing changes for reverting commits", + Discard: "Discard", + DiscardFileChangesTooltip: "View options for discarding changes to the selected file.", + DiscardChangesTitle: "Discard changes", + Cancel: "Cancel", + DiscardAllChanges: "Discard all changes", + DiscardUnstagedChanges: "Discard unstaged changes", + DiscardAllChangesToAllFiles: "Nuke working tree", + DiscardAnyUnstagedChanges: "Discard unstaged changes", + DiscardUntrackedFiles: "Discard untracked files", + DiscardStagedChanges: "Discard staged changes", + HardReset: "Hard reset", + BranchDeleteTooltip: "View delete options for local/remote branch.", + TagDeleteTooltip: "View delete options for local/remote tag.", + Delete: "Delete", + Reset: "Reset", + ResetTooltip: "View reset options (soft/mixed/hard) for resetting onto selected item.", + ResetSoftTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as staged changes.", + ResetMixedTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as unstaged changes.", + ResetHardTooltip: "Reset HEAD to the chosen commit, and discard all changes between the current and chosen commit, as well as all current modifications in the working tree.", + ResetHardConfirmation: "Are you sure you want to do a hard reset? This will discard all uncommitted changes (both staged and unstaged), which is not undoable.", + ViewResetOptions: `Reset`, + FileResetOptionsTooltip: "View reset options for working tree (e.g. nuking the working tree).", + FixupTooltip: "Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded.", + CreateFixupCommit: "Create fixup commit", + CreateFixupCommitTooltip: "Create 'fixup!' commit for the selected commit. Later on, you can press `{{.squashAbove}}` on this same commit to apply all above fixup commits.", + CreateAmendCommit: `Create "amend!" commit`, + FixupMenu_Fixup: "fixup! commit", + FixupMenu_FixupTooltip: "Lets you fixup another commit and keep the original commit's message.", + FixupMenu_AmendWithChanges: "amend! commit with changes", + FixupMenu_AmendWithChangesTooltip: "Lets you fixup another commit and also change its commit message.", + FixupMenu_AmendWithoutChanges: "amend! commit without changes (pure reword)", + FixupMenu_AmendWithoutChangesTooltip: "Lets you change the commit message of another commit without changing its content.", + SquashAboveCommits: "Apply fixup commits", + SquashAboveCommitsTooltip: `Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash).`, + SquashCommitsAboveSelectedTooltip: `Squash all 'fixup!' commits above the selected commit (autosquash).`, + SquashCommitsInCurrentBranchTooltip: `Squash all 'fixup!' commits in the current branch (autosquash).`, + SquashCommitsInCurrentBranch: "In current branch", + SquashCommitsAboveSelectedCommit: "Above the selected commit", + CannotSquashCommitsInCurrentBranch: "Cannot squash commits in current branch: the HEAD commit is a merge commit or is present on the main branch.", + ExecuteShellCommand: "Execute shell command", + ExecuteShellCommandTooltip: "Bring up a prompt where you can enter a shell command to execute.", + ShellCommand: "Shell command:", + CommitChangesWithoutHook: "Commit changes without pre-commit hook", + ResetTo: `Reset to`, + PressEnterToReturn: "Press enter to return to lazygit", + ViewStashOptions: "View stash options", + ViewStashOptionsTooltip: "View stash options (e.g. stash all, stash staged, stash unstaged).", + Stash: "Stash", + StashTooltip: "Stash all changes. For other variations of stashing, use the view stash options keybinding.", + StashAllChanges: "Stash all changes", + StashStagedChanges: "Stash staged changes", + StashAllChangesKeepIndex: "Stash all changes and keep index", + StashUnstagedChanges: "Stash unstaged changes", + StashIncludeUntrackedChanges: "Stash all changes including untracked files", + StashOptions: "Stash options", + NotARepository: "Error: must be run inside a git repository", + WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", + ScrollLeft: "Scroll left", + ScrollRight: "Scroll right", + DiscardPatch: "Discard patch", + DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?", + CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state", + ToggleAddToPatch: "Toggle file included in patch", + ToggleAddToPatchTooltip: "Toggle whether the file is included in the custom patch. See {{.doc}}.", + ToggleAllInPatch: "Toggle all files", + ToggleAllInPatchTooltip: "Add/remove all commit's files to custom patch. See {{.doc}}.", + UpdatingPatch: "Updating patch", + ViewPatchOptions: "View custom patch options", + PatchOptionsTitle: "Patch options", + NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", + EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", + EnterCommitFile: "Enter file / Toggle directory collapsed", + EnterCommitFileTooltip: "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.", + EnterStaging: "Enter staging/patch building", + ExitCustomPatchBuilder: `Exit custom patch builder`, + ExitFocusedMainView: "Exit back to side panel", + ToggleSelectionInFocusedMainView: "Show/hide selection", + OpenPullRequestForSelectedLine: "Open pull request for selected line", + OpenPullRequestForSelectedLineTooltip: "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub.", + EnterUpstream: `Enter upstream as ' '`, + InvalidUpstream: "Invalid upstream. Must be in the format ' '", + NewRemote: `New remote`, + NewRemoteName: `New remote name:`, + NewRemoteUrl: `New remote url:`, + AddForkRemoteUsername: `Fork owner (username/org). Use username:branch to check out a branch`, + AddForkRemote: `Add fork remote`, + AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, + IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different URL`, + NoOriginRemote: "Action needs 'origin' remote", + ViewBranches: "View branches", + EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, + EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, + RemoveRemote: `Remove remote`, + RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, + RemoveRemotePrompt: "Are you sure you want to remove remote?", + DeleteRemoteBranch: "Delete remote branch", + DeleteRemoteBranches: "Delete remote branches", + DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", + DeleteLocalAndRemoteBranch: "Delete local and remote branch", + DeleteLocalAndRemoteBranches: "Delete local and remote branches", + SetAsUpstream: "Set as upstream", + SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", + SetUpstream: "Set upstream of selected branch", + UnsetUpstream: "Unset upstream of selected branch", + ViewDivergenceFromUpstream: "View divergence from upstream", + ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", + CouldNotDetermineBaseBranch: "Couldn't determine base branch", + DivergenceSectionHeaderLocal: "Local", + DivergenceSectionHeaderRemote: "Remote", + ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", + ViewUpstreamResetOptionsTooltip: "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream.", + ViewUpstreamRebaseOptions: "Rebase checked-out branch onto {{.upstream}}", + ViewUpstreamRebaseOptionsTooltip: "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebase the checked-out branch onto the upstream.", + UpstreamGenericName: "upstream of selected branch", + SetUpstreamTitle: "Set upstream branch", + SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'?", + EditRemoteTooltip: "Edit the selected remote's name or URL.", + TagCommit: "Tag commit", + TagCommitTooltip: "Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description.", + TagNameTitle: "Tag name", + TagMessageTitle: "Tag description", + AnnotatedTag: "Annotated tag", + LightweightTag: "Lightweight tag", + DeleteTagTitle: "Delete tag '{{.tagName}}'?", + DeleteLocalTag: "Delete local tag", + DeleteRemoteTag: "Delete remote tag", + DeleteLocalAndRemoteTag: "Delete local and remote tag", + RemoteTagDeletedMessage: "Remote tag deleted", + SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", + DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", + DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", + PushTagTitle: "Remote to push tag '{{.tagName}}' to:", // Using 'push tag' rather than just 'push' to disambiguate from a global push PushTag: "Push tag", PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.", diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c2964a8b91f..15fb2f37cfc 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -4,8 +4,11 @@ import ( "bufio" "fmt" "io" + "os" "os/exec" + "strconv" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -43,6 +46,27 @@ type ViewBufferManager struct { taskKey string onNewKey func() + // When non-nil, the next cmd/pty task restores this scroll position. Used + // when re-rendering content the user was already scrolled into (e.g. when + // returning to a focused main view on escape). It has two effects: + // + // - The task does not reset the view's origin to the top at start, even if + // its command key differs from the previous task's. This keeps the + // placeholder that CopyContent left in the view showing at its current + // scroll position (i.e. "as if nothing changed") until the real content + // is ready, rather than flicking it to the top. + // - The task sizes its initial read to this position and scrolls there as + // part of its first refresh, so the saved scroll is applied in the same + // paint that shows the real content. + // + // Cleared once the next task has started. + scrollToOriginYForNextTask *int + + // Whether a command task is currently reading content into the view. While + // this is true the content is still growing, so callers (e.g. the layout) + // must not clamp the view's scroll position to the amount loaded so far. + loading atomic.Bool + // beforeStart is the function that is called before starting a new task beforeStart func() refreshView func() @@ -70,6 +94,12 @@ type LinesToRead struct { // subsequent requests. InitialRefreshAfter int + // When set, called once, just before the view is first refreshed, to scroll + // it to a saved position. Used so that content the user was scrolled into is + // painted at the saved scroll position the first time it appears, rather than + // at the top. Only set for the initial read request. + ApplyInitialScroll func() + // Function to call after reading the lines is done Then func() } @@ -107,6 +137,37 @@ func (self *ViewBufferManager) ReadLines(n int) { } } +// ScrollToOriginYForNextTask makes the next cmd/pty task restore the given +// scroll position instead of rendering at the top. Call this right before +// triggering a re-render of content the view is already scrolled into (e.g. +// when returning to a focused main view on escape). See the field doc for the +// two effects this has. It is cleared once the next task starts. +func (self *ViewBufferManager) ScrollToOriginYForNextTask(originY int) { + self.scrollToOriginYForNextTask = &originY +} + +// GetScrollToOriginYForNextTask returns the scroll position requested by a +// preceding ScrollToOriginYForNextTask call, or nil if none. It does not clear +// it; the task clears it when it starts. +func (self *ViewBufferManager) GetScrollToOriginYForNextTask() *int { + return self.scrollToOriginYForNextTask +} + +// IsLoading reports whether a command task is currently reading content into the +// view, meaning the content is still growing. +func (self *ViewBufferManager) IsLoading() bool { + return self.loading.Load() +} + +// StartLoading marks the view as loading content. It must be called +// synchronously when a command/pty task is started, before the task's goroutine +// runs, so that a layout pass happening in between doesn't clamp the scroll +// position to the not-yet-loaded content. It is cleared when the task reaches +// the end of its input. +func (self *ViewBufferManager) StartLoading() { + self.loading.Store(true) +} + func (self *ViewBufferManager) ReadToEnd(then func()) { if self.readLines != nil { go utils.Safe(func() { @@ -248,6 +309,29 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p } } + // The initial read request carries an optional scroll-to function (see + // LinesToRead.ApplyInitialScroll). We apply it exactly once, right before + // the view is first refreshed, so that content the user was scrolled into + // is painted at the saved position the first time it appears. + initialScroll := linesToRead.ApplyInitialScroll + var applyInitialScrollOnce sync.Once + applyInitialScroll := func() { + if initialScroll != nil { + applyInitialScrollOnce.Do(initialScroll) + } + } + + // Set LAZYGIT_SLOW_RENDER= to sleep that long after each + // line is written to the view, stretching async loads out so the frames + // of a re-render become visible. Useful for debugging scroll/flicker + // behaviour; has no effect when the variable is unset. + var slowRenderPerLine time.Duration + if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" { + if ms, err := strconv.Atoi(v); err == nil { + slowRenderPerLine = time.Duration(ms) * time.Millisecond + } + } + outer: for { select { @@ -282,18 +366,48 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content + // so we're at the EOF and can flush the stale content. Apply the + // saved scroll first (if any) so that onEndOfInput clamps it back + // into range when the new content turned out shorter than expected. + applyInitialScroll() self.onEndOfInput() + // The content is fully loaded now, so it's safe again for the + // layout to clamp the scroll position to it. We deliberately + // don't clear this when stopped (rather than EOF'd), because that + // means a newer task is taking over and is still loading. + self.loading.Store(false) callThen() + // Any read requests that were queued while we were reading are + // now trivially satisfied, since we've read everything. Fire + // their callbacks instead of dropping them when we break out of + // the loop below (and nil out readLines). + drain: + for { + select { + case queued := <-self.readLines: + if queued.Then != nil { + queued.Then() + } + default: + break drain + } + } break outer } writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} + if slowRenderPerLine > 0 { + time.Sleep(slowRenderPerLine) + } + if i+1 == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh - // here to show what we have. Continue reading and refresh again at - // the end to make sure the scrollbar has the right size. + // here to show what we have. Apply the saved scroll first (if any) + // so the first paint already lands at it. Continue reading and + // refresh again at the end to make sure the scrollbar has the right + // size. + applyInitialScroll() refreshViewIfStale() } } @@ -388,9 +502,14 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.newTaskID++ taskID := self.newTaskID - if self.GetTaskKey() != key && self.onNewKey != nil { + // Reset the origin to the top when the command changed, unless a caller + // asked us to restore a scroll position: in that case we keep the + // placeholder showing at its current scroll until the task scrolls to the + // saved position as part of its first paint (see scrollToOriginYForNextTask). + if self.GetTaskKey() != key && self.onNewKey != nil && self.scrollToOriginYForNextTask == nil { self.onNewKey() } + self.scrollToOriginYForNextTask = nil self.taskKey = key self.taskIDMutex.Unlock() diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 40ff0033d6e..9942c059dcd 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -52,7 +52,7 @@ func TestNewCmdTaskInstantStop(t *testing.T) { return cmd, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) @@ -115,7 +115,7 @@ func TestNewCmdTask(t *testing.T) { return cmd, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) wg := sync.WaitGroup{} wg.Go(func() { time.Sleep(100 * time.Millisecond) @@ -182,37 +182,37 @@ func TestNewCmdTaskRefresh(t *testing.T) { { "total < initialRefreshAfter", 150, - LinesToRead{100, 120, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 120}, []int{100}, }, { "total == initialRefreshAfter", 150, - LinesToRead{100, 100, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 100}, []int{100}, }, { "total > initialRefreshAfter", 150, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 100}, }, { "initialRefreshAfter == -1", 150, - LinesToRead{100, -1, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: -1}, []int{100}, }, { "totalTaskLines < initialRefreshAfter", 25, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{25}, }, { "totalTaskLines between total and initialRefreshAfter", 75, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 75}, }, }