diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index d532f1bbb..114d3f0c6 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -2,8 +2,11 @@ package git_commands import ( "fmt" + "os" + "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/mgutz/str" ) type DiffCommands struct { @@ -16,6 +19,69 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { } } +// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first +// output, to announce that it speaks the diff-line-metadata protocol: a version-only +// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's +// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake. +const metadataHandshake = "\x1b]1717" + +// ProbePagerEmitsDiffMetadata reports whether the configured pager speaks the +// diff-line-metadata protocol, by running it on empty input and checking for its +// handshake. It's the focused main view's signal for whether it can act on the +// pager's rendered diff or must fall back to the raw diff (see +// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent — +// the handshake is the pager's first output regardless of the diff — so the caller +// caches it per pager. +// +// No PTY is needed: git needs a terminal to decide to invoke a pager, but the pager +// itself emits the handshake whenever EMIT_OSC1717_METADATA is set, so we can run it +// directly with empty input. +// +// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via +// .gitattributes and a single diff can mix drivers, so there's no one pager to probe; +// we conservatively report false (the focused main view then always renders raw). +func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool { + if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" { + return self.externalDiffEmitsMetadata(extDiffCmd) + } + if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" { + return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, "")) + } + return false +} + +// externalDiffEmitsMetadata probes an external diff command, invoking it the way git +// invokes a diff.external driver — with 7 positional args +// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp +// files, so it emits its handshake without there being a real diff to render. +func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool { + oldFile, err := os.CreateTemp("", "lazygit-probe-old-*") + if err != nil { + return false + } + defer os.Remove(oldFile.Name()) + oldFile.Close() + + newFile, err := os.CreateTemp("", "lazygit-probe-new-*") + if err != nil { + return false + } + defer os.Remove(newFile.Name()) + newFile.Close() + + args := append(str.ToArgv(extDiffCmd), + "probe", oldFile.Name(), "0000000", "100644", newFile.Name(), "0000000", "100644") + return self.probeEmitsMetadata(self.cmd.New(args)) +} + +func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool { + cmdObj.AddEnvVars("EMIT_OSC1717_METADATA=V1") + // The pager may exit non-zero on the synthetic input; we only care about whether + // it emitted the handshake first, and the output is captured either way. + output, _ := cmdObj.RunWithOutput() + return strings.Contains(output, metadataHandshake) +} + // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom diff renderer if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 1f9d9653b..fc45805da 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -385,14 +385,17 @@ func (self *WorkingTreeCommands) Exclude(filename string) error { // WorktreeFileDiff returns the diff of a file func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { // for now we assume an error means the file was deleted - s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput() + s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, false, nil).RunWithOutput() return s } // WorktreeFileDiffCmdObj returns a command object for diffing a file or directory // in the working tree. When pathOverrides is non-empty, those paths are used instead of // the node's path (used to diff only filtered/visible files within a directory). -func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj { +// ignoreExternalDiff forces git's own (coloured) diff regardless of a configured +// external diff command, for the focused main view's raw-diff fallback (see +// StagingHelper.DiffMainViewShouldRenderRaw); unlike plain it keeps the colour. +func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, ignoreExternalDiff bool, pathOverrides []string) *oscommands.CmdObj { colorArg := self.diffRendererConfigManager.GetColorArg() if plain { colorArg = "never" @@ -407,7 +410,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain } cmdArgs := NewGitCmd("diff"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff). Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", colorArg)). ArgIf(cached, "--cached"). diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 5a176db41..fa1cde460 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -373,8 +373,13 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { split, mainShowsStaged := self.diffSplitState(node) + // When the focused main view needs to act on a diff the configured pager + // can't resolve, render it raw (no pager) so it's stageable; browsing keeps + // the pretty pager output. See StagingHelper.DiffMainViewShouldRenderRaw. + renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw() + pathOverrides := self.pathOverridesForDiff(node) - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, renderRaw, pathOverrides) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges @@ -382,14 +387,14 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { refreshOpts := types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ - Task: types.NewRunPtyTask(cmdObj.GetCmd()), + Task: diffMainViewTask(renderRaw, cmdObj.GetCmd()), SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Title: title, }, } if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, renderRaw, pathOverrides) title := self.c.Tr.StagedChanges if mainShowsStaged { @@ -399,7 +404,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { refreshOpts.Secondary = &types.ViewUpdateOpts{ Title: title, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRunPtyTask(cmdObj.GetCmd()), + Task: diffMainViewTask(renderRaw, cmdObj.GetCmd()), } } diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 4bc00f69f..188569c17 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -1,11 +1,13 @@ package helpers import ( + "fmt" "path/filepath" "regexp" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -18,6 +20,14 @@ var lazygitEditURLRegexp = regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`) type StagingHelper struct { c *HelperCommon windowHelper *WindowHelper + + // pagerMetadataSupport caches whether the configured pager speaks the + // diff-line-metadata protocol (nil until first probed), so the focused main view + // knows whether it can act on the pager's diff or must render it raw (see + // DiffMainViewShouldRenderRaw). Keyed on pagerSupportSig; re-probed when the pager + // changes (a cycle or a config reload). + pagerMetadataSupport *bool + pagerSupportSig string } func NewStagingHelper( @@ -784,3 +794,106 @@ func (self *StagingHelper) diffLineInfoFromHyperlink(hyperlink string) (types.Di NewLine: utils.MustConvertToInt(matches[2]), }, true } + +// --- The raw-diff fallback for unsupported pagers --- +// +// The focused main view is the staging surface, so it must be able to resolve the +// diff it shows to patch-space. A pager that restructures the diff without emitting +// our metadata (stock delta-default, plain difftastic, `cat -n`) produces output we +// can't resolve, so when such a diff is focused we re-render it raw (git's own +// colour, no pager) — the same content a no-pager setup shows, which the buffer +// parser handles. Browsing keeps the pretty pager output; only focusing to act +// switches to raw. +// +// Whether a pager is usable is decided by probing it for the metadata handshake (see +// DiffCommands.ProbePagerEmitsDiffMetadata) — a pager-level, content-independent fact, +// so the verdict is stable (a binary file, which has no change lines under any pager, +// can't mislead it) and known before we render, so we never render pretty only to +// discover mid-flight that we should have rendered raw. + +// pagerSupportsMetadata returns whether the current pager speaks the metadata +// protocol, probing it once and caching the result, re-probing when the pager changes. +func (self *StagingHelper) pagerSupportsMetadata() bool { + sig := self.currentPagerSignature() + if self.pagerMetadataSupport == nil || sig != self.pagerSupportSig { + result := self.c.Git().Diff.ProbePagerEmitsDiffMetadata() + self.pagerMetadataSupport = &result + self.pagerSupportSig = sig + } + return *self.pagerMetadataSupport +} + +// currentPagerSignature identifies the current pager, so the cached verdict resets +// when it changes (the user cycles pagers, or reloads a changed config). The pager +// command's width placeholder is irrelevant to its identity, so a fixed width is used. +func (self *StagingHelper) currentPagerSignature() string { + pc := self.c.State().GetDiffRendererConfigManager() + index, _ := pc.CurrentDiffRendererIndex() + return fmt.Sprintf("%d\x00%s\x00%s", + index, pc.GetExternalDiffCommand(3), pc.GetStdinFilterCommand(0)) +} + +// MainViewPagerConfigured reports whether any custom pager is in effect for the +// focused main view's diff — an external diff command, git's diff.external config, +// or a stdin pager. With no pager the diff is already raw and resolvable, so the +// raw-diff fallback only applies when one is configured. +func (self *StagingHelper) MainViewPagerConfigured() bool { + pc := self.c.State().GetDiffRendererConfigManager() + return pc.GetDiffRendererType() != config.DiffRendererType_RawGit +} + +func (self *StagingHelper) rendererIsRawGitWithArgs() bool { + pc := self.c.State().GetDiffRendererConfigManager() + return pc.GetDiffRendererType() == config.DiffRendererType_RawGit && + len(pc.GetRawGitArgs()) > 0 +} + +// DiffMainViewShouldRenderRaw reports whether a side panel rendering its diff into +// the focused main view should bypass the pager and render the raw (git-coloured) +// diff. This holds while the main view holds focus and the configured pager doesn't +// speak the metadata protocol. Side panels consult it when building their main-view +// diff (see e.g. FilesController.GetOnRenderToMain) so that a re-render while focused +// — after staging a hunk, say — stays raw. +func (self *StagingHelper) DiffMainViewShouldRenderRaw() bool { + return self.focusedOnMainView() && ((self.MainViewPagerConfigured() && !self.pagerSupportsMetadata()) || + self.rendererIsRawGitWithArgs()) +} + +func (self *StagingHelper) focusedOnMainView() bool { + current := self.c.Context().CurrentStatic().GetKey() + return current == self.c.Contexts().Normal.GetKey() || + current == self.c.Contexts().NormalSecondary.GetKey() +} + +// RenderFocusedMainViewRaw installs a restore that calls place once the focused main +// view's raw re-render lands, then triggers that re-render via the side panel beneath. +// It's used when focusing a diff under a pager that doesn't speak the protocol: +// DiffMainViewShouldRenderRaw is already true (the verdict is probed, not derived from +// this render), so the side panel renders raw; place then establishes the selection on +// the result. +func (self *StagingHelper) RenderFocusedMainViewRaw(view *gocui.View, sidePanel types.Context, place func()) { + manager := self.c.GetOrCreateViewBufferManagerForView(view) + if manager == nil { + return + } + + manager.SetRestoreForNextTask(&tasks.RenderRestore{ + // Hold the first paint until end of input so the whole diff is read before we + // place the selection (a change line near the end would otherwise be missed). + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + swapIn() + manager.ClearRestoreForNextTask() + // Apply runs on the task's goroutine; hop to the UI thread to place the + // selection, which reads the swapped-in content and touches view state. + self.c.OnUIThread(func() error { + place() + return nil + }) + }, + }) + + if sidePanel != nil { + sidePanel.HandleRenderToMain() + } +} diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index cc3181c19..5c0254882 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "os/exec" "path/filepath" "strings" @@ -249,21 +250,53 @@ func (self *MainViewController) togglePanel() error { return nil } -// showInitialDiffSelection turns on the focused main view's selection when entering -// a diff view without pointing at a specific line, anchored on the first change line -// already visible (so the view barely moves), falling back to the current top line -// when none is visible (scrolled into trailing context, or not loaded that far yet). -// With hunk mode configured as the default it selects the whole change block around -// that line, like entering the staging view does; otherwise a single line. +// showInitialDiffSelection turns on the focused main view's selection when entering a +// diff view by keyboard, without pointing at a specific line. See +// establishFocusedDiffSelection, which it defers to (clicks go through there too, +// passing the clicked line). func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext) { + establishFocusedDiffSelection(c, mainContext, -1) +} + +// establishFocusedDiffSelection turns on the focused main view's selection after the +// view is focused. Under a pager that doesn't speak the metadata protocol (probed, so +// known up front; see StagingHelper.DiffMainViewShouldRenderRaw) the diff on screen was +// rendered pretty for browsing and isn't resolvable, so it re-renders it raw and places +// the selection once that lands; otherwise it places the selection on the diff directly. +// clickedViewLine is the view line a click pointed at, or -1 for keyboard focus (start +// at the first change block). +func establishFocusedDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) { + staging := c.Helpers().Staging resetDiffSelectMode(mainContext) + + if staging.DiffMainViewShouldRenderRaw() { + sidePanel := c.Context().NextInStack(mainContext) + staging.RenderFocusedMainViewRaw(mainContext.GetView(), sidePanel, func() { + placeOrHideInitialDiffSelection(c, mainContext, clickedViewLine, true) + }) + return + } + + placeOrHideInitialDiffSelection(c, mainContext, clickedViewLine, clickedViewLine < 0) +} + +// placeOrHideInitialDiffSelection puts the focused main view's selection on the clicked +// line (clickedViewLine >= 0) or, for keyboard focus, on the first change line at or +// below the top of the viewport — so the view barely moves — falling back to the top +// line when none is visible. With hunk mode configured as the default, keyboard focus +// selects the whole change block around that line, like entering the staging view does. +// When the diff has nothing to act on (a placeholder, a binary file, an all-context +// diff) it shows no selection rather than highlighting a stray line. +func placeOrHideInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int, scrollIntoView bool) { view := mainContext.GetView() - // Nothing to act on (the main view shows "No changed files" or another non-diff - // placeholder): show no selection at all rather than highlighting a stray line. if !c.Helpers().Staging.ViewHasChangeLines(view) { view.Highlight = false return } + if clickedViewLine >= 0 { + showSelectionAtLine(view, clickedViewLine, scrollIntoView) + return + } target, ok := c.Helpers().Staging.FirstChangeLineInView(view) if !ok { showSelectionAtLine(view, view.OriginY(), true) @@ -277,6 +310,20 @@ func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainCont showSelectionAtLine(view, target, true) } +// diffMainViewTask builds the task a side panel uses to render its diff into the main +// view, choosing between the normal pty task and the raw-diff fallback. When renderRaw +// is set (the focused main view needs to act on a diff the configured pager can't +// resolve, see StagingHelper.DiffMainViewShouldRenderRaw) it uses a plain command task, +// which — unlike the pty task — doesn't pipe the diff through a stdin pager (GIT_PAGER); +// the external diff command, if any, is suppressed in the cmd itself. The caller passes +// the same renderRaw to the diff-cmd builder so the two stay in step. +func diffMainViewTask(renderRaw bool, cmd *exec.Cmd) types.UpdateTask { + if renderRaw { + return types.NewRunCommandTask(cmd) + } + return types.NewRunPtyTask(cmd) +} + // updateFocusedMainViewSelectionVisibility shows or hides the focused-main-view selection // to match what a side panel is rendering into the main view, called from the panel's // render-to-main so the selection tracks content changes (a refresh after the last change diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index a2dd22ed3..b5d4984c7 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() { if file == nil { task = types.NewRenderStringTask(prefix) } else { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, false, nil) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } 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 757a9bf1b..41cf829c9 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -88,14 +88,10 @@ func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *co return nil } - if clickedLineIdx >= 0 { - // A click points at a specific line, so select it directly (in the default - // single-line mode). - resetDiffSelectMode(mainViewContext) - showSelectionAtLine(mainViewContext.GetView(), clickedLineIdx, false) - } else { - showInitialDiffSelection(self.c, mainViewContext) - } + // A click points at a specific line (clickedLineIdx); keyboard focus (-1) starts at + // the first change block. Either way, if the pager produced an unresolvable diff + // this re-renders it raw first (see establishFocusedDiffSelection). + establishFocusedDiffSelection(self.c, mainViewContext, clickedLineIdx) // The inclusion gutter is refreshed by the main view's focus handler (it's shown // only while focused, so it tracks focus changes there). diff --git a/pkg/integration/tests/staging/stage_from_main_view_with_conforming_pager.go b/pkg/integration/tests/staging/stage_from_main_view_with_conforming_pager.go new file mode 100644 index 000000000..a9ad7f2a7 --- /dev/null +++ b/pkg/integration/tests/staging/stage_from_main_view_with_conforming_pager.go @@ -0,0 +1,65 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageFromMainViewWithConformingPager = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focus the main view under a pager that announces the diff-metadata protocol (a handshake); its output is trusted, so the diff is not re-rendered raw and staging works on it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // A fake conforming pager: it emits the version-only OSC 1717 handshake as its + // first output (announcing it speaks the protocol), prints a marker line so the + // test can tell its output apart from the raw diff, then passes the diff through + // unchanged. The probe finds the handshake and trusts the pager, so focusing + // does not fall back to the raw diff. (The passed-through diff is structurally + // intact, so the selection still resolves via the buffer parser.) + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: "printf '\\033]1717;1\\007CONFORMING-PAGER\\n'; cat"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ) + + // The handshake is swallowed (it leaves no visible bytes), but the pager's + // marker line proves its output is what's shown. + t.Views().Main().Content(Contains("CONFORMING-PAGER")) + + t.Views().Files().Press(keys.Universal.FocusMainView) + + // Focusing did not re-render raw — the marker is still there — and the selection + // resolved on the pager's (structure-preserving) output. + t.Views().Main(). + IsFocused(). + Content(Contains("CONFORMING-PAGER")). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction(). + Tap(func() { + t.Views().Secondary(). + ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + }) + }, +}) diff --git a/pkg/integration/tests/staging/stage_from_main_view_with_unsupported_pager.go b/pkg/integration/tests/staging/stage_from_main_view_with_unsupported_pager.go new file mode 100644 index 000000000..2215b929b --- /dev/null +++ b/pkg/integration/tests/staging/stage_from_main_view_with_unsupported_pager.go @@ -0,0 +1,68 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageFromMainViewWithUnsupportedPager = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focus the main view under a pager that restructures the diff without emitting metadata; it falls back to the raw diff so the selection is still stageable", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // `cat -n` prepends a line number to every line, which pushes the diff's +/- + // markers off the start of the line: the buffer parser can't resolve it, and + // cat emits no metadata, so the focused main view must fall back to the raw diff. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: "cat -n"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // Two separate change blocks, so staging one leaves the file split (the other + // stays unstaged) and the staged side renders into the secondary view. + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // While browsing, the main view shows the pager's output: `cat -n` has numbered + // every line (the first being the `diff --git` header), so the diff isn't in + // its raw form. + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ) + t.Views().Main().Content(Contains("1 diff --git a/file1 b/file1")) + + // Focusing the main view falls back to the raw diff, so the change line is + // resolved and selected (the pager's line numbers are gone). + t.Views().Files().Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction(). + Tap(func() { + t.Views().Secondary(). + ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + }). + // The other block stays unstaged, also shown raw. + ContainsLines( + Contains("-nine"), + Contains("+NINE"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fc72a4a85..78ea9132f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -431,6 +431,8 @@ var tests = []*components.IntegrationTest{ staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, staging.SelectNextLineAfterStagingLineFromMainView, + staging.StageFromMainViewWithConformingPager, + staging.StageFromMainViewWithUnsupportedPager, staging.StageHunkFromMainView, staging.StageHunks, staging.StageHunksWithRapidKeypresses,