diff --git a/README.md b/README.md index 0c3ead9..212762f 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ ui: | `ui.colorFileNames` | bool | `true` | Color filenames by git status | | `ui.showDiffStats` | bool | `true` | Show the amount of lines added / removed next to the file | | `ui.sideBySide` | bool | `true` | Use side-by-side diff view (false for unified) | +| `ui.wrapLines` | bool | `true` | Wrap long diff lines (false for unified view with horizontal scrolling) | | `ui.startFoldersOpenDepth` | int | `-1` | Folder open depth on start (-1 = all, 0 = none) | | `ui.theme` | string | `tokyo_night` | The ID of the theme to use | @@ -233,6 +234,7 @@ ui: | i | Cycle icon style | | o | Open file in $EDITOR | | s | Toggle side-by-side/unified view | +| w | Toggle line wrap (off = scroll horizontally with ←/→) | | Tab | Switch focus between the panes | | T | Open the theme picker | | q | Quit | diff --git a/pkg/config/config.go b/pkg/config/config.go index 47a6f07..0ad2cd1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -20,6 +20,7 @@ type UIConfig struct { ColorFileNames bool `yaml:"colorFileNames"` // Color filenames by git status (default: true) ShowDiffStats bool `yaml:"showDiffStats"` // Show the amount of lines added / removed next to the file SideBySide bool `yaml:"sideBySide"` // Side-by-side diff view (default: true) + WrapLines bool `yaml:"wrapLines"` // Wrap long diff lines (default: true); when false, scroll the diff horizontally instead StartFoldersOpenDepth int `yaml:"startFoldersOpenDepth"` // How many levels of folders to open on start (-1 = all, 0 = none) Theme string `yaml:"theme"` // The theme to use (default: "tokyo_night") } @@ -46,6 +47,7 @@ func DefaultConfig() Config { Icons: "nerd-fonts-status", ColorFileNames: true, SideBySide: true, + WrapLines: true, ShowDiffStats: true, StartFoldersOpenDepth: -1, Theme: tint.TintTokyoNight.ID, diff --git a/pkg/ui/keys.go b/pkg/ui/keys.go index 742b1a9..9301887 100644 --- a/pkg/ui/keys.go +++ b/pkg/ui/keys.go @@ -29,6 +29,7 @@ type KeyMap struct { SwitchPanel key.Binding OpenInEditor key.Binding ToggleDiffView key.Binding + ToggleWrapLines key.Binding ToggleIconStyle key.Binding ToggleHelp key.Binding ToggleMessage key.Binding @@ -141,6 +142,10 @@ var keys = &KeyMap{ key.WithKeys("s"), key.WithHelp("s", "toggle side-by-side"), ), + ToggleWrapLines: key.NewBinding( + key.WithKeys("w"), + key.WithHelp("w", "toggle line wrap"), + ), ToggleIconStyle: key.NewBinding( key.WithKeys("i"), key.WithHelp("i", "toggle icon style"), @@ -186,6 +191,7 @@ func KeyGroups() [][]key.Binding { keys.Copy, keys.OpenInEditor, keys.ToggleDiffView, + keys.ToggleWrapLines, keys.ToggleIconStyle, keys.ToggleSelection, }, { diff --git a/pkg/ui/panes/diffviewer/diffviewer.go b/pkg/ui/panes/diffviewer/diffviewer.go index 69bd003..e4304a6 100644 --- a/pkg/ui/panes/diffviewer/diffviewer.go +++ b/pkg/ui/panes/diffviewer/diffviewer.go @@ -33,10 +33,13 @@ type cachedNode struct { type nodeCache map[string]*cachedNode -func cacheKey(path string, sideBySide bool) string { +func cacheKey(path string, sideBySide bool, wrap bool) string { key := path + ":" + common.Themes.Current().ID if sideBySide { - return key + ":sbs" + key += ":sbs" + } + if !wrap { + key += ":nowrap" } return key } @@ -91,8 +94,21 @@ type Model struct { dir *cachedNode cache nodeCache sideBySide bool + wrapLines bool preamble string sb common.Scrollbar + // root additions/deletions are render-mode invariant and outlive cache + // clears, so the footer stats survive wrap/side-by-side toggles and theme + // changes while a file is open + rootAdditions int64 + rootDeletions int64 +} + +// renderSideBySide reports whether the diff should render side-by-side. +// delta truncates overflowing content in side-by-side mode, so it is only +// usable when lines are wrapped. +func (m Model) renderSideBySide() bool { + return m.sideBySide && m.wrapLines } // SetPreamble stores the preamble text (e.g. commit metadata from git show). @@ -100,11 +116,12 @@ func (m *Model) SetPreamble(preamble string) { m.preamble = preamble } -func New(sideBySide bool, styles *common.Styles) Model { +func New(sideBySide bool, wrapLines bool, styles *common.Styles) Model { vp := viewport.New( 0, 0, viewport.WithKeyMap[diffLine](ViewportKeyMap), + viewport.WithWrapText[diffLine](wrapLines), viewport.WithStyles[diffLine]( viewport.Styles{ SelectionPrefix: lipgloss.NewStyle().Foreground(styles.Tint.Blue).Render("▐"), @@ -200,6 +217,7 @@ func New(sideBySide bool, styles *common.Styles) Model { filterableviewport.WithHorizontalPad[diffLine](8), ), sideBySide: sideBySide, + wrapLines: wrapLines, cache: map[string]*cachedNode{}, } } @@ -208,6 +226,23 @@ func (m Model) Init() tea.Cmd { return nil } +// currentCacheKey returns the cache key of the node and render mode currently +// being displayed, or "" if none is set. +func (m Model) currentCacheKey() string { + if m.file != nil { + file := m.file.files[0] + return cacheKey( + m.file.path, + m.renderSideBySide() && !file.IsNew && !file.IsDelete, + m.wrapLines, + ) + } + if m.dir != nil { + return cacheKey(m.dir.path, m.renderSideBySide(), m.wrapLines) + } + return "" +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { log.Debug("diffviewer", "msg", fmt.Sprintf("%T", msg)) cmds := make([]tea.Cmd, 0) @@ -216,7 +251,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if _, ok := m.cache[msg.cacheKey]; ok { m.cache[msg.cacheKey].diff = msg.lines } - m.fvp.SetObjects(msg.lines) + // a render finishing after we navigated away (other node, or toggled + // wrap/side-by-side) must not clobber what is on screen + if msg.cacheKey == m.currentCacheKey() { + m.fvp.SetObjects(msg.lines) + } } fvp, fvpCmd := m.fvp.Update(msg) @@ -264,8 +303,8 @@ func (m Model) contentWidth() int { func (m *Model) diff() tea.Cmd { if m.file != nil { file := m.file.files[0] - sideBySide := m.sideBySide && !file.IsNew && !file.IsDelete - key := cacheKey(m.file.path, sideBySide) + sideBySide := m.renderSideBySide() && !file.IsNew && !file.IsDelete + key := cacheKey(m.file.path, sideBySide, m.wrapLines) if cached, ok := m.cache[key]; ok && len(cached.diff) != 0 { m.file = cached m.fvp.SetObjects(cached.diff) @@ -281,7 +320,7 @@ func (m *Model) diff() tea.Cmd { m.cache[key] = node return m.diffFile(node, m.contentWidth()) } else if m.dir != nil { - key := cacheKey(m.dir.path, m.sideBySide) + key := cacheKey(m.dir.path, m.renderSideBySide(), m.wrapLines) if cached, ok := m.cache[key]; ok && len(cached.diff) != 0 { m.dir = cached m.fvp.SetObjects(cached.diff) @@ -299,7 +338,7 @@ func (m *Model) diff() tea.Cmd { if m.dir.path == "/" { preamble = m.preamble } - return m.diffDir(node, m.contentWidth(), m.sideBySide, preamble) + return m.diffDir(node, m.contentWidth(), m.renderSideBySide(), preamble) } return nil @@ -350,10 +389,11 @@ func (m Model) dirHeaderView() string { func (m Model) SetFilePatch(file *gitdiff.File) (Model, tea.Cmd) { m.dir = nil + m.resetPan() fname := filenode.GetFileName(file) - sideBySide := m.sideBySide && !file.IsNew && !file.IsDelete - key := cacheKey(fname, sideBySide) + sideBySide := m.renderSideBySide() && !file.IsNew && !file.IsDelete + key := cacheKey(fname, sideBySide, m.wrapLines) if cached, ok := m.cache[key]; ok { m.file = cached m.updateHeader() @@ -378,8 +418,9 @@ func (m Model) SetFilePatch(file *gitdiff.File) (Model, tea.Cmd) { func (m Model) SetDirPatch(dirPath string, files []*gitdiff.File) (Model, tea.Cmd) { m.file = nil + m.resetPan() - key := cacheKey(dirPath, m.sideBySide) + key := cacheKey(dirPath, m.renderSideBySide(), m.wrapLines) if cached, ok := m.cache[key]; ok { m.dir = cached m.updateHeader() @@ -400,12 +441,16 @@ func (m Model) SetDirPatch(dirPath string, files []*gitdiff.File) (Model, tea.Cm deletions: deleted, } m.cache[key] = m.dir + if dirPath == "/" { + m.rootAdditions = added + m.rootDeletions = deleted + } preamble := "" if dirPath == "/" { preamble = m.preamble } m.updateHeader() - return m, m.diffDir(m.dir, m.contentWidth(), m.sideBySide, preamble) + return m, m.diffDir(m.dir, m.contentWidth(), m.renderSideBySide(), preamble) } func (m *Model) updateHeader() { @@ -418,6 +463,21 @@ func (m *Model) SetSideBySide(sideBySide bool) tea.Cmd { return m.diff() } +// SetWrapLines updates whether long lines wrap and re-renders. Wrapping is +// required for side-by-side, so turning it off falls back to the unified view. +func (m *Model) SetWrapLines(wrapLines bool) tea.Cmd { + m.wrapLines = wrapLines + m.fvp.SetWrapText(wrapLines) + return m.diff() +} + +// panResetCols is large enough that a ScrollLeft by it always lands on column 0. +const panResetCols = 1 << 30 + +func (m *Model) resetPan() { + m.fvp.ScrollLeft(panResetCols) +} + // ScrollUp scrolls the viewport up by the given number of lines. func (m *Model) ScrollUp(lines int) { m.fvp.ScrollUp(lines) @@ -443,7 +503,7 @@ func (m *Model) ScrollLeft(cols int) { m.fvp.ScrollLeft(cols) } -// ScrollRight scrolls the viewport one column away from column 0. +// ScrollRight scrolls the viewport right by the given number of columns. func (m *Model) ScrollRight(cols int) { m.fvp.ScrollRight(cols) } @@ -454,8 +514,8 @@ func (m *Model) diffFile(node *cachedNode, width int) tea.Cmd { } file := node.files[0] - sideBySide := m.sideBySide && !file.IsNew && !file.IsDelete - key := cacheKey(node.path, sideBySide) + sideBySide := m.renderSideBySide() && !file.IsNew && !file.IsDelete + key := cacheKey(node.path, sideBySide, m.wrapLines) return func() tea.Msg { args := m.makeDeltaArgs( sideBySide, @@ -479,7 +539,7 @@ func (m *Model) diffDir(dir *cachedNode, width int, sideBySide bool, preamble st if width == 0 || dir == nil { return nil } - key := cacheKey(dir.path, sideBySide) + key := cacheKey(dir.path, sideBySide, m.wrapLines) return func() tea.Msg { args := m.makeDeltaArgs(sideBySide, width, deltaOpts{}) log.Info("executing delta", "cmd", fmt.Sprintf(`delta %s`, strings.Join(args, " "))) @@ -549,11 +609,7 @@ func (m *Model) ClearCache() { } func (m *Model) RootDiffStats() (int64, int64) { - if item, ok := m.cache[cacheKey("/", m.sideBySide)]; ok { - return item.additions, item.deletions - } - - return 0, 0 + return m.rootAdditions, m.rootDeletions } func (m *Model) Searching() bool { @@ -609,6 +665,12 @@ func (m *Model) makeDeltaArgs(sideBySide bool, width int, opts deltaOpts) []stri minusLine := common.LipglossColorToHex(theme.BrightRed) red := common.LipglossColorToHex(lipgloss.Darken(theme.Red, 0.8)) brightRed := common.LipglossColorToHex(lipgloss.Darken(theme.BrightRed, 0.5)) + // zero means delta never wraps; with --max-line-length=0 the full line is + // emitted so the viewport can scroll it horizontally + wrapMaxLines := "unlimited" + if !m.wrapLines { + wrapMaxLines = "0" + } args := []string{ "--paging=never", "--line-numbers", @@ -633,7 +695,7 @@ func (m *Model) makeDeltaArgs(sideBySide bool, width int, opts deltaOpts) []stri fmt.Sprintf("--file-decoration-style='\"%s\" box %s'", selectionColor, selectionColor), fmt.Sprintf("-w=%d", width), "--max-line-length=0", - "--wrap-max-lines=unlimited", + fmt.Sprintf("--wrap-max-lines=%s", wrapMaxLines), fmt.Sprintf( "--line-numbers-right-style='\"%s\" dim'", common.LipglossColorToHex(m.Styles.Colors.FaintBlue()), diff --git a/pkg/ui/panes/diffviewer/diffviewer_test.go b/pkg/ui/panes/diffviewer/diffviewer_test.go index 4047473..f26edcf 100644 --- a/pkg/ui/panes/diffviewer/diffviewer_test.go +++ b/pkg/ui/panes/diffviewer/diffviewer_test.go @@ -1,16 +1,18 @@ package diffviewer import ( + "slices" "strings" "testing" + "github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/charmbracelet/x/ansi" "github.com/dlvhdr/diffnav/pkg/ui/common" ) func TestRenderPreamble_Empty(t *testing.T) { common.RegisterSupportedTints() - m := New(false, &common.Styles{ + m := New(false, true, &common.Styles{ Tint: common.Themes.Current(), Colors: common.Colors{}, }) @@ -24,7 +26,7 @@ func TestRenderPreamble_Empty(t *testing.T) { func TestRenderPreamble_GitShow(t *testing.T) { common.RegisterSupportedTints() - m := New(false, &common.Styles{ + m := New(false, true, &common.Styles{ Tint: common.Themes.Current(), Colors: common.Colors{}, }) @@ -62,7 +64,7 @@ Date: Mon Jan 1 00:00:00 2026 +0000 Merge branch 'feature' into main` common.RegisterSupportedTints() - m := New(false, &common.Styles{ + m := New(false, true, &common.Styles{ Tint: common.Themes.Current(), Colors: common.Colors{}, }) @@ -78,3 +80,130 @@ Date: Mon Jan 1 00:00:00 2026 +0000 } } } + +func TestMakeDeltaArgs_WrapLines(t *testing.T) { + common.RegisterSupportedTints() + styles := common.MakeStyles() + + wrapped := New(false, true, &styles) + if args := wrapped.makeDeltaArgs(false, 80, deltaOpts{}); !slices.Contains( + args, + "--wrap-max-lines=unlimited", + ) { + t.Errorf("expected wrapping on, got %v", args) + } + + unwrapped := New(false, false, &styles) + args := unwrapped.makeDeltaArgs(false, 80, deltaOpts{}) + if !slices.Contains(args, "--wrap-max-lines=0") { + t.Errorf("expected wrapping off, got %v", args) + } + // the full line must survive so the viewport can scroll it horizontally + if !slices.Contains(args, "--max-line-length=0") { + t.Errorf("expected no truncation, got %v", args) + } +} + +func TestRenderSideBySide_RequiresWrap(t *testing.T) { + common.RegisterSupportedTints() + styles := common.MakeStyles() + + if m := New(true, false, &styles); m.renderSideBySide() { + t.Error("side-by-side must not render when wrapping is off") + } + if m := New(true, true, &styles); !m.renderSideBySide() { + t.Error("expected side-by-side when wrapping is on") + } +} + +func TestCacheKey_SeparatesWrapMode(t *testing.T) { + wrapOn := cacheKey("file.txt", false, true) + wrapOff := cacheKey("file.txt", false, false) + if wrapOn == wrapOff { + t.Errorf("expected distinct cache keys, got %q for both modes", wrapOn) + } +} + +func TestRootDiffStats_SurvivesWrapToggle(t *testing.T) { + common.RegisterSupportedTints() + styles := common.MakeStyles() + + files, _, err := gitdiff.Parse(strings.NewReader( + "diff --git a/test.txt b/test.txt\n" + + "index 1111111..2222222 100644\n" + + "--- a/test.txt\n" + + "+++ b/test.txt\n" + + "@@ -1,2 +1,2 @@\n" + + "-short\n" + + "-short2\n" + + "+long line\n" + + "+long line2\n", + )) + if err != nil { + t.Fatal(err) + } + + m := New(true, true, &styles) + m, _ = m.SetDirPatch("/", files) + m, _ = m.SetFilePatch(files[0]) + + if additions, deletions := m.RootDiffStats(); additions != 2 || deletions != 2 { + t.Errorf("expected +2/-2 root stats, got +%d/-%d", additions, deletions) + } + + _ = m.SetWrapLines(false) + if additions, deletions := m.RootDiffStats(); additions != 2 || deletions != 2 { + t.Errorf("expected stats to survive wrap toggle, got +%d/-%d", additions, deletions) + } +} + +func TestSetWrapLines_TogglesViewportWrap(t *testing.T) { + common.RegisterSupportedTints() + styles := common.MakeStyles() + + m := New(false, true, &styles) + _ = m.SetWrapLines(false) + if m.fvp.GetWrapText() { + t.Error("expected viewport wrapping off after SetWrapLines(false)") + } + _ = m.SetWrapLines(true) + if !m.fvp.GetWrapText() { + t.Error("expected viewport wrapping on after SetWrapLines(true)") + } +} + +func TestUpdate_IgnoresStaleDiffContentMsg(t *testing.T) { + common.RegisterSupportedTints() + styles := common.MakeStyles() + + files, _, err := gitdiff.Parse(strings.NewReader( + "diff --git a/test.txt b/test.txt\n" + + "index 1111111..2222222 100644\n" + + "--- a/test.txt\n" + + "+++ b/test.txt\n" + + "@@ -1 +1 @@\n" + + "-short\n" + + "+long line\n", + )) + if err != nil { + t.Fatal(err) + } + + m := New(true, true, &styles) + m, _ = m.SetFilePatch(files[0]) + + m.fvp.SetWidth(40) + m.fvp.SetHeight(10) + + fresh := stringToDiffLines("fresh content") + m, _ = m.Update(diffContentMsg{cacheKey: m.currentCacheKey(), lines: fresh}) + if !strings.Contains(m.fvp.View(), "fresh content") { + t.Error("expected current-key render to be shown") + } + + stale := stringToDiffLines("stale render that must be dropped") + m, _ = m.Update(diffContentMsg{cacheKey: "other:node", lines: stale}) + if strings.Contains(m.fvp.View(), "stale render") { + t.Error("stale render must not replace the displayed content") + } +} diff --git a/pkg/ui/tui.go b/pkg/ui/tui.go index 0930af1..3644d48 100644 --- a/pkg/ui/tui.go +++ b/pkg/ui/tui.go @@ -90,6 +90,7 @@ type mainModel struct { draggingSidebar bool iconStyle string sideBySide bool + wrapLines bool help help.Model helpOpen bool messageOpen bool @@ -134,6 +135,7 @@ func New(opts ModelOpts, cfg config.Config) mainModel { config: cfg, iconStyle: cfg.UI.Icons, sideBySide: cfg.UI.SideBySide, + wrapLines: cfg.UI.WrapLines, watchEnabled: cfg.Watch.Enabled, watchCmd: cfg.Watch.Cmd, watchInterval: cfg.Watch.Interval, @@ -142,7 +144,7 @@ func New(opts ModelOpts, cfg config.Config) mainModel { } m.fileTree = filetree.New(cfg, &m.styles) m.fileTree.SetSize(cfg.UI.FileTreeWidth, 0) - m.diffViewer = diffviewer.New(cfg.UI.SideBySide, &m.styles) + m.diffViewer = diffviewer.New(cfg.UI.SideBySide, cfg.UI.WrapLines, &m.styles) m.themePicker = themepicker.New(&m.styles) m.help = help.New() m.help.SetKeys(KeyGroups()) @@ -361,8 +363,12 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cycleIconStyle() case key.Matches(msg, keys.ToggleDiffView): m.sideBySide = !m.sideBySide - cmd = m.diffViewer.SetSideBySide(m.sideBySide) - cmds = append(cmds, cmd) + cmds = append(cmds, m.diffViewer.SetSideBySide(m.sideBySide)) + if m.sideBySide && !m.wrapLines { + // side-by-side needs wrapping; delta truncates overflow otherwise + m.wrapLines = true + cmds = append(cmds, m.diffViewer.SetWrapLines(true)) + } case key.Matches(msg, keys.SwitchPanel): if m.isShowingFileTree { if m.activePanel == FileTreePanel { @@ -414,13 +420,14 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.diffViewer.ScrollTop() } case key.Matches(msg, keys.ScrollLeft): - if m.activePanel != FileTreePanel { - m.diffViewer.ScrollLeft(1) - } + // the file tree binds h/l for expand/collapse, so the arrows are + // free to pan the diff from either panel + m.diffViewer.ScrollLeft(m.hScrollStep()) case key.Matches(msg, keys.ScrollRight): - if m.activePanel != FileTreePanel { - m.diffViewer.ScrollRight(1) - } + m.diffViewer.ScrollRight(m.hScrollStep()) + case key.Matches(msg, keys.ToggleWrapLines): + m.wrapLines = !m.wrapLines + cmds = append(cmds, m.diffViewer.SetWrapLines(m.wrapLines)) case key.Matches(msg, keys.Copy): cmd = m.fileTree.CopyCurrNodePath() if cmd != nil { @@ -1110,6 +1117,11 @@ func (m mainModel) resultsView() string { return sb.String() } +// hScrollStep is how many columns the diff view pans per horizontal scroll keypress. +func (m mainModel) hScrollStep() int { + return max(1, (m.width-m.sidebarWidth())/4) +} + func (m mainModel) sidebarWidth() int { if m.searchingFiles { return m.config.UI.SearchTreeWidth @@ -1245,7 +1257,10 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } // Handle scroll wheel first. - if msg.Mouse().Button == tea.MouseWheelUp || msg.Mouse().Button == tea.MouseWheelDown { + if button := msg.Mouse().Button; button == tea.MouseWheelUp || + button == tea.MouseWheelDown || + button == tea.MouseWheelLeft || + button == tea.MouseWheelRight { return m.handleScroll(msg) } @@ -1376,16 +1391,18 @@ func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { lines := scrollLines + button := msg.Mouse().Button // Check if scrolling in sidebar (file tree or search results). if zone.Get(zoneFileTree).InBounds(msg) || zone.Get(zoneSearchResults).InBounds(msg) { - if msg.Mouse().Button == tea.MouseWheelUp { + switch button { + case tea.MouseWheelUp: if m.searchingFiles { m.resultsVp.ScrollUp(lines) } else { m.fileTree.ScrollUp(lines) } - } else { + case tea.MouseWheelDown: if m.searchingFiles { m.resultsVp.ScrollDown(lines) } else { @@ -1397,10 +1414,15 @@ func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Check if scrolling in diff viewer. if zone.Get(zoneDiffViewer).InBounds(msg) { - if msg.Mouse().Button == tea.MouseWheelUp { + switch button { + case tea.MouseWheelUp: m.diffViewer.ScrollUp(lines) - } else { + case tea.MouseWheelDown: m.diffViewer.ScrollDown(lines) + case tea.MouseWheelLeft: + m.diffViewer.ScrollLeft(lines) + case tea.MouseWheelRight: + m.diffViewer.ScrollRight(lines) } } return m, nil