From 0bbef4c12fb935b056867e355f7bc8ef57c62f94 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Sun, 2 Aug 2026 10:33:40 +0530 Subject: [PATCH 1/4] perf(tui): render the header gradient once instead of every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's gradient-coloured image reference was recomputed on every View() call — per-rune float colour interpolation plus a lipgloss.Style allocation per character — even though both inputs (the image reference and the theme gradient stops) are fixed for the session. bubbletea renders after every Update, so this ran on every keypress, scroll, and 10 Hz spinner tick for a result that never changes. Compute it once in NewModel as renderedImageRef and have renderHeader read the cached string. Output is unchanged; only the redundant per-frame work is removed. --- CHANGELOG.md | 6 ++++++ tui/model.go | 12 +++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a059f..d00c50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed ### Changed - The interactive file tree now caches its flatten/filter/sort result between redraws, so holding a navigation key or scrolling a large image's tree stays 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 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/model.go b/tui/model.go index 90f2b05..4e92821 100644 --- a/tui/model.go +++ b/tui/model.go @@ -272,6 +272,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 +324,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 +335,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, } @@ -1925,8 +1932,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 From 8cfc2e9abb837f584a26e3f32a030850ed506811 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Mon, 3 Aug 2026 20:52:58 +0530 Subject: [PATCH 2/4] perf(tui): split file-viewer content into lines once per open The file viewer re-ran splitFileLines on every keystroke and every frame: it copied the whole file body into a string, ran two ReplaceAll passes, then Split. On a large file that is megabytes of throwaway allocation per redraw across six call sites (render, scroll clamp, cursor-column bound, search indexing). Split once when the file opens, cache the result on the model as viewLines, and read the cache from every hot path. A new viewLineCount method preserves the single-trailing-newline-counts-as-one contract that fileViewLineCount defined. Rendered output and line counts are unchanged. --- CHANGELOG.md | 6 ++- tui/fileview.go | 6 ++- tui/fileview_test.go | 10 +++++ tui/model.go | 40 ++++++++++++++----- tui/model_test.go | 95 ++++++++++++++++++++++++-------------------- tui/waste_test.go | 2 +- 6 files changed, 104 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c50f..c0b8db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed ### Changed - The interactive file tree now caches its flatten/filter/sort result between redraws, so holding a navigation key or scrolling a large image's tree stays 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. 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 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 4e92821..6a131c2 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 @@ -504,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 @@ -593,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 @@ -714,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 @@ -1019,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 @@ -1049,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 { @@ -1063,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])) } @@ -1094,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 @@ -1864,6 +1871,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, @@ -2104,7 +2112,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 { @@ -2292,8 +2300,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++ } @@ -2344,7 +2366,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..d8601a3 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 @@ -1703,11 +1712,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 +1742,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 +1756,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 +1776,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 +1793,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 +1812,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 +1841,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 +1856,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 +1885,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 +1899,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 +2083,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 +2106,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 +2119,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 +2133,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 +2153,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 +2171,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 +2187,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 +2205,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 +2217,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 +2229,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 +2294,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") From b9d133d1967079f857bae8511c5724765a87be8d Mon Sep 17 00:00:00 2001 From: deveshctl Date: Mon, 3 Aug 2026 21:03:05 +0530 Subject: [PATCH 3/4] perf(tui): skip the file-tree pipeline while the file viewer is open viewReady always computed the flatten/filter/sort tree result to pass to renderStatusBar, but renderStatusBar returns early via renderViewerStatusBar when the viewer is open and never reads it. Guard the displayTree() call on viewState so the pipeline does not run for a value that is discarded. Added a regression test that renders View() with the viewer open and asserts the viewer status bar (not the tree/efficiency bar) is shown. Rendered output is unchanged. --- CHANGELOG.md | 4 +++- tui/model.go | 8 +++++++- tui/model_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b8db9..6430254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. Rendered output and line counts are unchanged. + 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 diff --git a/tui/model.go b/tui/model.go index 6a131c2..88a734f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -1862,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) diff --git a/tui/model_test.go b/tui/model_test.go index d8601a3..608234d 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -1511,6 +1511,30 @@ 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") +} + // --- File Extraction to Disk (M10) ------------------------------------------- func TestExtractKeyOnDirectoryShowsStatus(t *testing.T) { From 4345098a9e152b083d12f0959fac8b2805e48c4f Mon Sep 17 00:00:00 2001 From: deveshctl Date: Mon, 3 Aug 2026 21:25:16 +0530 Subject: [PATCH 4/4] test(tui): cover viewLineCount and pin it to the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit viewLineCount reads the cached m.viewLines that scroll clamping and the status-bar line counter rely on, but only the direct fileViewLineCount reference had test coverage — the cached runtime path could drift from the reference (e.g. losing the binary guard or the trailing-newline terminator rule) without any test catching it. Add a table test that asserts viewLineCount matches fileViewLineCount for every input, including the binary case the reference table omitted, plus a nil-content case. Test-only change; no production behaviour affected. --- tui/model_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tui/model_test.go b/tui/model_test.go index 608234d..a84e95c 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -1535,6 +1535,41 @@ func TestViewerOpen_RendersViewerStatusBar(t *testing.T) { 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) {