diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a059f..6430254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 responsive instead of recomputing the whole tree on every frame. The cache refreshes whenever the selected layer, filter, sort, or collapse state changes. Output is unchanged; only the redundant per-frame work is removed. +- The file viewer now splits a file's contents into lines once when the file is + opened instead of re-splitting the whole body on every keystroke and every + frame. Scrolling and incremental search through large files (minified JSON, + logs, long single-line files) no longer allocate megabytes of throwaway + strings per redraw. While the viewer is open, the file-tree pipeline is no + longer run for a status bar that does not use it. Rendered output and line + counts are unchanged. +- The header's gradient-coloured image reference is now rendered once when the + TUI starts instead of being recomputed on every frame. The image reference and + theme gradient are fixed for the session, so the per-character colour + interpolation was redundant work on each redraw. Output is unchanged; only the + repeated per-frame computation is removed. ## [v1.6.0] - 2026-07-28 diff --git a/tui/fileview.go b/tui/fileview.go index 0013983..4be0f35 100644 --- a/tui/fileview.go +++ b/tui/fileview.go @@ -12,6 +12,10 @@ import ( type viewerParams struct { content *image.FileContent + // lines is the plain-text split of content.Data, computed once on file open + // and cached on the model. The renderer reads it instead of re-splitting the + // body every frame. nil is tolerated (falls back to a single empty line). + lines []string offset int hOffset int cursorCol int @@ -73,7 +77,7 @@ func renderFileView(p viewerParams) string { return renderPanel(p.theme, body, title, true, contentWidth, p.height, false, false) } - lines := splitFileLines(p.content.Data) + lines := p.lines if lines == nil { lines = []string{""} } diff --git a/tui/fileview_test.go b/tui/fileview_test.go index 471ef83..5e65a07 100644 --- a/tui/fileview_test.go +++ b/tui/fileview_test.go @@ -34,6 +34,7 @@ func TestRenderFileViewSyntaxHighlighting(t *testing.T) { Data: src, Size: 13, }, + lines: splitFileLines(src), offset: 0, width: 80, height: 10, @@ -51,6 +52,7 @@ func TestRenderFileView_ScrolledDoesNotExceedWidth(t *testing.T) { Data: data, Size: int64(len(data)), }, + lines: splitFileLines(data), offset: 36, width: 120, height: 30, @@ -75,6 +77,7 @@ func TestRenderFileViewSearchDisablesSyntaxHighlighting(t *testing.T) { Data: src, Size: 13, }, + lines: splitFileLines(src), offset: 0, width: 80, height: 10, @@ -96,6 +99,7 @@ func TestRenderFileView_TitleTruncation_WideChar(t *testing.T) { Data: []byte("a\n"), Size: 2, }, + lines: splitFileLines([]byte("a\n")), originLayer: 1, originCmd: wideCmd, currentLayer: 2, @@ -139,6 +143,7 @@ func TestRenderFileView_TrailingNewlineLineCount(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "x.txt", Data: data, Size: int64(len(data))}, + lines: splitFileLines(data), offset: 0, width: 80, height: 20, @@ -193,6 +198,7 @@ func TestRenderFileView_HOffset_ShowsLeftMarker(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "/long.txt", Data: data, Size: int64(len(data))}, + lines: splitFileLines(data), offset: 0, hOffset: 80, width: 80, @@ -207,6 +213,7 @@ func TestRenderFileView_HOffsetZero_NoLeftMarker(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "/x.txt", Data: data, Size: int64(len(data))}, + lines: splitFileLines(data), offset: 0, hOffset: 0, width: 80, @@ -222,6 +229,7 @@ func TestRenderFileView_HOffset_StillRespectsWidth(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "/x.txt", Data: data, Size: int64(len(data))}, + lines: splitFileLines(data), offset: 0, hOffset: 50, width: 80, @@ -242,6 +250,7 @@ func TestRenderFileView_LongLineMatchVisibleAfterScroll(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "/long.txt", Data: data, Size: int64(len(data))}, + lines: splitFileLines(data), offset: 0, hOffset: 170, // chosen so column 200 falls within an 80-col view width: 80, @@ -263,6 +272,7 @@ func TestRenderFileView_HOffset_PreservesChromaOutput(t *testing.T) { body := renderFileView(viewerParams{ theme: CatppuccinMocha(), content: &image.FileContent{Path: "app.go", Data: src, Size: int64(len(src))}, + lines: splitFileLines(src), offset: 0, hOffset: 50, width: 80, diff --git a/tui/model.go b/tui/model.go index 90f2b05..88a734f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -245,6 +245,12 @@ type model struct { viewState viewState viewContent *image.FileContent viewHighlightedLines []string + // viewLines is the plain-text split of viewContent.Data, computed once when + // the file opens. The viewer's hot paths (scroll clamp, cursor-column bound, + // search indexing, render) read this instead of re-running splitFileLines — + // which copies the whole body and allocates per line — on every keystroke + // and every frame. nil when no file is open; parallels viewHighlightedLines. + viewLines []string viewOffset int viewHOffset int viewCursorCol int @@ -272,6 +278,11 @@ type model struct { noCache bool theme Theme transparentBg bool + // renderedImageRef is the gradient-coloured image ref for the header, + // precomputed once in NewModel. Both inputs (imageRef, theme gradient + // stops) are immutable for the session, so renderHeader must not recompute + // the per-rune colour interpolation on every frame. + renderedImageRef string // collapsedGen is bumped whenever a collapse map is mutated; it is the // invalidation key for the displayTreeFor cache (see treeCache). @@ -319,6 +330,7 @@ func themeFor(name string) Theme { func NewModel(cfg Config) model { ch := make(chan image.ProgressEvent, 16) ctx, cancel := context.WithCancel(context.Background()) + theme := themeFor(cfg.Theme) return model{ state: stateLoading, imageRef: cfg.ImageRef, @@ -329,9 +341,10 @@ func NewModel(cfg Config) model { statFile: os.Lstat, keys: defaultKeys(), noCache: cfg.NoCache, - theme: themeFor(cfg.Theme), + theme: theme, transparentBg: cfg.TransparentBg, treeCache: &treeCache{}, + renderedImageRef: renderGradient(cfg.ImageRef, theme.GradientStart, theme.GradientEnd), fetchCtx: ctx, fetchCancel: cancel, } @@ -497,6 +510,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewState = viewReady m.viewContent = msg.content m.viewHighlightedLines = nil + m.viewLines = splitFileLines(msg.content.Data) m.viewOffset = 0 m.viewHOffset = 0 m.viewCursorCol = 0 @@ -586,6 +600,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewState = viewNone m.viewContent = nil m.viewHighlightedLines = nil + m.viewLines = nil m.viewOffset = 0 m.viewHOffset = 0 m.viewCursorCol = 0 @@ -707,7 +722,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewHOffset = 0 m.viewCursorCol = 0 case key.Matches(msg, m.keys.Bottom): - maxOffset := max(fileViewLineCount(m.viewContent)-m.viewVisibleHeight(), 0) + maxOffset := max(m.viewLineCount()-m.viewVisibleHeight(), 0) m.viewOffset = maxOffset m.viewHOffset = 0 m.viewCursorCol = 0 @@ -1012,7 +1027,7 @@ func (m *model) recomputeViewerMatches() { return } query := strings.ToLower(m.viewSearchQuery) - lines := splitFileLines(m.viewContent.Data) + lines := m.viewLines for lineIdx, line := range lines { lower := strings.ToLower(line) offset := 0 @@ -1042,8 +1057,7 @@ func (m *model) scrollToViewerMatch() { targetLine := m.viewSearchMatches[m.viewSearchCursor][0] targetCol := m.viewSearchMatches[m.viewSearchCursor][1] visHeight := m.viewVisibleHeight() - lines := splitFileLines(m.viewContent.Data) - totalLines := len(lines) + totalLines := len(m.viewLines) desired := max(targetLine-visHeight/2, 0) maxOffset := max(totalLines-visHeight, 0) if desired > maxOffset { @@ -1056,7 +1070,7 @@ func (m *model) scrollToViewerMatch() { // is grapheme-aware and matches the renderer's truncate metric. displayCol := targetCol if targetLine < totalLines { - runes := []rune(lines[targetLine]) + runes := []rune(m.viewLines[targetLine]) if targetCol <= len(runes) { displayCol = ansi.StringWidth(string(runes[:targetCol])) } @@ -1087,7 +1101,7 @@ func (m *model) viewVisibleWidth() int { if m.viewContent == nil { return 0 } - return m.viewVisibleWidthFor(fileViewLineCount(m.viewContent)) + return m.viewVisibleWidthFor(m.viewLineCount()) } // viewVisibleWidthFor is the totalLines-cached form, used inside @@ -1848,7 +1862,13 @@ func (m model) viewReady() tea.View { // header(1) + panel borders(2) + commandBar(3) + separator(1) + statusBar(1) = 8 panelHeight := m.height - chromeRows header := m.renderHeader() - treeFiles := m.displayTree() + // The status bar only consumes treeFiles in its non-viewer branch; when the + // viewer is open renderStatusBar returns early via renderViewerStatusBar and + // never reads it. Skip the tree pipeline entirely in that case. + var treeFiles []*image.FileNode + if m.viewState == viewNone { + treeFiles = m.displayTree() + } left := renderLayers(m.theme, m.layers(), m.layerCursor, m.layerOffset, leftWidth, panelHeight, m.focus == focusLayers, m.sizeMode, m.finalLiveSize()) right := m.renderRightPanel(rightWidth, panelHeight) @@ -1857,6 +1877,7 @@ func (m model) viewReady() tea.View { if m.viewState != viewNone { viewer := renderFileView(viewerParams{ content: m.viewContent, + lines: m.viewLines, offset: m.viewOffset, hOffset: m.viewHOffset, cursorCol: m.viewCursorCol, @@ -1925,8 +1946,7 @@ func (m model) renderHeader() string { glyph := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("◆") brand := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Bold(true).Render(" layerx") sep := lipgloss.NewStyle().Foreground(m.theme.HeaderSep).Background(m.theme.StatusBg).Render(" │ ") - imageName := renderGradient(m.imageRef, m.theme.GradientStart, m.theme.GradientEnd) - imageName = lipgloss.NewStyle().Background(m.theme.StatusBg).Render(imageName) + imageName := lipgloss.NewStyle().Background(m.theme.StatusBg).Render(m.renderedImageRef) left := glyph + brand + sep + imageName // Append the active platform after the image name when --platform is // set. Multi-platform images otherwise give no visual cue which variant @@ -2098,7 +2118,7 @@ func (m model) renderViewerStatusBar() string { matchStyle := lipgloss.NewStyle().Foreground(m.theme.SearchCurrentBg).Background(m.theme.StatusBg).Bold(true) right = matchStyle.Render(fmt.Sprintf("Match %d/%d ", m.viewSearchCursor+1, len(m.viewSearchMatches))) } else if m.viewContent != nil && !m.viewContent.Binary && len(m.viewContent.Data) > 0 { - total := fileViewLineCount(m.viewContent) + total := m.viewLineCount() line := m.viewOffset + 1 pct := 0 if total > 0 { @@ -2286,8 +2306,22 @@ func atomicWriteFile(name string, data []byte, perm os.FileMode) error { } +// viewLineCount returns the viewer's rendered line count from the cached split +// in m.viewLines, preserving fileViewLineCount's contract: non-empty text whose +// only content is a trailing newline counts as one line. Reads the cache so +// scroll clamping does not re-split the file body on every keystroke. +func (m *model) viewLineCount() int { + if m.viewContent == nil || m.viewContent.Binary || len(m.viewContent.Data) == 0 { + return 0 + } + if len(m.viewLines) == 0 { + return 1 + } + return len(m.viewLines) +} + func (m *model) scrollViewDown() { - maxOffset := max(fileViewLineCount(m.viewContent)-m.viewVisibleHeight(), 0) + maxOffset := max(m.viewLineCount()-m.viewVisibleHeight(), 0) if m.viewOffset < maxOffset { m.viewOffset++ } @@ -2338,7 +2372,7 @@ func (m *model) viewMaxCursorCol() int { if m.viewContent == nil { return 0 } - lines := splitFileLines(m.viewContent.Data) + lines := m.viewLines if len(lines) == 0 { return 0 } diff --git a/tui/model_test.go b/tui/model_test.go index edf00d6..a84e95c 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -39,6 +39,15 @@ func viewContent(v tea.View) string { return v.Content } +// openViewer sets the viewer content and its derived line-split cache together, +// mirroring what the fileContentMsg handler does in production. Tests that set +// m.viewContent directly must keep m.viewLines in sync, or the viewer's cached +// hot paths (scroll clamp, search indexing, render) see an empty file. +func openViewer(m *model, fc *image.FileContent) { + m.viewContent = fc + m.viewLines = splitFileLines(fc.Data) +} + // --- test fixtures ----------------------------------------------------------- func testAnalysis() *image.Analysis { @@ -1053,11 +1062,11 @@ func TestFilterCtrlCStillQuits(t *testing.T) { func TestViewerSearchSwallowsQWhenActive(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("jquery and graphql"), Size: 18, - } + }) m.viewSearchActive = true m = send(m, keyPress('j')) @@ -1069,11 +1078,11 @@ func TestViewerSearchSwallowsQWhenActive(t *testing.T) { func TestViewerSearchCtrlCStillQuits(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("hello"), Size: 5, - } + }) m.viewSearchActive = true m = send(m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) @@ -1350,7 +1359,7 @@ func TestEnterOnRemovedFileShowsStatusMsg(t *testing.T) { func TestEscClosesFileViewer(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/test", Data: []byte("hi")} + openViewer(&m, &image.FileContent{Path: "/test", Data: []byte("hi")}) updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) um := updated.(model) @@ -1365,7 +1374,7 @@ func TestEscClosesFileViewer(t *testing.T) { func TestEscMashOnFileViewerDoesNotQuit(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/test", Data: []byte("hi")} + openViewer(&m, &image.FileContent{Path: "/test", Data: []byte("hi")}) // First Esc: closes viewer. m = send(m, keyPressSpecial(tea.KeyEscape)) @@ -1380,11 +1389,11 @@ func TestEscMashOnFileViewerDoesNotQuit(t *testing.T) { func TestViewerScrollDown(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte(strings.Repeat("line\n", 100)), Size: 500, - } + }) m.viewOffset = 0 m.height = 30 @@ -1396,11 +1405,11 @@ func TestViewerScrollDown(t *testing.T) { func TestViewerScrollUpAtTopStays(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("line1\nline2\n"), Size: 12, - } + }) m.viewOffset = 0 updated, _ := m.Update(keyPress('k')) @@ -1444,7 +1453,7 @@ func TestFileContentMsgPopulatesHighlightCache(t *testing.T) { func TestEscClearsHighlightCache(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "main.go", Data: []byte("package main\n")} + openViewer(&m, &image.FileContent{Path: "main.go", Data: []byte("package main\n")}) m.viewHighlightedLines = []string{"package main"} updated, _ := m.Update(keyPressSpecial(tea.KeyEscape)) @@ -1465,7 +1474,7 @@ func TestFileContentMsgErrorClearsViewState(t *testing.T) { func TestViewerBlocksNavigationKeys(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/test", Data: []byte("hi")} + openViewer(&m, &image.FileContent{Path: "/test", Data: []byte("hi")}) m.focus = focusLayers cursorBefore := m.layerCursor @@ -1502,6 +1511,65 @@ func TestEfficiencyBadgeInStatusBar(t *testing.T) { assert.Contains(t, content, "wasted") } +// With the viewer open, viewReady must skip the file-tree pipeline (whose +// result the viewer status bar never consumes) yet still render a correct +// viewer status bar. Guards the PERF optimisation that passes a nil treeFiles +// through renderStatusBar's early viewer branch. +func TestViewerOpen_RendersViewerStatusBar(t *testing.T) { + m := setupModel() + m.width = 80 + m.height = 30 + m.efficiency = &image.EfficiencyResult{Score: 0.85, WastedBytes: 1500000} + m.viewState = viewReady + openViewer(&m, &image.FileContent{ + Path: "/etc/hosts", + Data: []byte("line1\nline2\nline3\n"), + Size: 18, + }) + + content := viewContent(m.View()) + // Viewer status bar hints and the line counter are present… + assert.Contains(t, content, "search", "viewer status bar must render while the viewer is open") + assert.Contains(t, content, "Line 1/3", "viewer status bar must show the line counter") + // …and the normal (non-viewer) tree status bar is not. + assert.NotContains(t, content, "Eff:", "efficiency badge belongs to the non-viewer status bar") +} + +// viewLineCount reads the cached m.viewLines that scroll clamping and the +// status-bar line counter depend on. It must stay identical to the direct +// fileViewLineCount reference for every input, including the trailing-newline +// terminator and binary cases — a drift here off-by-ones the scroll limit and +// the "Line n/N" counter. Asserting equivalence keeps the cached path honest +// even though fileViewLineCount itself is no longer on a hot path. +func TestViewLineCount_MatchesFileViewLineCount(t *testing.T) { + tests := []struct { + name string + content *image.FileContent + want int + }{ + {"trailing newline (terminator)", &image.FileContent{Data: []byte("a\nb\n")}, 2}, + {"no trailing newline", &image.FileContent{Data: []byte("a\nb")}, 2}, + {"single newline", &image.FileContent{Data: []byte("\n")}, 1}, + {"single line", &image.FileContent{Data: []byte("hello")}, 1}, + {"empty", &image.FileContent{Data: []byte{}}, 0}, + {"binary", &image.FileContent{Data: []byte("a\nb\n"), Binary: true}, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := setupModel() + openViewer(&m, tc.content) + assert.Equal(t, tc.want, m.viewLineCount()) + assert.Equal(t, fileViewLineCount(tc.content), m.viewLineCount(), + "cached viewLineCount must match the direct fileViewLineCount reference") + }) + } +} + +func TestViewLineCount_NilContent(t *testing.T) { + m := setupModel() + assert.Equal(t, 0, m.viewLineCount(), "no file open must report zero lines") +} + // --- File Extraction to Disk (M10) ------------------------------------------- func TestExtractKeyOnDirectoryShowsStatus(t *testing.T) { @@ -1703,11 +1771,11 @@ func TestCopyPathYKeyInLayersPanelIsNoop(t *testing.T) { func TestCopyContentShiftYInViewer(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/etc/passwd", Data: []byte("root:x:0:0"), Size: 10, - } + }) updated, cmd := m.Update(keyPress('Y')) um := updated.(model) @@ -1733,11 +1801,11 @@ func TestCopyContentShiftYInLayerPanelIsNoOp(t *testing.T) { func TestViewerSearchActivatesOnSlash(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/etc/passwd", Data: []byte("root:x:0:0\nnobody:x:65534:65534"), Size: 30, - } + }) updated, _ := m.Update(keyPress('/')) um := updated.(model) @@ -1747,11 +1815,11 @@ func TestViewerSearchActivatesOnSlash(t *testing.T) { func TestViewerSearchTypingBuildsQuery(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/etc/passwd", Data: []byte("root:x:0:0\nnobody:x:65534:65534"), Size: 30, - } + }) m.viewSearchActive = true updated, _ := m.Update(tea.KeyPressMsg{Text: "r"}) @@ -1767,11 +1835,11 @@ func TestViewerSearchTypingBuildsQuery(t *testing.T) { func TestViewerSearchEnterConfirms(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("hello world"), Size: 11, - } + }) m.viewSearchActive = true m.viewSearchQuery = "hello" @@ -1784,11 +1852,11 @@ func TestViewerSearchEnterConfirms(t *testing.T) { func TestViewerSearchEscClearsQuery(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("hello world"), Size: 11, - } + }) m.viewSearchActive = true m.viewSearchQuery = "hello" m.viewSearchMatches = [][2]int{{0, 0}} @@ -1803,11 +1871,11 @@ func TestViewerSearchEscClearsQuery(t *testing.T) { func TestViewerSearchNextPrevMatch(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("aaa\naaa\naaa"), Size: 11, - } + }) m.viewSearchQuery = "aaa" m.viewSearchMatches = [][2]int{{0, 0}, {1, 0}, {2, 0}} m.viewSearchCursor = 0 @@ -1832,11 +1900,11 @@ func TestViewerSearchNextPrevMatch(t *testing.T) { func TestViewerScrollStillWorksWithoutSearch(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte(strings.Repeat("line\n", 100)), Size: 500, - } + }) m.viewOffset = 0 updated, _ := m.Update(keyPress('j')) @@ -1847,11 +1915,11 @@ func TestViewerScrollStillWorksWithoutSearch(t *testing.T) { func TestViewerEscCascadeWithSearch(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "/test", Data: []byte("test"), Size: 4, - } + }) m.viewSearchActive = true m.viewSearchQuery = "test" @@ -1876,11 +1944,11 @@ func TestMouseWheelDownScrollsViewer(t *testing.T) { lines = append(lines, fmt.Sprintf("line%d", i)) } data := []byte(strings.Join(lines, "\n") + "\n") - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "test.txt", Data: data, Size: int64(len(data)), - } + }) m.height = 20 m = send(m, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) @@ -1890,11 +1958,11 @@ func TestMouseWheelDownScrollsViewer(t *testing.T) { func TestMouseWheelUpScrollsViewer(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{ + openViewer(&m, &image.FileContent{ Path: "test.txt", Data: []byte("line1\nline2\nline3\n"), Size: 18, - } + }) m.viewOffset = 1 m.height = 20 @@ -2074,7 +2142,7 @@ func TestScrollToViewerMatch_AdjustsHOffsetForOffScreenMatch(t *testing.T) { prefix := strings.Repeat("x", 200) data := []byte(prefix + "needle and rest of the line") m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/long.txt", Data: data, Size: int64(len(data))} + openViewer(&m, &image.FileContent{Path: "/long.txt", Data: data, Size: int64(len(data))}) m.viewSearchQuery = "needle" m.recomputeViewerMatches() @@ -2097,7 +2165,7 @@ func TestScrollToViewerMatch_LeavesHOffsetWhenMatchAlreadyVisible(t *testing.T) // Short line with the match well within the viewport. data := []byte("hello needle world") m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/short.txt", Data: data, Size: int64(len(data))} + openViewer(&m, &image.FileContent{Path: "/short.txt", Data: data, Size: int64(len(data))}) m.viewSearchQuery = "needle" m.recomputeViewerMatches() @@ -2110,7 +2178,7 @@ func TestScrollToViewerMatch_LeavesHOffsetWhenMatchAlreadyVisible(t *testing.T) // first match falls in the un-shifted region of the line. func TestRecomputeViewerMatches_ResetsHOffsetOnEmptyQuery(t *testing.T) { m := setupModel() - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("abc"), Size: 3} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("abc"), Size: 3}) m.viewHOffset = 999 m.viewSearchQuery = "" @@ -2124,7 +2192,7 @@ func TestRecomputeViewerMatches_ResetsHOffsetOnEmptyQuery(t *testing.T) { func TestViewerEsc_ClearsHOffsetWithSearch(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("hello world"), Size: 11} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("hello world"), Size: 11}) m.viewSearchQuery = "world" m.viewHOffset = 50 @@ -2144,7 +2212,7 @@ func TestViewerHKey_MovesCursorLeft(t *testing.T) { // past the start so a leftward step on the cursor at the left edge // drags the viewport but no further than column 0. long := strings.Repeat("x", 200) - m.viewContent = &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))}) m.viewHOffset = 50 m.viewCursorCol = 50 // at the visible left edge @@ -2162,7 +2230,7 @@ func TestViewerLKey_KeepsViewportStable(t *testing.T) { m := setupModel() m.viewState = viewReady long := strings.Repeat("x", 200) - m.viewContent = &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))}) m.viewHOffset = 0 m.viewCursorCol = 0 @@ -2178,7 +2246,7 @@ func TestViewerLKey_ScrollsViewportAtRightEdge(t *testing.T) { m := setupModel() m.viewState = viewReady long := strings.Repeat("x", 500) - m.viewContent = &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte(long), Size: int64(len(long))}) visWidth := m.viewVisibleWidth() require.Greater(t, visWidth, 0) // Park the cursor at the rightmost visible column. Next l takes it @@ -2196,7 +2264,7 @@ func TestViewerLKey_ScrollsViewportAtRightEdge(t *testing.T) { func TestViewerHKey_ClampsAtZero(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("line"), Size: 4} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("line"), Size: 4}) m.viewHOffset = 0 um := send(m, keyPress('h')) @@ -2208,7 +2276,7 @@ func TestViewerHKey_ClampsAtZero(t *testing.T) { func TestViewerGTop_ResetsHOffset(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("a\nb\nc\n"), Size: 6} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("a\nb\nc\n"), Size: 6}) m.viewOffset = 2 m.viewHOffset = 50 @@ -2220,7 +2288,7 @@ func TestViewerGTop_ResetsHOffset(t *testing.T) { func TestViewerGBottom_ResetsHOffset(t *testing.T) { m := setupModel() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("a\nb\nc\n"), Size: 6} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("a\nb\nc\n"), Size: 6}) m.viewHOffset = 50 um := send(m, keyPress('G')) @@ -2285,7 +2353,7 @@ func TestModel_AggregateToggle_RoutesCurrentTreeRoot(t *testing.T) { func TestModel_AggregateToggle_NoOpWhenViewerOpen(t *testing.T) { m := setupModelWithDiffs() m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("data"), Size: 4} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("data"), Size: 4}) m = send(m, keyPress('A')) diff --git a/tui/waste_test.go b/tui/waste_test.go index 1f342a0..ab60e71 100644 --- a/tui/waste_test.go +++ b/tui/waste_test.go @@ -382,7 +382,7 @@ func TestWasteWGuard(t *testing.T) { m = setupModel() m.efficiency = efficiencyOf(3) m.viewState = viewReady - m.viewContent = &image.FileContent{Path: "/x", Data: []byte("hi"), Size: 2} + openViewer(&m, &image.FileContent{Path: "/x", Data: []byte("hi"), Size: 2}) updated, _ = m.Update(keyPress('w')) um = updated.(model) assert.False(t, um.showWaste, "w should not open while viewer is up")