diff --git a/internal/tui/export_test.go b/internal/tui/export_test.go index 49b057f59..bd4f0ae0d 100644 --- a/internal/tui/export_test.go +++ b/internal/tui/export_test.go @@ -144,6 +144,15 @@ func (c *staticRenderCache) stats() renderCacheStats { return c.statsData } +func fileViewCacheStatsForTest() fileViewCacheStats { + return defaultFileViewCache.stats() +} + +func resetFileViewCacheForTest() { + defaultFileViewCache.clear() + defaultFileViewCache.resetStats() +} + func renderSelectableList(options selectableListOptions) string { if len(options.Items) == 0 { return "" diff --git a/internal/tui/file_view.go b/internal/tui/file_view.go index 7e544d70b..a40b56bc4 100644 --- a/internal/tui/file_view.go +++ b/internal/tui/file_view.go @@ -15,30 +15,792 @@ package tui import ( "bufio" + "container/list" + "errors" "fmt" + "io" "os" "path/filepath" + "sort" "strings" + "sync" + "sync/atomic" + "time" + + tea "charm.land/bubbletea/v2" ) // fileViewMaxLines caps the full-file mode so a giant generated file can't -// freeze a render; the tail collapses into a "… N more lines" trailer. -const fileViewMaxLines = 4000 +// freeze a render; the tail collapses into a "… more lines (file truncated at N for display)" trailer. +const ( + fileViewMaxLines = 4000 + fileViewMaxBytes = 1 << 20 // 1 MiB total read budget per file + fileViewMaxLineBytes = 4096 // 4 KiB max line length budget + defaultFileViewCacheMaxEntries = 64 + fileViewMaxRenderVariants = 4 // max rendered variants (width/fingerprint) per cached file entry + fileViewLoadingPlaceholder = "Loading…" +) const ( fileViewDiff = iota fileViewFull ) +var ( + fileViewLifetimeTS atomic.Uint64 + fileViewLifetimeSeq atomic.Uint32 +) + +// nextFileViewLifetimeToken produces a monotonic, time-ordered 128-bit UUIDv7 (RFC 9562) +// to uniquely identify the lifecycle of a file view session without heap allocations. +func nextFileViewLifetimeToken() [16]byte { + nowMs := uint64(time.Now().UnixMilli()) + for { + last := fileViewLifetimeTS.Load() + if nowMs > last { + if fileViewLifetimeTS.CompareAndSwap(last, nowMs) { + fileViewLifetimeSeq.Store(0) + break + } + } else { + nowMs = last + break + } + } + seq := fileViewLifetimeSeq.Add(1) + var u [16]byte + u[0] = byte(nowMs >> 40) + u[1] = byte(nowMs >> 32) + u[2] = byte(nowMs >> 24) + u[3] = byte(nowMs >> 16) + u[4] = byte(nowMs >> 8) + u[5] = byte(nowMs) + u[6] = 0x70 | byte((seq>>8)&0x0F) // Version 7 + u[7] = byte(seq & 0xFF) + u[8] = 0x80 | byte((seq>>16)&0x3F) // RFC 9562 variant + u[9] = byte(seq >> 24) + return u +} + +// fileViewCacheStats tracks disk I/O, Chroma highlighting, and cache hits/misses. +type fileViewCacheStats struct { + DiskReads int + HighlightCalls int + CacheHits int + CacheMisses int + Evictions int + ThemeClears int + RenderEvictions int +} + +type fileViewCachedEntry struct { + targetPath string + displayPath string + modTime time.Time + size int64 + sourceRev uint64 + lines []string + display []string + truncated bool + omittedLines bool + + rendersMu sync.RWMutex + renderKeys []string // LRU order: oldest at index 0, most recent at end + renders map[string]string // key: "width:changedLinesFingerprint" -> formatted ANSI string +} + +func (e *fileViewCachedEntry) getRender(key string) (string, bool) { + e.rendersMu.RLock() + val, ok := e.renders[key] + e.rendersMu.RUnlock() + if !ok { + return "", false + } + e.rendersMu.Lock() + for i, k := range e.renderKeys { + if k == key { + e.renderKeys = append(append(e.renderKeys[:i], e.renderKeys[i+1:]...), key) + break + } + } + e.rendersMu.Unlock() + return val, true +} + +func (e *fileViewCachedEntry) putRender(key string, val string) { + e.rendersMu.Lock() + defer e.rendersMu.Unlock() + if e.renders == nil { + e.renders = make(map[string]string) + } + if _, ok := e.renders[key]; !ok { + for len(e.renders) >= fileViewMaxRenderVariants { + if len(e.renderKeys) > 0 { + oldKey := e.renderKeys[0] + e.renderKeys = e.renderKeys[1:] + delete(e.renders, oldKey) + } else { + for k := range e.renders { + delete(e.renders, k) + break + } + } + } + e.renderKeys = append(e.renderKeys, key) + } + e.renders[key] = val +} + +type fileViewRenderCache struct { + mu sync.Mutex + maxEntries int + items map[string]*list.Element // targetPath -> *list.Element containing *fileViewCachedEntry + lru *list.List + gen int + pathRevisions map[string]uint64 + statsData fileViewCacheStats +} + +var defaultFileViewCache = newFileViewRenderCache(defaultFileViewCacheMaxEntries) + +func newFileViewRenderCache(maxEntries int) *fileViewRenderCache { + return &fileViewRenderCache{ + maxEntries: maxEntries, + items: make(map[string]*list.Element), + lru: list.New(), + pathRevisions: make(map[string]uint64), + } +} + +func (c *fileViewRenderCache) invalidatePath(targetPath string) uint64 { + c.mu.Lock() + defer c.mu.Unlock() + c.pathRevisions[targetPath]++ + rev := c.pathRevisions[targetPath] + if elem, ok := c.items[targetPath]; ok { + c.lru.Remove(elem) + delete(c.items, targetPath) + } + return rev +} + +func (c *fileViewRenderCache) requiredRevision(targetPath string) uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.pathRevisions[targetPath] +} + +func (c *fileViewRenderCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.gen++ + c.items = make(map[string]*list.Element) + c.lru.Init() + for k := range c.pathRevisions { + c.pathRevisions[k]++ + } + c.statsData.ThemeClears++ +} + +func (c *fileViewRenderCache) generation() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.gen +} + +func (c *fileViewRenderCache) resetStats() { + c.mu.Lock() + defer c.mu.Unlock() + c.statsData = fileViewCacheStats{} +} + +func (c *fileViewRenderCache) stats() fileViewCacheStats { + c.mu.Lock() + defer c.mu.Unlock() + return c.statsData +} + +type fileViewReadResult struct { + lines []string + truncated bool + omittedLines bool + err error +} + +type fileViewByteCounter struct { + r io.Reader + n int +} + +func (c *fileViewByteCounter) Read(p []byte) (int, error) { + got, err := c.r.Read(p) + c.n += got + return got, err +} + +func (c *fileViewByteCounter) delivered(buf *bufio.Reader) int { + n := c.n - buf.Buffered() + if n < 0 { + return 0 + } + return n +} + +func stripFileViewLineEnding(b []byte) []byte { + if n := len(b); n > 0 && b[n-1] == '\n' { + b = b[:n-1] + } + if n := len(b); n > 0 && b[n-1] == '\r' { + b = b[:n-1] + } + return b +} + +func readFileViewBounded(path string, maxLines int, maxLineBytes int, maxTotalBytes int) fileViewReadResult { + file, err := os.Open(path) + if err != nil { + return fileViewReadResult{err: err} + } + defer file.Close() + + var lines []string + truncated := false + omittedLines := false + counter := &fileViewByteCounter{r: io.LimitReader(file, int64(maxTotalBytes)+1)} + reader := bufio.NewReader(counter) + + moreSource := func() bool { + if reader.Buffered() > 0 { + return true + } + _, err := reader.Peek(1) + return err == nil + } + + for len(lines) < maxLines { + start := counter.delivered(reader) + if start >= maxTotalBytes { + if moreSource() { + truncated = true + omittedLines = true + } + break + } + + var raw []byte + for { + frag, err := reader.ReadSlice('\n') + raw = append(raw, frag...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if err != nil && !errors.Is(err, io.EOF) { + if len(lines) == 0 && len(raw) == 0 { + return fileViewReadResult{err: err} + } + truncated = true + omittedLines = true + break + } + break + } + + if len(raw) == 0 { + break + } + + consumed := counter.delivered(reader) + budget := maxTotalBytes - start + if budget < 0 { + budget = 0 + } + kept := raw + if len(kept) > budget { + kept = kept[:budget] + truncated = true + omittedLines = true + } + display := stripFileViewLineEnding(kept) + lineTruncated := false + if len(display) > maxLineBytes { + display = display[:maxLineBytes] + lineTruncated = true + truncated = true + } + if consumed > maxTotalBytes { + truncated = true + omittedLines = true + } + if lineTruncated { + truncated = true + } + lines = append(lines, string(display)) + if consumed >= maxTotalBytes { + if moreSource() { + truncated = true + omittedLines = true + } + break + } + if len(raw) > 0 && raw[len(raw)-1] != '\n' && !moreSource() { + break + } + } + + if !omittedLines && len(lines) >= maxLines && moreSource() { + truncated = true + omittedLines = true + } + + return fileViewReadResult{ + lines: lines, + truncated: truncated, + omittedLines: omittedLines, + } +} + +// canonicalChangedLineKey normalizes a line for changed-line matching: +// - Replaces tabs with 4 spaces (matching sanitizeRawFileLine) +// - Strips ANSI escape sequences and non-printable control characters +// - Trims leading and trailing whitespace +func canonicalChangedLineKey(s string) string { + var out strings.Builder + for _, r := range s { + if r == '\t' { + out.WriteString(" ") + } else if r == '\r' || r == '\n' { + continue + } else if r < 32 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + if r == '\x1b' { + out.WriteString("^[") + } + } else { + out.WriteRune(r) + } + } + return strings.TrimSpace(out.String()) +} + +func changedLinesFingerprint(changed map[string]bool) string { + if len(changed) == 0 { + return "" + } + keys := make([]string, 0, len(changed)) + for k, v := range changed { + if v { + keys = append(keys, k) + } + } + sort.Strings(keys) + return strings.Join(keys, "\x00") +} + +func formatFileViewLines(lines []string, display []string, changed map[string]bool, truncated bool, omittedLines bool, width int, theme tuiTheme) string { + gutterW := len(fmt.Sprintf("%d", len(lines))) + textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column + + var b strings.Builder + for i, line := range display { + line = fitStyledLine(line, textBudget) + if i > 0 { + b.WriteString("\n") + } + marker := " " + if changed != nil && len(lines) > i && changed[canonicalChangedLineKey(lines[i])] { + marker = theme.accent.Render("▎") + } + b.WriteString(theme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1))) + b.WriteString(marker) + b.WriteString(line) + } + if truncated { + // No exact remaining-line count: computing one would require reading the + // rest of the file, defeating the bounded read above. + b.WriteString("\n") + if omittedLines { + b.WriteString(theme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines)))) + } else { + b.WriteString(theme.faint.Render("… (line content truncated at display limit)")) + } + } + return b.String() +} + +// peekRenderOnly looks up an already-formatted variant in memory. It performs +// strictly 0 I/O and 0 string formatting/allocations, guaranteeing O(1) instantaneous +// access on the View() drawing path. +func (c *fileViewRenderCache) peekRenderOnly(targetPath string, width int, changedFingerprint string, loadedSeq, desiredSeq uint64, loadedRev, reqRev uint64) (string, bool) { + if loadedSeq != desiredSeq || loadedRev < reqRev { + return "", false + } + c.mu.Lock() + elem, ok := c.items[targetPath] + if !ok { + c.mu.Unlock() + return "", false + } + entry := elem.Value.(*fileViewCachedEntry) + if entry.sourceRev < reqRev { + c.mu.Unlock() + return "", false + } + c.lru.MoveToFront(elem) + c.statsData.CacheHits++ + c.mu.Unlock() + + renderKey := fmt.Sprintf("%d:%s", width, changedFingerprint) + return entry.getRender(renderKey) +} + +// loadAndRender performs the bounded read, Chroma highlighting, and formatting +// on a cache miss (or re-formats for a new width variant on a cache hit). It is +// intended to be executed from a tea.Cmd / background worker, off the View path. +var errFileViewSuperseded = errors.New("file view request superseded") + +var ( + fileViewInsideLoad func() + fileViewBeforeCacheHitFormat func() + fileViewBeforeDiskRead func() + fileViewBeforeHighlight func() + fileViewBeforeFormat func() + fileViewBeforeCacheCommit func() +) + +func fileViewSuperseded(liveSeq *atomic.Uint64, seq uint64) bool { + return liveSeq != nil && liveSeq.Load() != seq +} + +func (c *fileViewRenderCache) loadAndRender(targetPath string, displayPath string, width int, changed map[string]bool, changedFingerprint string, reqGen int, theme tuiTheme, reqSourceRev uint64, liveSeq *atomic.Uint64, seq uint64) (string, error) { + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + if fileViewInsideLoad != nil { + fileViewInsideLoad() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + stat, err := os.Stat(targetPath) + if err != nil { + c.mu.Lock() + if elem, ok := c.items[targetPath]; ok { + c.lru.Remove(elem) + delete(c.items, targetPath) + } + c.mu.Unlock() + rendered := theme.faint.Render("Could not read file: " + err.Error()) + return rendered, err + } + + modTime := stat.ModTime() + size := stat.Size() + renderKey := fmt.Sprintf("%d:%s", width, changedFingerprint) + + c.mu.Lock() + if c.gen != reqGen { + c.mu.Unlock() + return "", errors.New("request superseded by cache invalidation") + } + + curRequiredRev := c.pathRevisions[targetPath] + if reqSourceRev < curRequiredRev { + reqSourceRev = curRequiredRev + } + + if elem, ok := c.items[targetPath]; ok { + entry := elem.Value.(*fileViewCachedEntry) + forceReload := (entry.sourceRev < reqSourceRev) + refreshSource := forceReload + if !refreshSource && entry.modTime.Equal(modTime) && entry.size == size && entry.displayPath == displayPath { + if fileViewSuperseded(liveSeq, seq) { + c.mu.Unlock() + return "", errFileViewSuperseded + } + c.statsData.CacheHits++ + c.lru.MoveToFront(elem) + c.mu.Unlock() + + if rendered, ok := entry.getRender(renderKey); ok { + return rendered, nil + } + + if fileViewBeforeCacheHitFormat != nil { + fileViewBeforeCacheHitFormat() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + + // Re-format for the new width or changed markers using cached display and lines + rendered := formatFileViewLines(entry.lines, entry.display, changed, entry.truncated, entry.omittedLines, width, theme) + if fileViewBeforeCacheCommit != nil { + fileViewBeforeCacheCommit() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + entry.putRender(renderKey, rendered) + return rendered, nil + } + } + + if fileViewSuperseded(liveSeq, seq) { + c.mu.Unlock() + return "", errFileViewSuperseded + } + + c.statsData.CacheMisses++ + c.statsData.DiskReads++ + c.mu.Unlock() + + if fileViewBeforeDiskRead != nil { + fileViewBeforeDiskRead() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + + readRes := readFileViewBounded(targetPath, fileViewMaxLines, fileViewMaxLineBytes, fileViewMaxBytes) + if readRes.err != nil && len(readRes.lines) == 0 { + c.mu.Lock() + if elem, ok := c.items[targetPath]; ok { + c.lru.Remove(elem) + delete(c.items, targetPath) + } + c.mu.Unlock() + rendered := theme.faint.Render("Could not read file: " + readRes.err.Error()) + return rendered, readRes.err + } + + if fileViewBeforeHighlight != nil { + fileViewBeforeHighlight() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + + c.mu.Lock() + c.statsData.HighlightCalls++ + c.mu.Unlock() + + cleanLines := make([]string, len(readRes.lines)) + for i, l := range readRes.lines { + cleanLines[i] = sanitizeRawFileLine(l) + } + + display, ok := highlightCodeForPathWithTheme(cleanLines, displayPath, 1<<20, nil, theme) + if !ok || len(display) != len(cleanLines) { + display = cleanLines + } + + if fileViewBeforeFormat != nil { + fileViewBeforeFormat() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + + rendered := formatFileViewLines(cleanLines, display, changed, readRes.truncated, readRes.omittedLines, width, theme) + + if fileViewBeforeCacheCommit != nil { + fileViewBeforeCacheCommit() + } + if fileViewSuperseded(liveSeq, seq) { + return "", errFileViewSuperseded + } + + entry := &fileViewCachedEntry{ + targetPath: targetPath, + displayPath: displayPath, + modTime: modTime, + size: size, + sourceRev: reqSourceRev, + lines: cleanLines, + display: display, + truncated: readRes.truncated, + omittedLines: readRes.omittedLines, + renderKeys: []string{renderKey}, + renders: map[string]string{renderKey: rendered}, + } + + c.mu.Lock() + if c.gen != reqGen { + c.mu.Unlock() + return "", errors.New("request superseded by cache invalidation") + } + if fileViewSuperseded(liveSeq, seq) { + c.mu.Unlock() + return "", errFileViewSuperseded + } + + if elem, ok := c.items[targetPath]; ok { + existing := elem.Value.(*fileViewCachedEntry) + if existing.modTime.After(modTime) && existing.sourceRev > reqSourceRev { + c.mu.Unlock() + return rendered, nil + } + c.lru.Remove(elem) + delete(c.items, targetPath) + } + elem := c.lru.PushFront(entry) + c.items[targetPath] = elem + + for len(c.items) > c.maxEntries { + back := c.lru.Back() + if back == nil { + break + } + backEntry := back.Value.(*fileViewCachedEntry) + delete(c.items, backEntry.targetPath) + c.lru.Remove(back) + c.statsData.Evictions++ + } + c.mu.Unlock() + + return rendered, nil +} + +// sanitizeRawFileLine strips or transforms raw control characters and terminal escape sequences +// when syntax highlighting is bypassed or unavailable, preventing terminal screen corruption. +func sanitizeRawFileLine(s string) string { + var out strings.Builder + for _, r := range s { + if r == '\t' { + out.WriteString(" ") + } else if r == '\r' || r == '\n' { + continue + } else if r < 32 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + if r == '\x1b' { + out.WriteString("^[") + } + } else { + out.WriteRune(r) + } + } + return out.String() +} + +func (c *fileViewRenderCache) getOrRender(targetPath string, displayPath string, width int, changed map[string]bool) string { + fingerprint := changedLinesFingerprint(changed) + rendered, _ := c.loadAndRender(targetPath, displayPath, width, changed, fingerprint, c.generation(), zeroTheme, 0, nil, 0) + return rendered +} + +// fileViewLoadedMsg delivers the result of an asynchronous file read & render. +type fileViewLoadedMsg struct { + lifetimeToken [16]byte + seq uint64 + requiredSourceRev uint64 + generation int + targetPath string + displayPath string + width int + fingerprint string + rendered string + err error +} + +func loadFileViewCmd(targetPath string, displayPath string, width int, changed map[string]bool, fingerprint string, token [16]byte, seq uint64, gen int, reqRev uint64, theme tuiTheme, liveSeq *atomic.Uint64) tea.Cmd { + return func() tea.Msg { + if fileViewSuperseded(liveSeq, seq) { + return fileViewLoadedMsg{lifetimeToken: token, seq: seq, requiredSourceRev: reqRev, generation: gen, targetPath: targetPath, displayPath: displayPath, width: width, fingerprint: fingerprint, err: errFileViewSuperseded} + } + rendered, err := defaultFileViewCache.loadAndRender(targetPath, displayPath, width, changed, fingerprint, gen, theme, reqRev, liveSeq, seq) + return fileViewLoadedMsg{ + lifetimeToken: token, + seq: seq, + requiredSourceRev: reqRev, + generation: gen, + targetPath: targetPath, + displayPath: displayPath, + width: width, + fingerprint: fingerprint, + rendered: rendered, + err: err, + } + } +} + // fileViewState manages the drill-in view for a touched file. When active, the // transcript body swaps to the file's diff/content instead of the chat rows. type fileViewState struct { - active bool - path string // workspace-relative, as carried by changedFiles - mode int // fileViewDiff | fileViewFull - // parentScrollOffset preserves the chat scroll position so closing the view - // returns to the same spot (mirrors subchatState). + active bool + path string // workspace-relative, as carried by changedFiles + mode int // fileViewDiff | fileViewFull parentScrollOffset int + + // View session lifetime identity (UUIDv7 RFC 9562 0-alloc) + lifetimeToken [16]byte + + // Monotonically advancing desired snapshot sequence & requested parameters + desiredSeq uint64 + requiredSourceRev uint64 + desiredWidth int + desiredFingerprint string + desiredGen int + + // Authoritative completed snapshot (only valid when loadedSeq == desiredSeq) + renderedContent string + loadedPath string + loadedWidth int + loadedGen int + loadedFingerprint string + loadedToken [16]byte + loadedSeq uint64 + loadedRev uint64 + loading bool + hasError bool + snapshotReady bool + liveSeq *atomic.Uint64 +} + +func (m model) startFileViewLoadCmd(width int) (model, tea.Cmd) { + return m.startFileViewLoad(width, false) +} + +func (m model) startFileViewRefreshCmd(width int) (model, tea.Cmd) { + return m.startFileViewLoad(width, true) +} + +func (m model) startFileViewLoad(width int, refreshSource bool) (model, tea.Cmd) { + if !m.fileView.active || m.fileView.mode != fileViewFull || m.fileView.path == "" { + return m, nil + } + target := m.fileView.path + if !filepath.IsAbs(target) { + target = filepath.Join(m.cwd, target) + } + if refreshSource { + rev := defaultFileViewCache.invalidatePath(target) + if rev > m.fileView.requiredSourceRev { + m.fileView.requiredSourceRev = rev + } else { + m.fileView.requiredSourceRev++ + } + } + curReq := defaultFileViewCache.requiredRevision(target) + if curReq > m.fileView.requiredSourceRev { + m.fileView.requiredSourceRev = curReq + } + + m.fileView.desiredSeq++ + m.fileView.desiredWidth = width + m.fileView.loading = true + m.fileView.snapshotReady = false + if m.fileView.liveSeq == nil { + m.fileView.liveSeq = new(atomic.Uint64) + } + seq := m.fileView.desiredSeq + m.fileView.liveSeq.Store(seq) + token := m.fileView.lifetimeToken + gen := defaultFileViewCache.generation() + changed := m.fileViewChangedLines() + fingerprint := changedLinesFingerprint(changed) + m.fileView.desiredFingerprint = fingerprint + m.fileView.desiredGen = gen + reqRev := m.fileView.requiredSourceRev + theme := zeroTheme + return m, loadFileViewCmd(target, m.fileView.path, width, changed, fingerprint, token, seq, gen, reqRev, theme, m.fileView.liveSeq) } // openFileView activates the drill-in for path in diff mode. Opening from an @@ -47,16 +809,36 @@ type fileViewState struct { // Re-opening the file that is ALREADY being viewed is a no-op: a stray // re-click must not bounce the user from full mode back to diff or reset // their scroll position. -func (m model) openFileView(path string) model { +func (m model) revokeFileViewRequest() { + if m.fileView.liveSeq != nil { + m.fileView.liveSeq.Add(1) + } +} + +func (m model) openFileView(path string) (model, tea.Cmd) { if m.fileView.active && m.fileView.path == path { - return m + return m, nil + } + if m.fileView.active { + m.revokeFileViewRequest() } if !m.fileView.active { m.fileView.parentScrollOffset = m.chatScrollOffset } + target := path + if !filepath.IsAbs(target) { + target = filepath.Join(m.cwd, target) + } m.fileView.active = true m.fileView.path = path m.fileView.mode = fileViewDiff + m.fileView.lifetimeToken = nextFileViewLifetimeToken() + m.fileView.renderedContent = "" + m.fileView.loadedToken = [16]byte{} + m.fileView.loadedSeq = 0 + m.fileView.loadedRev = 0 + m.fileView.requiredSourceRev = defaultFileViewCache.requiredRevision(target) + m.fileView.hasError = false // A file only the git sweep knows about (bash/subagent mutation) has no edit // cards to stack — open straight on the full file instead of a placeholder. if len(m.fileViewResultRows()) == 0 { @@ -64,7 +846,10 @@ func (m model) openFileView(path string) model { } m.chatScrollOffset = 0 m = m.clearHover() // bodyY numbering differs between the file body and the chat - return m + if m.fileView.mode == fileViewFull { + return m.startFileViewLoadCmd(m.chatColumnWidth()) + } + return m, nil } // exitFileView deactivates the view and restores the chat scroll position. @@ -72,6 +857,7 @@ func (m model) exitFileView() model { if !m.fileView.active { return m } + m.revokeFileViewRequest() m.chatScrollOffset = m.fileView.parentScrollOffset m.fileView = fileViewState{} m = m.clearHover() @@ -80,13 +866,62 @@ func (m model) exitFileView() model { // setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored // start since the two bodies have unrelated heights. -func (m model) setFileViewMode(mode int) model { +func (m model) setFileViewMode(mode int) (model, tea.Cmd) { if !m.fileView.active || m.fileView.mode == mode { - return m + return m, nil + } + if m.fileView.mode == fileViewFull && mode != fileViewFull { + m.revokeFileViewRequest() } m.fileView.mode = mode m.chatScrollOffset = 0 - return m + if mode == fileViewFull { + return m.startFileViewLoadCmd(m.chatColumnWidth()) + } + return m, nil +} + +func (m model) handleFileViewLoaded(msg fileViewLoadedMsg) (model, tea.Cmd) { + if !m.fileView.active || m.fileView.mode != fileViewFull { + return m, nil + } + if errors.Is(msg.err, errFileViewSuperseded) { + return m, nil + } + // 1. Session lifetime identity match + if m.fileView.lifetimeToken != msg.lifetimeToken || m.fileView.path != msg.displayPath { + return m, nil + } + // 2. Exact desired snapshot match: reject superseded / out-of-order completions without any side effects + if msg.seq != m.fileView.desiredSeq || + msg.width != m.fileView.desiredWidth || + msg.fingerprint != m.fileView.desiredFingerprint { + // Out-of-order or obsolete sequence: drop without modifying state or triggering retries + return m, nil + } + + // 3. Current sequence with invalid theme generation: request a retry inheriting current source revision requirement + if msg.generation != defaultFileViewCache.generation() { + return m.startFileViewLoadCmd(m.chatColumnWidth()) + } + + // 4. Source revision monotonic check: if required revision was advanced while in flight, request a refresh + if msg.requiredSourceRev < m.fileView.requiredSourceRev { + return m.startFileViewRefreshCmd(m.chatColumnWidth()) + } + + m.fileView.loading = false + m.fileView.snapshotReady = true + m.fileView.renderedContent = msg.rendered + m.fileView.loadedPath = msg.displayPath + m.fileView.loadedWidth = msg.width + m.fileView.loadedGen = msg.generation + m.fileView.loadedFingerprint = msg.fingerprint + m.fileView.loadedToken = msg.lifetimeToken + m.fileView.loadedSeq = msg.seq + m.fileView.loadedRev = msg.requiredSourceRev + m.fileView.hasError = (msg.err != nil) + return m, nil } // fileViewNavBar renders the single-line header shown in place of the pinned @@ -165,69 +1000,27 @@ func (m model) renderFileViewDiff(width int) string { // highlighted, with a line-number gutter and an accent ▎ marker on the lines // this session's diffs added (matched by exact text — an approximation that // tolerates later drift; a stale marker just doesn't highlight). +// It is strictly non-blocking and performs O(1) lookup without invoking formatters. func (m model) renderFileViewFull(width int) string { + if m.fileView.hasError { + return m.fileView.renderedContent + } target := m.fileView.path if !filepath.IsAbs(target) { target = filepath.Join(m.cwd, target) } - // Stream the read and stop at the cap: os.ReadFile would load a multi-GB - // file wholesale before any truncation, which is the exact render freeze - // fileViewMaxLines exists to prevent. - file, err := os.Open(target) - if err != nil { - return zeroTheme.faint.Render("Could not read file: " + err.Error()) - } - defer file.Close() - var lines []string - truncated := false - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for scanner.Scan() { - if len(lines) == fileViewMaxLines { - truncated = true - break - } - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - if len(lines) == 0 { - return zeroTheme.faint.Render("Could not read file: " + err.Error()) - } - truncated = true // e.g. a single over-long line mid-file: show what we have - } - - changed := m.fileViewChangedLines() - gutterW := len(fmt.Sprintf("%d", len(lines))) - textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column - // Highlight with an effectively-infinite measure so the highlighter never - // wraps — output lines stay 1:1 with file lines and the gutter numbering - // can't desync. Each line is then truncated to the column budget below. - display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil) - if !ok || len(display) != len(lines) { - display = lines // no lexer for this path: render plain - } - - var b strings.Builder - for i, line := range display { - line = fitStyledLine(line, textBudget) - if i > 0 { - b.WriteString("\n") - } - marker := " " - if changed[strings.TrimSpace(lines[i])] { - marker = zeroTheme.accent.Render("▎") - } - b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1))) - b.WriteString(marker) - b.WriteString(line) + if cached, ok := defaultFileViewCache.peekRenderOnly(target, width, m.fileView.desiredFingerprint, m.fileView.loadedSeq, m.fileView.desiredSeq, m.fileView.loadedRev, m.fileView.requiredSourceRev); ok { + return cached } - if truncated { - // No exact remaining-line count: computing one would require reading the - // rest of the file, defeating the bounded read above. - b.WriteString("\n") - b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines)))) + if m.fileView.snapshotReady && + m.fileView.loadedPath == m.fileView.path && + m.fileView.loadedSeq == m.fileView.desiredSeq && + m.fileView.loadedRev >= m.fileView.requiredSourceRev && + m.fileView.loadedGen == defaultFileViewCache.generation() && + m.fileView.loadedToken == m.fileView.lifetimeToken { + return m.fileView.renderedContent } - return b.String() + return zeroTheme.faint.Render(fileViewLoadingPlaceholder) } // fileViewChangedLines collects the trimmed text of every line the session's @@ -242,8 +1035,9 @@ func (m model) fileViewChangedLines() map[string]bool { if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") { continue } - if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); len(text) >= 4 { - changed[text] = true + raw := strings.TrimPrefix(line, "+") + if key := canonicalChangedLineKey(raw); len(key) >= 4 { + changed[key] = true } } } diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go index dc39a9991..9d5af712c 100644 --- a/internal/tui/file_view_test.go +++ b/internal/tui/file_view_test.go @@ -2,12 +2,16 @@ package tui import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" "strconv" "strings" + "sync" + "sync/atomic" "testing" + "time" tea "charm.land/bubbletea/v2" @@ -16,6 +20,26 @@ import ( "github.com/Gitlawb/zero/internal/tools" ) +func testOpenFile(m model, path string) model { + next, cmd := m.openFileView(path) + if cmd != nil { + msg := cmd() + updated, _ := next.Update(msg) + return updated.(model) + } + return next +} + +func testSetMode(m model, mode int) model { + next, cmd := m.setFileViewMode(mode) + if cmd != nil { + msg := cmd() + updated, _ := next.Update(msg) + return updated.(model) + } + return next +} + // TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position, // resets it for the file body, and Esc restores it; switching files while open // keeps the ORIGINAL saved position (not the file view's own). @@ -23,7 +47,7 @@ func TestFileViewOpenExitRestoresScroll(t *testing.T) { m := filesPanelTestModel() m.chatScrollOffset = 12 - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") if !m.fileView.active || m.fileView.mode != fileViewDiff { t.Fatalf("open should activate in diff mode: %+v", m.fileView) } @@ -32,7 +56,7 @@ func TestFileViewOpenExitRestoresScroll(t *testing.T) { } m.chatScrollOffset = 5 // scrolled within the file body - m = m.openFileView("internal/tui/sidebar.go") + m, _ = m.openFileView("internal/tui/sidebar.go") if m.fileView.parentScrollOffset != 12 { t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset) } @@ -47,15 +71,23 @@ func TestFileViewOpenExitRestoresScroll(t *testing.T) { // d/f switch modes while the composer is empty and never while typing. func TestFileViewEscAndModeKeys(t *testing.T) { m := filesPanelTestModel() - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") - updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + updated, cmd := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) m = updated.(model) + if cmd != nil { + updated, _ = m.Update(cmd()) + m = updated.(model) + } if m.fileView.mode != fileViewFull { t.Fatal("f should switch to full mode") } - updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + updated, cmd = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) m = updated.(model) + if cmd != nil { + updated, _ = m.Update(cmd()) + m = updated.(model) + } if m.fileView.mode != fileViewDiff { t.Fatal("d should switch back to diff mode") } @@ -81,7 +113,7 @@ func TestFileViewEscAndModeKeys(t *testing.T) { // placeholder. func TestFileViewDiffBody(t *testing.T) { m := filesPanelTestModel() - m = m.openFileView("internal/tui/sidebar.go") + m, _ = m.openFileView("internal/tui/sidebar.go") body := plainRender(t, m.renderFileViewDiff(78)) if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") { t.Fatalf("expected chronological edit labels:\n%s", body) @@ -111,8 +143,8 @@ func TestFileViewFullBody(t *testing.T) { detail: "+let a = 1", changedFiles: []string{"app.js"}, }) - m = m.openFileView("app.js") - m = m.setFileViewMode(fileViewFull) + m = testOpenFile(m, "app.js") + m = testSetMode(m, fileViewFull) body := m.renderFileViewFull(78) plain := plainRender(t, body) @@ -131,6 +163,11 @@ func TestFileViewFullBody(t *testing.T) { } m.fileView.path = "gone.js" + m, cmd := m.startFileViewLoadCmd(78) + if cmd != nil { + updated, _ := m.Update(cmd()) + m = updated.(model) + } if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") { t.Errorf("missing file should degrade to an error line, got:\n%s", got) } @@ -142,7 +179,7 @@ func TestFileViewFullBody(t *testing.T) { // relies on. func TestFileViewSwapsTranscriptBody(t *testing.T) { m := filesPanelTestModel() - m = m.openFileView("internal/tui/sidebar.go") + m, _ = m.openFileView("internal/tui/sidebar.go") items := m.transcriptBodyItems(m.chatColumnWidth(), "", false) if len(items) != 1 { @@ -177,7 +214,7 @@ func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) { transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1}, ) m.activeRunID = 1 - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") width := sidebarWidth(m.width) agents := m.sidebarAgentSelectables(width) @@ -246,11 +283,11 @@ func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) { // unconditional openFileView bounced full mode back to diff and reset scroll. func TestOpenFileViewSamePathIsNoOp(t *testing.T) { m := filesPanelTestModel() - m = m.openFileView("web/app.js") - m = m.setFileViewMode(fileViewFull) + m = testOpenFile(m, "web/app.js") + m = testSetMode(m, fileViewFull) m.chatScrollOffset = 7 // scrolled within the file body - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") if m.fileView.mode != fileViewFull { t.Fatal("re-opening the same file must keep full mode") } @@ -258,7 +295,7 @@ func TestOpenFileViewSamePathIsNoOp(t *testing.T) { t.Fatalf("re-opening the same file must keep the scroll, got %d", m.chatScrollOffset) } // A DIFFERENT file still switches (and resets to diff mode as documented). - m = m.openFileView("internal/tui/sidebar.go") + m, _ = m.openFileView("internal/tui/sidebar.go") if m.fileView.path != "internal/tui/sidebar.go" || m.fileView.mode != fileViewDiff { t.Fatalf("opening another file should switch views: %+v", m.fileView) } @@ -279,7 +316,7 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) { m := filesPanelTestModel() m.cwd = dir m.gitTouched = []gitSweepFile{{path: "big.txt"}} - m = m.openFileView("big.txt") + m = testOpenFile(m, "big.txt") plain := plainRender(t, m.renderFileViewFull(80)) lines := strings.Split(plain, "\n") @@ -297,7 +334,7 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) { func TestDetailedTranscriptClosesFileView(t *testing.T) { m := filesPanelTestModel() m.altScreen = true - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") if !m.fileView.active { t.Fatal("sanity check: openFileView should activate the file view") @@ -324,7 +361,7 @@ func TestDetailedTranscriptClosesFileView(t *testing.T) { func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) { m := filesPanelTestModel() m.altScreen = true - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") updated, _ := m.Update(testKeyCtrl('o')) m = updated.(model) @@ -345,7 +382,7 @@ func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) { // (Esc exiting the view instead of reaching the prompt's deny handling). func TestFileViewKeysDeferToBlockingModal(t *testing.T) { m := filesPanelTestModel() - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") m.pendingPermission = &pendingPermissionPrompt{ request: agent.PermissionRequest{ToolName: "write_file"}, decide: func(agent.PermissionDecision) {}, @@ -362,3 +399,2067 @@ func TestFileViewKeysDeferToBlockingModal(t *testing.T) { t.Fatal("Esc with a permission prompt up must not exit the file view") } } + +// TestFileViewRepeatedViewNoDiskIOOrHighlighting proves that repeated calls +// to render the full file view do not perform disk I/O or Chroma syntax +// highlighting after the initial load, and that modifying the file on disk +// properly invalidates and triggers a reload. +func TestFileViewRepeatedViewNoDiskIOOrHighlighting(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "sample.go") + content := "package main\n\nfunc main() {\n\tprintln(\"hello world\")\n}\n" + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "sample.go") + m = testSetMode(m, fileViewFull) + + // First render: Misses cache during testSetMode cmd execution, performed 1 disk read and 1 highlight call + firstRender := m.renderFileViewFull(80) + if !strings.Contains(firstRender, "hello world") { + t.Fatalf("first render missing content: %s", firstRender) + } + + statsAfterFirst := fileViewCacheStatsForTest() + if statsAfterFirst.DiskReads != 1 { + t.Fatalf("expected 1 disk read on initial view, got %d", statsAfterFirst.DiskReads) + } + if statsAfterFirst.HighlightCalls != 1 { + t.Fatalf("expected 1 highlight call on initial view, got %d", statsAfterFirst.HighlightCalls) + } + + // Repeated renders (e.g. 10 frames during typing/scrolling/resize) + for i := 0; i < 10; i++ { + rendered := m.renderFileViewFull(80) + if rendered != firstRender { + t.Fatalf("subsequent render %d mismatch", i) + } + } + + statsAfterRepeated := fileViewCacheStatsForTest() + if statsAfterRepeated.DiskReads != 1 { + t.Fatalf("repeated View calls must not trigger disk reads, got %d", statsAfterRepeated.DiskReads) + } + if statsAfterRepeated.HighlightCalls != 1 { + t.Fatalf("repeated View calls must not trigger Chroma highlighting, got %d", statsAfterRepeated.HighlightCalls) + } + if statsAfterRepeated.CacheHits != statsAfterFirst.CacheHits+10 { + t.Fatalf("expected 10 additional cache hits, got %d (before: %d)", statsAfterRepeated.CacheHits, statsAfterFirst.CacheHits) + } + + // Same byte length as `content`, so only mtime can invalidate the entry. + newContent := "package main\n\nfunc main() {\n\tprintln(\"HELLO WORLD\")\n}\n" + if len(newContent) != len(content) { + t.Fatalf("test setup: newContent length %d must match original length %d", len(newContent), len(content)) + } + if err := os.WriteFile(filePath, []byte(newContent), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Hour) + if err := os.Chtimes(filePath, future, future); err != nil { + t.Fatal(err) + } + + // Re-trigger load command after file update + m, cmd := m.startFileViewLoadCmd(80) + if cmd != nil { + updated, _ := m.Update(cmd()) + m = updated.(model) + } + + updatedRender := m.renderFileViewFull(80) + if !strings.Contains(updatedRender, "HELLO WORLD") { + t.Fatalf("expected updated content after disk mutation, got: %s", updatedRender) + } + + statsAfterUpdate := fileViewCacheStatsForTest() + if statsAfterUpdate.DiskReads != 2 { + t.Fatalf("expected 2 disk reads after file change, got %d", statsAfterUpdate.DiskReads) + } + if statsAfterUpdate.HighlightCalls != 2 { + t.Fatalf("expected 2 highlight calls after file change, got %d", statsAfterUpdate.HighlightCalls) + } +} + +// TestFileViewMaxBytesBudgetTruncation verifies that files exceeding the +// total byte budget (fileViewMaxBytes) are truncated and display the trailer. +func TestFileViewMaxBytesBudgetTruncation(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "giant_bytes.txt") + + // Generate a file with total size ~1.5 MB (> fileViewMaxBytes of 1 MiB) + line := strings.Repeat("a", 500) + "\n" + numLines := (fileViewMaxBytes / 500) + 100 + var sb strings.Builder + for i := 0; i < numLines; i++ { + sb.WriteString(line) + } + if err := os.WriteFile(filePath, []byte(sb.String()), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "giant_bytes.txt") + m = testSetMode(m, fileViewFull) + + body := m.renderFileViewFull(80) + plain := plainRender(t, body) + + if !strings.Contains(plain, "truncated") { + t.Fatalf("expected truncation trailer for file exceeding byte budget, got:\n%s", plain) + } + + renderedLines := strings.Split(plain, "\n") + if len(renderedLines) >= numLines { + t.Fatalf("rendered line count %d should be strictly less than total file lines %d", len(renderedLines), numLines) + } +} + +// TestFileViewMaxLineBytesBudgetTruncation verifies that single overlong lines +// exceeding fileViewMaxLineBytes are clamped without crashing or unbounded memory. +func TestFileViewMaxLineBytesBudgetTruncation(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "giant_line.js") + + // Generate a single overlong line of 50,000 bytes (> fileViewMaxLineBytes of 4096) + giantLine := "let data = \"" + strings.Repeat("x", 50000) + "\";\nlet next = 1;\n" + if err := os.WriteFile(filePath, []byte(giantLine), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "giant_line.js") + m = testSetMode(m, fileViewFull) + + body := m.renderFileViewFull(80) + plain := plainRender(t, body) + + if !strings.Contains(plain, "let next = 1") { + t.Fatalf("expected next line to be readable after overlong line truncation, got:\n%s", plain) + } + if !strings.Contains(plain, "truncated") { + t.Fatalf("expected truncation trailer for overlong line, got:\n%s", plain) + } +} + +// TestFileViewCacheEviction verifies that the LRU cache caps entry count to +// defaultFileViewCacheMaxEntries. +func TestFileViewCacheEviction(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + numFiles := defaultFileViewCacheMaxEntries + 10 + for i := 0; i < numFiles; i++ { + fname := fmt.Sprintf("file_%d.txt", i) + if err := os.WriteFile(filepath.Join(dir, fname), []byte(fmt.Sprintf("content %d\n", i)), 0o644); err != nil { + t.Fatal(err) + } + } + + m := filesPanelTestModel() + m.cwd = dir + for i := 0; i < numFiles; i++ { + fname := fmt.Sprintf("file_%d.txt", i) + m = testOpenFile(m, fname) + m = testSetMode(m, fileViewFull) + _ = m.renderFileViewFull(80) + } + + defaultFileViewCache.mu.Lock() + cachedCount := len(defaultFileViewCache.items) + defaultFileViewCache.mu.Unlock() + + if cachedCount > defaultFileViewCacheMaxEntries { + t.Fatalf("cache size %d exceeded maxEntries %d", cachedCount, defaultFileViewCacheMaxEntries) + } +} + +// TestFileViewClearOnThemeChange verifies that switching themes clears the +// file view cache so updated palette styles are applied. +func TestFileViewClearOnThemeChange(t *testing.T) { + defer applyTheme(themeDark, true) + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "code.go") + if err := os.WriteFile(filePath, []byte("package main\nfunc main() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "code.go") + m = testSetMode(m, fileViewFull) + _ = m.renderFileViewFull(80) + + defaultFileViewCache.mu.Lock() + entriesBefore := len(defaultFileViewCache.items) + defaultFileViewCache.mu.Unlock() + + if entriesBefore == 0 { + t.Fatal("expected cached entries before theme switch") + } + + applyTheme(themeLight, false) + + defaultFileViewCache.mu.Lock() + entriesAfter := len(defaultFileViewCache.items) + defaultFileViewCache.mu.Unlock() + + if entriesAfter != 0 { + t.Fatalf("expected cache to be cleared after theme change, got %d entries", entriesAfter) + } +} + +// TestReadFileViewBounded_GiantSingleLineStopsAtBudget verifies that reading a +// multi-megabyte physical line without newlines stops immediately at maxTotalBytes +// rather than reading through to EOF or loading the entire line into memory. +func TestReadFileViewBounded_GiantSingleLineStopsAtBudget(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "giant_single_line.txt") + + // 5 MiB single line without any newlines + totalSize := 5 * 1024 * 1024 + giantContent := strings.Repeat("A", totalSize) + if err := os.WriteFile(filePath, []byte(giantContent), 0o644); err != nil { + t.Fatal(err) + } + + maxTotalBytes := 1 << 20 // 1 MiB budget + maxLineBytes := 4096 // 4 KiB line cap + maxLines := 4000 + + res := readFileViewBounded(filePath, maxLines, maxLineBytes, maxTotalBytes) + if res.err != nil { + t.Fatalf("unexpected read error: %v", res.err) + } + + if !res.truncated { + t.Fatal("expected truncated=true when reading 5 MiB single line with 1 MiB budget") + } + + if len(res.lines) != 1 { + t.Fatalf("expected exactly 1 truncated line, got %d", len(res.lines)) + } + + if len(res.lines[0]) > maxLineBytes { + t.Fatalf("retained line length %d exceeded per-line cap %d", len(res.lines[0]), maxLineBytes) + } +} + +// TestReadFileViewBounded_ExactMaxBytesNotTruncated verifies that a file exactly +// equal in size to maxTotalBytes without extra unread bytes is read completely +// without an erroneous truncation flag or trailer. +func TestReadFileViewBounded_ExactMaxBytesNotTruncated(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "exact_budget.txt") + + maxTotalBytes := 1024 * 1024 // 1 MiB + maxLineBytes := 4096 + maxLines := 4000 + + // Construct exactly 1024 * 1024 bytes with 512 lines of 2048 bytes (2047 chars + '\n') + lineLen := 2048 + numLines := maxTotalBytes / lineLen + remainder := maxTotalBytes % lineLen + + var sb strings.Builder + for i := 0; i < numLines; i++ { + sb.WriteString(strings.Repeat("B", lineLen-1) + "\n") + } + if remainder > 0 { + sb.WriteString(strings.Repeat("C", remainder)) + } + + content := []byte(sb.String()) + if len(content) != maxTotalBytes { + t.Fatalf("generated content size %d != %d", len(content), maxTotalBytes) + } + + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatal(err) + } + + res := readFileViewBounded(filePath, maxLines, maxLineBytes, maxTotalBytes) + if res.err != nil { + t.Fatalf("unexpected read error: %v", res.err) + } + + if res.truncated { + t.Fatal("expected truncated=false for file exactly matching maxTotalBytes with no trailing data") + } + + totalReadLen := 0 + for _, l := range res.lines { + totalReadLen += len(l) + } + if totalReadLen == 0 { + t.Fatal("expected lines to be populated") + } +} + +// TestReadFileViewBounded_ExactMaxBytesUnterminatedLineTruncated verifies that a single +// unterminated physical line of exactly maxTotalBytes is correctly marked truncated=true +// when the line length exceeds maxLineBytes. +func TestReadFileViewBounded_ExactMaxBytesUnterminatedLineTruncated(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "exact_single_line_unterminated.txt") + + maxTotalBytes := 1024 * 1024 // 1 MiB + maxLineBytes := 4096 + maxLines := 4000 + + // 1 MiB continuous single line without any newlines + content := []byte(strings.Repeat("X", maxTotalBytes)) + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatal(err) + } + + res := readFileViewBounded(filePath, maxLines, maxLineBytes, maxTotalBytes) + if res.err != nil { + t.Fatalf("unexpected read error: %v", res.err) + } + + if !res.truncated { + t.Fatal("expected truncated=true for 1 MiB single unterminated line clipped at maxLineBytes") + } + + if len(res.lines) != 1 { + t.Fatalf("expected exactly 1 line, got %d", len(res.lines)) + } + if len(res.lines[0]) > maxLineBytes { + t.Fatalf("retained line length %d exceeded maxLineBytes %d", len(res.lines[0]), maxLineBytes) + } +} + +func TestReadFileViewBounded_SourceByteBudgetCountsDelimiters(t *testing.T) { + tests := []struct { + name string + content string + maxLines int + maxLineBytes int + maxTotalBytes int + wantLines []string + wantTrunc bool + wantOmitted bool + wantTrailer string + }{ + { + name: "empty file", + content: "", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 1, + wantLines: nil, + }, + { + name: "budget 1 two LF empty lines", + content: "\n\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 1, + wantLines: []string{""}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "single LF exact budget", + content: "\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 1, + wantLines: []string{""}, + }, + { + name: "LF content line exact budget", + content: "ab\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 3, + wantLines: []string{"ab"}, + }, + { + name: "LF second line one byte over", + content: "ab\ncd\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 3, + wantLines: []string{"ab"}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "CRLF two empty lines budget 2", + content: "\r\n\r\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 2, + wantLines: []string{""}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "CRLF exact budget", + content: "ab\r\n", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 4, + wantLines: []string{"ab"}, + }, + { + name: "CRLF one byte over", + content: "ab\r\ncd", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 3, + wantLines: []string{"ab"}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "exact budget unterminated last line", + content: "abc", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 3, + wantLines: []string{"abc"}, + }, + { + name: "unterminated last line one byte over", + content: "abcd", + maxLines: 4000, + maxLineBytes: 4096, + maxTotalBytes: 3, + wantLines: []string{"abc"}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "overlong physical line clipped then next line shown", + content: strings.Repeat("x", 20) + "\nnext\n", + maxLines: 4000, + maxLineBytes: 8, + maxTotalBytes: 100, + wantLines: []string{strings.Repeat("x", 8), "next"}, + wantTrunc: true, + wantTrailer: "line content truncated", + }, + { + name: "overlong physical line newline counts against byte budget", + content: strings.Repeat("x", 10) + "\ny\n", + maxLines: 4000, + maxLineBytes: 4, + maxTotalBytes: 11, + wantLines: []string{strings.Repeat("x", 4)}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "line count cap with leftover", + content: "a\nb\nc\nd\n", + maxLines: 2, + maxLineBytes: 4096, + maxTotalBytes: 100, + wantLines: []string{"a", "b"}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + { + name: "line count cap exact file", + content: "a\nb\n", + maxLines: 2, + maxLineBytes: 4096, + maxTotalBytes: 100, + wantLines: []string{"a", "b"}, + }, + { + name: "byte budget binds before line count cap on empty LF lines", + content: "\n\n\n\n", + maxLines: 10, + maxLineBytes: 4096, + maxTotalBytes: 2, + wantLines: []string{"", ""}, + wantTrunc: true, + wantOmitted: true, + wantTrailer: "more lines", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "input.txt") + if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil { + t.Fatal(err) + } + + res := readFileViewBounded(path, tc.maxLines, tc.maxLineBytes, tc.maxTotalBytes) + if res.err != nil { + t.Fatalf("unexpected read error: %v", res.err) + } + if res.truncated != tc.wantTrunc { + t.Fatalf("truncated=%v, want %v (lines=%q)", res.truncated, tc.wantTrunc, res.lines) + } + if res.omittedLines != tc.wantOmitted { + t.Fatalf("omittedLines=%v, want %v (lines=%q)", res.omittedLines, tc.wantOmitted, res.lines) + } + if len(res.lines) != len(tc.wantLines) { + t.Fatalf("retained %d lines %q, want %d %q", len(res.lines), res.lines, len(tc.wantLines), tc.wantLines) + } + for i := range tc.wantLines { + if res.lines[i] != tc.wantLines[i] { + t.Fatalf("line %d = %q, want %q", i, res.lines[i], tc.wantLines[i]) + } + } + + plain := plainRender(t, formatFileViewLines(res.lines, res.lines, nil, res.truncated, res.omittedLines, 80, zeroTheme)) + if tc.wantTrailer == "" { + if strings.Contains(plain, "truncated") { + t.Fatalf("expected no trailer, got %q", plain) + } + } else if !strings.Contains(plain, tc.wantTrailer) { + t.Fatalf("expected trailer %q in %q", tc.wantTrailer, plain) + } + }) + } +} + +// TestFileViewCache_RenderVariantsBoundedUnderResize verifies that varying width +// and changed-line fingerprints cannot grow an entry's renders map beyond +// fileViewMaxRenderVariants, even under concurrent access from multiple goroutines. +func TestFileViewCache_RenderVariantsBoundedUnderResize(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "resize_test.go") + if err := os.WriteFile(filePath, []byte("package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "resize_test.go") + m = testSetMode(m, fileViewFull) + + // Execute mixed-width getOrRender calls concurrently from multiple goroutines + var wg sync.WaitGroup + workers := 8 + callsPerWorker := 20 + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for i := 0; i < callsPerWorker; i++ { + width := 40 + ((workerID*callsPerWorker + i) % 50) + changed := map[string]bool{} + if width%2 == 0 { + changed[fmt.Sprintf("marker_%d_%d", workerID, width)] = true + } + _ = defaultFileViewCache.getOrRender(filePath, "resize_test.go", width, changed) + } + }(w) + } + wg.Wait() + + defaultFileViewCache.mu.Lock() + elem, ok := defaultFileViewCache.items[filePath] + defaultFileViewCache.mu.Unlock() + + if !ok || elem == nil { + t.Fatal("expected cached entry for file") + } + + entry := elem.Value.(*fileViewCachedEntry) + entry.rendersMu.RLock() + variantCount := len(entry.renders) + keyCount := len(entry.renderKeys) + entry.rendersMu.RUnlock() + + if variantCount > fileViewMaxRenderVariants { + t.Fatalf("variant count %d exceeded maximum limit %d", variantCount, fileViewMaxRenderVariants) + } + if keyCount > fileViewMaxRenderVariants { + t.Fatalf("renderKeys count %d exceeded maximum limit %d", keyCount, fileViewMaxRenderVariants) + } +} + +// TestFileViewAsyncCacheMissLifecycle exercises the cache-miss lifecycle through +// the actual View/Update boundary: +// 1. Initial full-mode activation returns a command while View() renders the +// loading placeholder without performing disk I/O or Chroma work. +// 2. The command executes asynchronously and returns fileViewLoadedMsg. +// 3. Update() applies the message to the model. +// 4. View() renders the loaded, formatted content. +func TestFileViewAsyncCacheMissLifecycle(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "async_sample.go") + content := "package main\n\nfunc AsyncWork() string {\n\treturn \"done\"\n}\n" + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + // Step 1: Open file with no edit rows (opens in full mode directly with async cmd) + m, cmd := m.openFileView("async_sample.go") + if cmd == nil { + t.Fatal("expected async load command on cache miss") + } + + // View before cmd completes must render loading placeholder with 0 disk I/O or highlighting + initialView := m.renderFileViewFull(80) + if !strings.Contains(initialView, "Loading…") { + t.Fatalf("expected loading placeholder before command completes, got:\n%s", initialView) + } + statsBefore := fileViewCacheStatsForTest() + if statsBefore.DiskReads != 0 || statsBefore.HighlightCalls != 0 { + t.Fatalf("View() must not stat/read/highlight directly: %+v", statsBefore) + } + + // Step 2: Execute command asynchronously + msg := cmd() + loadedMsg, ok := msg.(fileViewLoadedMsg) + if !ok { + t.Fatalf("expected fileViewLoadedMsg, got %T", msg) + } + if loadedMsg.err != nil { + t.Fatalf("unexpected load error: %v", loadedMsg.err) + } + + // Step 3: Update model with loaded message + updated, _ := m.Update(loadedMsg) + m = updated.(model) + + // Step 4: View now renders the loaded content + loadedView := m.renderFileViewFull(80) + if !strings.Contains(loadedView, "AsyncWork") { + t.Fatalf("expected loaded content in view, got:\n%s", loadedView) + } + statsAfter := fileViewCacheStatsForTest() + if statsAfter.DiskReads != 1 || statsAfter.HighlightCalls != 1 { + t.Fatalf("expected exactly 1 disk read and 1 highlight call, got: %+v", statsAfter) + } + + // Also verify switching from diff mode to full mode triggers the cmd + appFile := filepath.Join(dir, "web", "app.js") + if err := os.MkdirAll(filepath.Join(dir, "web"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(appFile, []byte("let webApp = true;\n"), 0o644); err != nil { + t.Fatal(err) + } + mDiff, cmdDiff := m.openFileView("web/app.js") + if cmdDiff != nil || mDiff.fileView.mode != fileViewDiff { + t.Fatalf("file with edit cards must open in diff mode with nil cmd: mode=%d, cmd=%v", mDiff.fileView.mode, cmdDiff) + } + mFull, cmdFull := mDiff.setFileViewMode(fileViewFull) + if cmdFull == nil || mFull.fileView.mode != fileViewFull { + t.Fatal("switching to full mode must return load command") + } + updated, _ = mFull.Update(cmdFull()) + mFull = updated.(model) + if !strings.Contains(mFull.renderFileViewFull(80), "webApp") { + t.Fatalf("expected loaded webApp content, got: %s", mFull.renderFileViewFull(80)) + } +} + +// TestFileViewAsyncDiscardSupersededResult verifies that if a user switches files +// or exits the view while an async load is in flight, the completed message from +// the old file is safely discarded and does not overwrite the active view. +func TestFileViewAsyncDiscardSupersededResult(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + fileA := filepath.Join(dir, "fileA.txt") + fileB := filepath.Join(dir, "fileB.txt") + if err := os.WriteFile(fileA, []byte("Content of File A\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fileB, []byte("Content of File B\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + // Start loading File A + m, cmdA := m.openFileView("fileA.txt") + if cmdA == nil { + t.Fatal("expected cmd for fileA") + } + + // User switches to File B before cmdA returns + m, cmdB := m.openFileView("fileB.txt") + if cmdB == nil { + t.Fatal("expected cmd for fileB") + } + + // Now cmdA completes and its message is dispatched + msgA := cmdA() + updated, _ := m.Update(msgA) + m = updated.(model) + + // File A's result must be discarded because active file is File B + viewWhileB := m.renderFileViewFull(80) + if strings.Contains(viewWhileB, "Content of File A") { + t.Fatalf("stale File A result must not paint over File B: %s", viewWhileB) + } + + // Now cmdB completes and is dispatched + msgB := cmdB() + updated, _ = m.Update(msgB) + m = updated.(model) + + viewFinal := m.renderFileViewFull(80) + if !strings.Contains(viewFinal, "Content of File B") { + t.Fatalf("expected File B content, got: %s", viewFinal) + } +} + +// TestFileViewAsyncDiscardOnModeSwitchOrExit verifies that if a view exits or +// switches back to diff mode, completed async loads are safely ignored. +func TestFileViewAsyncDiscardOnModeSwitchOrExit(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + fileA := filepath.Join(dir, "discard_mode.txt") + if err := os.WriteFile(fileA, []byte("Some content\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + m, cmdA := m.openFileView("discard_mode.txt") + if cmdA == nil { + t.Fatal("expected cmd") + } + + // Exit file view before command returns + m = m.exitFileView() + if m.fileView.active { + t.Fatal("view should be inactive") + } + + // Now deliver the message + msgA := cmdA() + updated, _ := m.Update(msgA) + m = updated.(model) + + if m.fileView.active { + t.Fatal("discarded message must not re-activate file view") + } +} + +// TestFileViewAsyncDiscardOnThemeInvalidation verifies that if a theme switch occurs +// while an async load is in flight, the old theme's completion message is discarded +// and a fresh load command for the new theme generation is triggered. +func TestFileViewAsyncDiscardOnThemeInvalidation(t *testing.T) { + defer applyTheme(themeDark, true) + resetFileViewCacheForTest() + + dir := t.TempDir() + fileA := filepath.Join(dir, "theme_test.go") + if err := os.WriteFile(fileA, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + m, cmd := m.openFileView("theme_test.go") + if cmd == nil { + t.Fatal("expected cmd") + } + + // Invalidate cache by switching theme before cmd completes + applyTheme(themeLight, false) + + // Now old cmd completes with stale generation + msg := cmd() + updated, retryCmd := m.Update(msg) + m = updated.(model) + + // The message must have been rejected due to generation mismatch + if m.fileView.renderedContent != "" { + t.Fatalf("expected empty renderedContent after invalidation, got %q", m.fileView.renderedContent) + } + if retryCmd == nil { + t.Fatal("expected retry command for new generation after invalidation") + } + + // Executing the retry command loads the file under the new generation + retryMsg := retryCmd() + updated, _ = m.Update(retryMsg) + m = updated.(model) + + if !strings.Contains(m.renderFileViewFull(80), "package") { + t.Fatalf("expected file content loaded after retry, got: %s", m.renderFileViewFull(80)) + } + if m.fileView.loadedGen != defaultFileViewCache.generation() { + t.Fatalf("loadedGen %d != cache generation %d", m.fileView.loadedGen, defaultFileViewCache.generation()) + } +} + +// TestFileViewThemeSwitchWhileLoaded verifies that switching themes invalidates +// the loaded generation and allows immediate reloading for the new palette. +func TestFileViewThemeSwitchWhileLoaded(t *testing.T) { + defer applyTheme(themeDark, true) + resetFileViewCacheForTest() + + dir := t.TempDir() + fileA := filepath.Join(dir, "switch_test.go") + if err := os.WriteFile(fileA, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + m = testOpenFile(m, "switch_test.go") + if !strings.Contains(m.renderFileViewFull(80), "package") { + t.Fatal("file should be loaded initially") + } + + // Switch theme: generation advances, cache is cleared + applyTheme(themeLight, false) + + // renderFileViewFull must not return the stale dark-theme content + staleCheck := m.renderFileViewFull(80) + if strings.Contains(staleCheck, "package") { + t.Fatalf("stale renderedContent must not be rendered after generation increment: %s", staleCheck) + } + + // Starting a new load command re-populates for the new theme + m, reloadCmd := m.startFileViewLoadCmd(80) + if reloadCmd == nil { + t.Fatal("expected reload command") + } + updated, _ := m.Update(reloadCmd()) + m = updated.(model) + + if !strings.Contains(m.renderFileViewFull(80), "package") { + t.Fatalf("expected reloaded content for new theme, got: %s", m.renderFileViewFull(80)) + } +} + +// TestFileViewLifecycle_OpenToLoad tests the full model update flow from open to async load completion. +func TestFileViewLifecycle_OpenToLoad(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + filePath := filepath.Join(dir, "app.go") + if err := os.WriteFile(filePath, []byte("package app\nfunc Run() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + // Open file via model action + m, cmd := m.openFileView("app.go") + if !m.fileView.active || m.fileView.mode != fileViewFull { + t.Fatal("file view should be active in full mode for new file") + } + if cmd == nil { + t.Fatal("expected async load command on open") + } + + // View displays loading placeholder before completion + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), fileViewLoadingPlaceholder) { + t.Fatalf("expected loading placeholder, got: %s", plainRender(t, m.renderFileViewFull(80))) + } + + // Process load completion + updated, _ := m.Update(cmd()) + m = updated.(model) + + rendered := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(rendered, "package app") || !strings.Contains(rendered, "func Run()") { + t.Fatalf("expected loaded content, got: %s", rendered) + } + if m.fileView.hasError { + t.Fatal("expected no error") + } +} + +// TestFileViewLifecycle_RapidResizeCoalesced verifies that repeated resize events +// do not cause race conditions or synchronous render spikes, and the latest resize wins. +func TestFileViewLifecycle_RapidResizeCoalesced(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + filePath := filepath.Join(dir, "resize.go") + if err := os.WriteFile(filePath, []byte("package resize\nconst BigWidth = true\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "resize.go") + + var cmds []tea.Cmd + for w := 40; w <= 120; w += 10 { + var cmd tea.Cmd + m, cmd = m.startFileViewLoadCmd(w) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + // Deliver the latest resize command completion + lastCmd := cmds[len(cmds)-1] + updated, _ := m.Update(lastCmd()) + m = updated.(model) + + if m.fileView.loadedWidth != 120 { + t.Fatalf("expected loadedWidth 120, got %d", m.fileView.loadedWidth) + } + if !strings.Contains(plainRender(t, m.renderFileViewFull(120)), "package resize") { + t.Fatalf("expected content for width 120, got: %s", plainRender(t, m.renderFileViewFull(120))) + } +} + +// TestFileViewLifecycle_ThemeSwitchReloadsActiveView tests that selecting a theme +// in production immediately triggers a reload command for the active file view. +func TestFileViewLifecycle_ThemeSwitchReloadsActiveView(t *testing.T) { + defer applyTheme(themeDark, true) + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "theme_active.go") + if err := os.WriteFile(filePath, []byte("package theme\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "theme_active.go") + + // Trigger /theme light via command handling + cmdAction := parsedCommand{kind: commandTheme, text: "light"} + updated, reloadCmd := m.dispatchCommand(cmdAction) + m = updated.(model) + + if reloadCmd == nil { + t.Fatal("expected reload command on active file view after theme change") + } + + // Complete the reload + updated, _ = m.Update(reloadCmd()) + m = updated.(model) + + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package theme") { + t.Fatalf("expected reloaded theme content, got: %s", plainRender(t, m.renderFileViewFull(80))) + } + if m.fileView.loadedGen != defaultFileViewCache.generation() { + t.Fatalf("expected loadedGen %d, got %d", defaultFileViewCache.generation(), m.fileView.loadedGen) + } +} + +// TestFileViewLifecycle_DirectToolMutationTriggersRefresh tests that tool result +// rows from write_file or edit_file directly refresh an active file view snapshot. +func TestFileViewLifecycle_DirectToolMutationTriggersRefresh(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "live_edit.go") + if err := os.WriteFile(filePath, []byte("version 1\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "live_edit.go") + + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "version 1") { + t.Fatal("initial load should have version 1") + } + + // Directly modify file on disk + if err := os.WriteFile(filePath, []byte("version 2 modified\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Dispatch tool result for write_file affecting live_edit.go + toolRow := transcriptRow{ + kind: rowToolResult, + tool: "write_file", + changedFiles: []string{"live_edit.go"}, + detail: "+version 2 modified", + } + updated, reloadCmd := m.Update(agentRowMsg{runID: m.activeRunID, row: toolRow}) + m = updated.(model) + + if reloadCmd == nil { + t.Fatal("expected reload command on direct tool mutation for active file") + } + + // Execute reload + updated, _ = m.Update(reloadCmd()) + m = updated.(model) + + rendered := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(rendered, "version 2 modified") { + t.Fatalf("expected version 2 after tool mutation reload, got: %s", rendered) + } +} + +// TestFileViewLifecycle_DeletionOverrulesStaleCache tests that when a file is deleted, +// the reload failure evicts the former cache entry and immediately displays the error. +func TestFileViewLifecycle_DeletionOverrulesStaleCache(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "deleted.go") + if err := os.WriteFile(filePath, []byte("package deleted\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "deleted.go") + + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package deleted") { + t.Fatal("initial load failed") + } + + // Delete the file + if err := os.Remove(filePath); err != nil { + t.Fatal(err) + } + + // Force reload + m, reloadCmd := m.startFileViewLoadCmd(80) + if reloadCmd == nil { + t.Fatal("expected reload command") + } + + updated, _ := m.Update(reloadCmd()) + m = updated.(model) + + if !m.fileView.hasError { + t.Fatal("expected hasError to be true after deletion") + } + + rendered := plainRender(t, m.renderFileViewFull(80)) + if strings.Contains(rendered, "package deleted") { + t.Fatalf("stale cache must not be shown after deletion, got: %s", rendered) + } + if !strings.Contains(rendered, "Could not read file") { + t.Fatalf("expected error message in view, got: %s", rendered) + } +} + +// TestFileViewLifecycle_LateCompletionAcrossReopenDiscarded tests that if a file view +// is exited and the same path is reopened, any late-arriving completion from the first +// session is discarded and cannot populate the new session. +func TestFileViewLifecycle_LateCompletionAcrossReopenDiscarded(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "reopen.go") + if err := os.WriteFile(filePath, []byte("original content\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + + // Session 1: open and get command + m, cmd1 := m.openFileView("reopen.go") + if cmd1 == nil { + t.Fatal("expected cmd1") + } + + // Exit session 1 + m = m.exitFileView() + if m.fileView.active { + t.Fatal("view should be inactive") + } + + // Modify file on disk before session 2 + if err := os.WriteFile(filePath, []byte("new session content\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Session 2: reopen same path + m, cmd2 := m.openFileView("reopen.go") + if cmd2 == nil { + t.Fatal("expected cmd2") + } + + // Late completion from session 1 arrives + msg1 := cmd1() + updated, _ := m.Update(msg1) + m = updated.(model) + + // Session 1 message must have been discarded: view is still waiting on session 2 + if m.fileView.renderedContent != "" { + t.Fatalf("late completion from session 1 must be discarded, got: %s", m.fileView.renderedContent) + } + + // Session 2 completion arrives + msg2 := cmd2() + updated, _ = m.Update(msg2) + m = updated.(model) + + rendered := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(rendered, "new session content") { + t.Fatalf("expected new session content, got: %s", rendered) + } +} + +// TestFileViewLifecycle_ReverseOrderResizeCompletions verifies that when two resize events +// schedule requests A (earlier) and B (later), and B completes before A, the subsequent +// late arrival of A is discarded and does not overwrite B's width or rendered snapshot. +func TestFileViewLifecycle_ReverseOrderResizeCompletions(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "resize_order.go") + if err := os.WriteFile(filePath, []byte("package resize_order\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "resize_order.go") + + // Schedule Request A for width 60 + m, cmdA := m.startFileViewLoadCmd(60) + if cmdA == nil { + t.Fatal("expected cmdA") + } + + // Schedule Request B for width 100 + m, cmdB := m.startFileViewLoadCmd(100) + if cmdB == nil { + t.Fatal("expected cmdB") + } + + // Message B completes first + msgB := cmdB() + updated, _ := m.Update(msgB) + m = updated.(model) + + if m.fileView.loadedWidth != 100 { + t.Fatalf("expected loadedWidth 100 after B completes, got %d", m.fileView.loadedWidth) + } + if !strings.Contains(plainRender(t, m.renderFileViewFull(100)), "package resize_order") { + t.Fatalf("expected width 100 content rendered, got: %s", plainRender(t, m.renderFileViewFull(100))) + } + + // Message A arrives late (reverse-order) + msgA := cmdA() + updated, _ = m.Update(msgA) + m = updated.(model) + + // State MUST remain B (width 100), not overwritten by A (width 60) + if m.fileView.loadedWidth != 100 { + t.Fatalf("late completion A must NOT overwrite loadedWidth, got %d (want 100)", m.fileView.loadedWidth) + } + if !strings.Contains(plainRender(t, m.renderFileViewFull(100)), "package resize_order") { + t.Fatalf("expected width 100 content still visible, got: %s", plainRender(t, m.renderFileViewFull(100))) + } +} + +// TestFileViewLifecycle_ReverseOrderToolMutationCompletions verifies that when two tool +// mutations trigger requests A (version 1) and B (version 2), and B completes before A, +// the subsequent arrival of A cannot revert the visible snapshot back to version 1. +func TestFileViewLifecycle_ReverseOrderToolMutationCompletions(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "tool_order.go") + if err := os.WriteFile(filePath, []byte("initial\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "tool_order.go") + + // Mutation A modifies file to v1 + if err := os.WriteFile(filePath, []byte("version 1 state\n"), 0o644); err != nil { + t.Fatal(err) + } + rowA := transcriptRow{ + kind: rowToolResult, + tool: "write_file", + changedFiles: []string{"tool_order.go"}, + detail: "+version 1 state", + } + updated, cmdA := m.Update(agentRowMsg{runID: m.activeRunID, row: rowA}) + m = updated.(model) + if cmdA == nil { + t.Fatal("expected cmdA for mutation A") + } + + // Capture A while v1 is still on disk so msgA contains the genuine v1 snapshot + msgA := cmdA() + + // Mutation B immediately modifies file to v2 before A is applied + if err := os.WriteFile(filePath, []byte("version 2 state\n"), 0o644); err != nil { + t.Fatal(err) + } + rowB := transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + changedFiles: []string{"tool_order.go"}, + detail: "+version 2 state", + } + updated, cmdB := m.Update(agentRowMsg{runID: m.activeRunID, row: rowB}) + m = updated.(model) + if cmdB == nil { + t.Fatal("expected cmdB for mutation B") + } + + // B completes first + msgB := cmdB() + updated, _ = m.Update(msgB) + m = updated.(model) + + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "version 2 state") { + t.Fatalf("expected version 2 state after B completes, got: %s", plainRender(t, m.renderFileViewFull(80))) + } + + // A arrives late (reverse-order delivery of stale snapshot) + updated, _ = m.Update(msgA) + m = updated.(model) + + // View MUST remain version 2, never reverted by A + rendered := plainRender(t, m.renderFileViewFull(80)) + if strings.Contains(rendered, "version 1 state") { + t.Fatalf("stale version 1 completion must NOT overwrite version 2, got: %s", rendered) + } + if !strings.Contains(rendered, "version 2 state") { + t.Fatalf("expected version 2 state still visible, got: %s", rendered) + } +} + +func TestFileViewSanitizesControlSequencesInHighlightedSource(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + // Recognized Go source with OSC 52 clipboard hijacking sequence in a comment + // and ANSI CSI styling codes inside a string literal. + goContent := `package main + +// \x1b]52;c;cGFzc3dvcmQ=\x07 malicious comment +func main() { + const escape = "\x1b[31;1mred\x1b[0m" +} +` + // Replace raw escape escapes with actual byte values 0x1b and 0x07 + goContent = strings.ReplaceAll(goContent, `\x1b`, "\x1b") + goContent = strings.ReplaceAll(goContent, `\x07`, "\x07") + + filePath := filepath.Join(dir, "malicious.go") + if err := os.WriteFile(filePath, []byte(goContent), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "malicious.go") + + rendered := m.renderFileViewFull(80) + + // Verify that raw hostile control sequences never reach the rendered output + if strings.Contains(rendered, "\x1b]52;") { + t.Fatalf("rendered output must not contain raw OSC 52 sequence, got: %q", rendered) + } + if strings.Contains(rendered, "\x07") { + t.Fatalf("rendered output must not contain BEL character, got: %q", rendered) + } + if strings.Contains(rendered, "\x1b[31;1m") { + t.Fatalf("rendered output must not contain raw source-supplied CSI sequence, got: %q", rendered) + } + + // Verify that the sanitized printable representation is present and styled + plain := plainRender(t, rendered) + if !strings.Contains(plain, "^[") { + t.Fatalf("expected sanitized escape prefix '^[' in plain view, got: %q", plain) + } + if !strings.Contains(plain, "malicious comment") { + t.Fatalf("expected safe comment text in plain view, got: %q", plain) + } +} + +func TestFileViewLifecycle_ResizeRoundTripDoesNotReuseStaleCache(t *testing.T) { + resetFileViewCacheForTest() + + dir := t.TempDir() + filePath := filepath.Join(dir, "roundtrip.go") + if err := os.WriteFile(filePath, []byte("package roundtrip\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "roundtrip.go") + + m, cmd80 := m.startFileViewLoadCmd(80) + if cmd80 == nil { + t.Fatal("expected first width-80 load") + } + updated, _ := m.Update(cmd80()) + m = updated.(model) + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package roundtrip") { + t.Fatal("expected committed width-80 content") + } + + m, cmd100 := m.startFileViewLoadCmd(100) + if cmd100 == nil { + t.Fatal("expected width-100 load") + } + m, cmd80b := m.startFileViewLoadCmd(80) + if cmd80b == nil { + t.Fatal("expected second width-80 load") + } + + got := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, fileViewLoadingPlaceholder) { + t.Fatalf("80→100→80 must stay on loading until desiredSeq completes, got: %s", got) + } + if strings.Contains(got, "package roundtrip") { + t.Fatalf("must not reuse cached width 80 from an earlier seq, got: %s", got) + } + + updated, _ = m.Update(cmd80b()) + m = updated.(model) + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package roundtrip") { + t.Fatalf("expected content after matching seq completes, got: %s", plainRender(t, m.renderFileViewFull(80))) + } +} + +func TestFileViewLifecycle_EmptyFileIsCompletedSnapshot(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "empty.go"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "empty.go") + got := plainRender(t, m.renderFileViewFull(80)) + if strings.Contains(got, fileViewLoadingPlaceholder) { + t.Fatalf("empty completed snapshot must not stay on loading, got %q", got) + } + if !m.fileView.snapshotReady { + t.Fatal("snapshotReady must be set for an empty file") + } +} + +func TestFileViewLifecycle_ShellEscapeReloadsFullView(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + path := filepath.Join(dir, "shell.go") + if err := os.WriteFile(path, []byte("package old\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "shell.go") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + oldTime := info.ModTime() + if err := os.WriteFile(path, []byte("package new\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, oldTime, oldTime); err != nil { + t.Fatal(err) + } + updated, cmd := m.Update(bashResultMsg{output: "ok"}) + m = updated.(model) + if cmd == nil { + t.Fatal("bashResultMsg must schedule a file-view reload") + } + updated, _ = m.Update(cmd()) + m = updated.(model) + got := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, "package new") { + t.Fatalf("expected reloaded content after shell escape, got %s", got) + } +} + +func TestFileViewLifecycle_SupersededResizeSkipsWork(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "coalesce.go"), []byte("package coalesce\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + var cmd0 tea.Cmd + m, cmd0 = m.openFileView("coalesce.go") + if cmd0 == nil { + t.Fatal("expected initial load") + } + var cmd1, cmd2 tea.Cmd + m, cmd1 = m.startFileViewLoadCmd(60) + m, cmd2 = m.startFileViewLoadCmd(100) + _ = cmd0() + _ = cmd1() + msg := cmd2() + updated, _ := m.Update(msg) + m = updated.(model) + stats := fileViewCacheStatsForTest() + if stats.DiskReads != 1 { + t.Fatalf("superseded loads must not re-read, DiskReads=%d", stats.DiskReads) + } + if !strings.Contains(plainRender(t, m.renderFileViewFull(100)), "package coalesce") { + t.Fatal("latest width must still load") + } +} + +func TestFilesPanelSecondActivationOpensFullViewThroughUpdate(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "web"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "web", "app.js"), []byte("let a = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m.runDetailsOpen = true + m, cmd := m.selectFile("web/app.js") + if cmd != nil { + t.Fatal("first FILES activation must only select") + } + if m.fileView.active { + t.Fatal("first FILES activation must not open the file view") + } + if m.selectedFile != "web/app.js" { + t.Fatalf("selectedFile = %q", m.selectedFile) + } + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(model) + if !m.fileView.active || m.fileView.path != "web/app.js" { + t.Fatal("Enter on the selected FILES row must call openFileView") + } + if m.runDetailsOpen { + t.Fatal("drilling in must close run details") + } + + updated, cmd = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + m = updated.(model) + if m.fileView.mode != fileViewFull { + t.Fatal("f must switch to full mode") + } + if cmd == nil { + t.Fatal("full mode must schedule an async load") + } + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), fileViewLoadingPlaceholder) { + t.Fatal("expected loading before async result") + } + updated, _ = m.Update(cmd()) + m = updated.(model) + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "let a = 1") { + t.Fatalf("expected loaded file content, got %s", plainRender(t, m.renderFileViewFull(80))) + } +} + +func TestFileViewLifecycle_SupersedeStopsInFlightWork(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "stall.go"), []byte("package stall\n"), 0o644); err != nil { + t.Fatal(err) + } + entered := make(chan struct{}) + release := make(chan struct{}) + fileViewInsideLoad = func() { + select { + case <-entered: + default: + close(entered) + } + <-release + } + defer func() { fileViewInsideLoad = nil }() + + m := filesPanelTestModel() + m.cwd = dir + m, cmdA := m.openFileView("stall.go") + if cmdA == nil { + t.Fatal("expected initial load") + } + doneA := make(chan tea.Msg, 1) + go func() { doneA <- cmdA() }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("load A did not enter worker") + } + m, cmdB := m.startFileViewLoadCmd(100) + if cmdB == nil { + t.Fatal("expected superseding load") + } + close(release) + msgA := <-doneA + loaded, ok := msgA.(fileViewLoadedMsg) + if !ok { + t.Fatalf("msgA type %T", msgA) + } + if !errors.Is(loaded.err, errFileViewSuperseded) { + t.Fatalf("in-flight A must stop, err=%v", loaded.err) + } + msgB := cmdB() + updated, _ := m.Update(msgB) + m = updated.(model) + if !strings.Contains(plainRender(t, m.renderFileViewFull(100)), "package stall") { + t.Fatal("latest request must complete") + } +} + +func TestFileViewLifecycle_SupersededHighlightMustNotClobberCache(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + path := filepath.Join(dir, "clobber.go") + if err := os.WriteFile(path, []byte("package old\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m = testOpenFile(m, "clobber.go") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + oldTime := info.ModTime() + if err := os.WriteFile(path, []byte("package new\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + entered := make(chan struct{}) + release := make(chan struct{}) + var n int32 + fileViewBeforeCacheCommit = func() { + if atomic.AddInt32(&n, 1) == 1 { + close(entered) + <-release + } + } + defer func() { fileViewBeforeCacheCommit = nil }() + + m, cmdA := m.startFileViewRefreshCmd(80) + if cmdA == nil { + t.Fatal("expected refresh A") + } + doneA := make(chan tea.Msg, 1) + go func() { doneA <- cmdA() }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("A did not reach cache commit") + } + m, cmdB := m.startFileViewRefreshCmd(80) + if cmdB == nil { + t.Fatal("expected refresh B") + } + updated, _ := m.Update(cmdB()) + m = updated.(model) + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package new") { + t.Fatal("B must be accepted before releasing A") + } + close(release) + msgA := <-doneA + loaded, ok := msgA.(fileViewLoadedMsg) + if !ok { + t.Fatalf("msgA type %T", msgA) + } + if !errors.Is(loaded.err, errFileViewSuperseded) { + t.Fatalf("A must not commit, err=%v", loaded.err) + } + got := plainRender(t, m.renderFileViewFull(80)) + if strings.Contains(got, "package old") { + t.Fatalf("stale highlight must not clobber cache, got %s", got) + } + if !strings.Contains(got, "package new") { + t.Fatalf("expected package new, got %s", got) + } +} + +func TestFileViewExitRevokesWorkerToken(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "leave.go" + if err := os.WriteFile(filepath.Join(dir, name), []byte("package p\nfunc B() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m, cmd := m.openFileView(name) + if cmd == nil { + t.Fatal("load cmd") + } + token := m.fileView.liveSeq + if token == nil { + t.Fatal("liveSeq") + } + seq := token.Load() + m = m.exitFileView() + if token.Load() == seq { + t.Fatal("exitFileView must revoke the token the worker still holds") + } +} + +func TestFileViewCacheHitRespectsLiveSeq(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "hit.go" + if err := os.WriteFile(filepath.Join(dir, name), []byte("package p\nfunc A() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m, cmd := m.openFileView(name) + if cmd == nil { + t.Fatal("cache miss must yield load cmd") + } + updated, _ := m.Update(cmd()) + m = updated.(model) + if m.fileView.liveSeq == nil { + t.Fatal("liveSeq") + } + seq := m.fileView.liveSeq.Load() + live := m.fileView.liveSeq + fileViewBeforeCacheCommit = func() { live.Store(seq + 1) } + defer func() { fileViewBeforeCacheCommit = nil }() + target := filepath.Join(dir, name) + gen := defaultFileViewCache.generation() + _, err := defaultFileViewCache.loadAndRender(target, name, m.fileView.loadedWidth+17, nil, m.fileView.loadedFingerprint, gen, zeroTheme, 0, live, seq) + if !errors.Is(err, errFileViewSuperseded) { + t.Fatalf("cache-hit put after supersede: err=%v", err) + } +} + +func TestFileViewPlanToolRefreshesWhenGitSweepNil(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "mut.go" + if err := os.WriteFile(filepath.Join(dir, name), []byte("package old\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m.activeRunID = 7 + m.gitFileBaseline = nil + m.gitSweepUnavailable = true + m = testOpenFile(m, name) + if err := os.WriteFile(filepath.Join(dir, name), []byte("package new\n"), 0o644); err != nil { + t.Fatal(err) + } + updated, cmd := m.Update(agentRowMsg{ + runID: 7, + row: transcriptRow{ + kind: rowToolResult, + tool: "bash", + status: tools.StatusOK, + changedFiles: []string{name}, + }, + }) + m = updated.(model) + if cmd == nil { + t.Fatal("bash/plan mutation must refresh the file view even when maybeGitSweep is nil") + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + if c == nil { + continue + } + updated, _ = m.Update(c()) + m = updated.(model) + } + } else { + updated, _ = m.Update(msg) + m = updated.(model) + } + got := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, "package new") { + t.Fatalf("stale view after git-nil mutation: %s", got) + } +} + +func TestFileViewTransitionMatrix_MutationClosedDiffFull(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "sample.go" + filePath := filepath.Join(dir, name) + + // Phase 1: Closed mutation with equal size & equal mtime + if err := os.WriteFile(filePath, []byte("package old\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m.activeRunID = 1 + + // Seed cache + m = testOpenFile(m, name) + if !strings.Contains(plainRender(t, m.renderFileViewFull(80)), "package old") { + t.Fatal("initial seed must show package old") + } + m = m.exitFileView() + + // Mutate on disk with same length ("package new\n" has 12 bytes == "package old\n") + fi, _ := os.Stat(filePath) + oldTime := fi.ModTime() + if err := os.WriteFile(filePath, []byte("package new\n"), 0o644); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(filePath, oldTime, oldTime) + + // Deliver mutation row while closed + updated, _ := m.Update(agentRowMsg{ + runID: 1, + row: transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + }, + }) + m = updated.(model) + + // Re-open and switch to full mode: must not reuse stale cache entry + m = testOpenFile(m, name) + m = testSetMode(m, fileViewFull) + got := plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, "package new") { + t.Fatalf("reopening after closed mutation must render package new, got: %s", got) + } + + // Phase 2: Diff mode mutation with equal metadata + if err := os.WriteFile(filePath, []byte("package old\n"), 0o644); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(filePath, oldTime, oldTime) + m = testSetMode(m, fileViewFull) // refreshed to old + m = testSetMode(m, fileViewDiff) + + // Mutate while in diff mode + if err := os.WriteFile(filePath, []byte("package new\n"), 0o644); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(filePath, oldTime, oldTime) + updated, _ = m.Update(agentRowMsg{ + runID: 1, + row: transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + }, + }) + m = updated.(model) + + // Switch back to full mode: must not reuse stale cache entry + m = testSetMode(m, fileViewFull) + got = plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, "package new") { + t.Fatalf("switching to full after diff mutation must render package new, got: %s", got) + } + + // Phase 3: Full mode live mutation + if err := os.WriteFile(filePath, []byte("package live\n"), 0o644); err != nil { + t.Fatal(err) + } + updated, cmd := m.Update(agentRowMsg{ + runID: 1, + row: transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + }, + }) + m = updated.(model) + if cmd == nil { + t.Fatal("full mode mutation must yield refresh cmd") + } + updated, _ = m.Update(cmd()) + m = updated.(model) + got = plainRender(t, m.renderFileViewFull(80)) + if !strings.Contains(got, "package live") { + t.Fatalf("full mode live mutation must render package live, got: %s", got) + } +} + +func TestFileViewTransitionMatrix_RefreshResizeThemeOrdering(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "order.go" + filePath := filepath.Join(dir, name) + + if err := os.WriteFile(filePath, []byte("package v1\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m.activeRunID = 1 + m = testOpenFile(m, name) + + // Mutate to v2 with equal size & mtime + fi, _ := os.Stat(filePath) + oldTime := fi.ModTime() + if err := os.WriteFile(filePath, []byte("package v2\n"), 0o644); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(filePath, oldTime, oldTime) + + // 1. Tool mutation schedules refresh + updated, refreshCmd := m.Update(agentRowMsg{ + runID: 1, + row: transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + }, + }) + m = updated.(model) + if refreshCmd == nil { + t.Fatal("mutation must schedule refresh") + } + + // 2. Before refreshCmd completes, a resize event arrives (advances desiredSeq with refreshSource=false) + updated, resizeCmd := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(model) + if resizeCmd == nil { + t.Fatal("resize must yield load cmd") + } + + // 3. Obsolete refreshCmd returns: must be rejected + oldRefreshMsg := refreshCmd() + updated, _ = m.Update(oldRefreshMsg) + m = updated.(model) + + // 4. Winning resizeCmd completes: MUST inherit requiredSourceRev and render v2 + resizeMsg := resizeCmd() + updated, _ = m.Update(resizeMsg) + m = updated.(model) + + got := plainRender(t, m.renderFileViewFull(120)) + if !strings.Contains(got, "package v2") { + t.Fatalf("resize following mutation must render package v2, got: %s", got) + } +} + +func TestFileViewTransitionMatrix_DeterministicStageHooksSupersession(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "hooks.go" + filePath := filepath.Join(dir, name) + if err := os.WriteFile(filePath, []byte("package hooks\nfunc Work() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + + hooks := []struct { + name string + isCacheHit bool + set func(fn func()) + }{ + {"InsideLoad", false, func(fn func()) { fileViewInsideLoad = fn }}, + {"BeforeCacheHitFormat", true, func(fn func()) { fileViewBeforeCacheHitFormat = fn }}, + {"BeforeDiskRead", false, func(fn func()) { fileViewBeforeDiskRead = fn }}, + {"BeforeHighlight", false, func(fn func()) { fileViewBeforeHighlight = fn }}, + {"BeforeFormat", false, func(fn func()) { fileViewBeforeFormat = fn }}, + {"BeforeCacheCommit", false, func(fn func()) { fileViewBeforeCacheCommit = fn }}, + } + + for _, tc := range hooks { + t.Run(tc.name, func(t *testing.T) { + resetFileViewCacheForTest() + m := filesPanelTestModel() + m.cwd = dir + + if tc.isCacheHit { + // Seed cache first + m = testOpenFile(m, name) + m = testSetMode(m, fileViewFull) + } + + m, cmd := m.openFileView(name) + if tc.isCacheHit { + var loadCmd tea.Cmd + m, loadCmd = m.startFileViewLoadCmd(m.chatColumnWidth() + 15) // width variant + cmd = loadCmd + } + if cmd == nil { + t.Fatal("load cmd") + } + live := m.fileView.liveSeq + seq := m.fileView.desiredSeq + + executedHook := false + tc.set(func() { + executedHook = true + live.Store(seq + 1) // supersede A with B + }) + defer tc.set(nil) + + msg := cmd() + if !executedHook { + t.Fatalf("hook %s was never executed", tc.name) + } + loaded := msg.(fileViewLoadedMsg) + if !errors.Is(loaded.err, errFileViewSuperseded) { + t.Fatalf("hook %s: expected errFileViewSuperseded, got %v", tc.name, loaded.err) + } + + // Delivery of superseded message must be a complete no-op + updated, nextCmd := m.handleFileViewLoaded(loaded) + if nextCmd != nil { + t.Fatalf("hook %s: superseded message must not schedule retry", tc.name) + } + if updated.fileView.snapshotReady { + t.Fatalf("hook %s: superseded message must not mark snapshotReady", tc.name) + } + }) + } +} + +func TestFileViewTransitionMatrix_CurrentCompletionFollowedByObsoleteCompletion(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "race.go" + filePath := filepath.Join(dir, name) + if err := os.WriteFile(filePath, []byte("package race\n"), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m, cmd1 := m.openFileView(name) + if cmd1 == nil { + t.Fatal("cmd1") + } + + // Resize to advance sequence from 1 to 2 + updated, cmd2 := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = updated.(model) + if cmd2 == nil { + t.Fatal("cmd2") + } + + // Sequence 2 completes first + msg2 := cmd2() + updated, _ = m.Update(msg2) + m = updated.(model) + if !m.fileView.snapshotReady || m.fileView.loadedSeq != 2 { + t.Fatalf("seq 2 must be loaded, ready=%v seq=%d", m.fileView.snapshotReady, m.fileView.loadedSeq) + } + + // Sequence 1 arrives late: must be a complete no-op + msg1 := cmd1() + updated, lateCmd := m.Update(msg1) + m = updated.(model) + if lateCmd != nil { + t.Fatal("late completion must not schedule retry") + } + if !m.fileView.snapshotReady || m.fileView.loadedSeq != 2 { + t.Fatalf("late completion must not degrade state: ready=%v seq=%d", m.fileView.snapshotReady, m.fileView.loadedSeq) + } +} + +func TestFileView_ChangedLineMatchingWithTabsAndControlChars(t *testing.T) { + resetFileViewCacheForTest() + dir := t.TempDir() + name := "tabmatch.go" + content := "package main\n\nvar Field\t= 1\nvar Clean = 2\n" + filePath := filepath.Join(dir, name) + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + m := filesPanelTestModel() + m.cwd = dir + m.transcript = append(m.transcript, transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + detail: "--- a/tabmatch.go\n+++ b/tabmatch.go\n@@ -1,2 +1,3 @@\n+var Field\t= 1\n", + }) + + m = testOpenFile(m, name) + m = testSetMode(m, fileViewFull) + rendered := m.renderFileViewFull(80) + + // The rendered source displays 4 spaces instead of raw tab, and retains the accent gutter marker ▎ + if strings.Contains(rendered, "\t") { + t.Fatal("rendered output must not contain raw tab characters") + } + if !strings.Contains(rendered, "▎") { + t.Fatalf("added line with interior tab must retain accent gutter marker, got:\n%s", rendered) + } +} + +func TestRunDetails_KeyboardActivationStrictRowTargets(t *testing.T) { + m := filesPanelTestModel() + m.altScreen = true + m.width = 80 + m.height = 30 + m.runDetailsOpen = true + + // Build a transcript with 7 files touched so files 0..3 are visible, file 4 is overflow trailer ("… more in transcript"), files 5..6 hidden + for i := 0; i < 7; i++ { + name := fmt.Sprintf("file_%d.go", i) + m.transcript = append(m.transcript, transcriptRow{ + kind: rowToolResult, + tool: "edit_file", + status: tools.StatusOK, + changedFiles: []string{name}, + }) + } + + // 1. Visible file (file_6.go) can be activated on Enter + m.selectedFile = "file_6.go" + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + res := updated.(model) + if !res.fileView.active || res.fileView.path != "file_6.go" { + t.Fatalf("visible file in run details must be activated on Enter: active=%v path=%s", res.fileView.active, res.fileView.path) + } + + // Reset file view + m = res.exitFileView() + m.runDetailsOpen = true + + // 2. Hidden / overflow file (file_0.go) must NOT be activated on Enter + m.selectedFile = "file_0.go" + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + res = updated.(model) + if res.fileView.active { + t.Fatalf("overflow hidden file (file_0.go) must not be activated on Enter from run details modal") + } +} diff --git a/internal/tui/files_git_sweep_test.go b/internal/tui/files_git_sweep_test.go index cccdcb4d9..f2f2f6e2f 100644 --- a/internal/tui/files_git_sweep_test.go +++ b/internal/tui/files_git_sweep_test.go @@ -152,12 +152,12 @@ func TestTouchedFilesMergesGitSweep(t *testing.T) { func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) { m := filesPanelTestModel() m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}} - m = m.openFileView("kanban/board.tsx") + m, _ = m.openFileView("kanban/board.tsx") if m.fileView.mode != fileViewFull { t.Fatal("git-only file should open in full mode") } m = m.exitFileView() - m = m.openFileView("web/app.js") + m, _ = m.openFileView("web/app.js") if m.fileView.mode != fileViewDiff { t.Fatal("a file with edit cards still opens in diff mode") } diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index af9eac945..3cb9757b3 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -15,6 +15,8 @@ import ( "sort" "strings" + tea "charm.land/bubbletea/v2" + "github.com/Gitlawb/zero/internal/tools" ) @@ -357,8 +359,13 @@ func (m *model) setSelectedFile(path string) { // selectFile marks path as the selected file and scrolls the transcript so its // most recent edit card is in view; the card tint comes from the renderers -// reading selectedFile (rowTouchesSelectedFile). -func (m model) selectFile(path string) model { +// reading selectedFile (rowTouchesSelectedFile). A second activation of the +// same path drills into the file view and returns any load command. +func (m model) selectFile(path string) (model, tea.Cmd) { + if m.selectedFile == path { + m.runDetailsOpen = false + return m.openFileView(path) + } rowIndex := m.lastRowIndexForFile(path) m.setSelectedFile(path) if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok { @@ -367,7 +374,46 @@ func (m model) selectFile(path string) model { m.chatBodyLines = 0 } } - return m + return m, nil +} + +func (m model) runDetailsInnerWidth() int { + overlayWidth := minInt(72, maxInt(40, m.width-8)) + overlayWidth = minInt(overlayWidth, m.width) + return maxInt(12, overlayWidth-4) +} + +func (m model) runDetailsFileAtMouse(msg tea.MouseMsg) (string, bool) { + if !m.runDetailsOpen || !m.runDetailsAllowed() { + return "", false + } + width := m.width + overlay := m.runDetailsOverlay(width) + hit, ok := m.overlayMouseHit(msg, overlay, width) + if !ok { + return "", false + } + inner := m.runDetailsInnerWidth() + layout := m.runDetailsLayout(inner) + if layout.fileStart < 0 { + return "", false + } + // styledBlockFillTitle contributes a 1-line top border with title before the content rows. + const topBorderHeight = 1 + y := hit.y - topBorderHeight + var match fileHit + found := false + for _, h := range layout.fileHits { + if y == layout.fileStart+h.lineOffset { + match = h + found = true + break + } + } + if !found { + return "", false + } + return match.path, true } // scrollOffsetForTranscriptRow computes the chatScrollOffset that places the diff --git a/internal/tui/files_panel_test.go b/internal/tui/files_panel_test.go index 0af00afc8..90292a995 100644 --- a/internal/tui/files_panel_test.go +++ b/internal/tui/files_panel_test.go @@ -296,6 +296,24 @@ func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) { // TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first // write as content, so the FILES pulse is visible before any result row lands. +func TestSidebarFileHitsDistinguishSuffixPaths(t *testing.T) { + m := sidebarTestModel() + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolResult, tool: "edit_file", id: "a", status: tools.StatusOK, changedFiles: []string{"a.go"}}, + transcriptRow{kind: rowToolResult, tool: "edit_file", id: "b", status: tools.StatusOK, changedFiles: []string{"dir/a.go"}}, + ) + _, hits := m.sidebarFileLines(80) + if len(hits) < 2 { + t.Fatalf("want two fileHit rows, got %#v", hits) + } + if hits[0].path == hits[1].path { + t.Fatal("fileHit paths must stay distinct") + } + if hits[0].lineOffset == hits[1].lineOffset { + t.Fatal("fileHit rows must not share a line offset") + } +} + func TestSidebarHasContentForLiveWrite(t *testing.T) { m := sidebarTestModel() m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now @@ -309,3 +327,49 @@ func TestSidebarHasContentForLiveWrite(t *testing.T) { t.Fatal("a live in-flight write must count as sidebar content") } } + +func TestRunDetailsFileMouseSelectionEndToEnd(t *testing.T) { + m := filesPanelTestModel() + m.width = 80 + m.height = 24 + m.altScreen = true + m.runDetailsOpen = true + + overlay := m.runDetailsOverlay(m.width) + if overlay == "" { + t.Fatal("expected non-empty runDetailsOverlay") + } + + inner := m.runDetailsInnerWidth() + layout := m.runDetailsLayout(inner) + if len(layout.fileHits) == 0 || layout.fileStart < 0 { + t.Fatalf("expected file hits in layout, got: %+v", layout) + } + + targetHit := layout.fileHits[0] // "internal/tui/sidebar.go" + overlayLines := viewLines(overlay) + left, trimmedLines, overlayWidth := normalizeOverlayBlock(overlayLines, m.width) + if overlayWidth <= 0 || len(trimmedLines) == 0 { + t.Fatalf("invalid overlay block normalization: left=%d, width=%d", left, overlayWidth) + } + + rect := m.overlayMouseRect(len(trimmedLines), m.width) + // Click on the target file row: top border is row 0 of the overlay, content starts at row 1 + clickY := rect.y + 1 + layout.fileStart + targetHit.lineOffset + clickX := left + 4 // inside the border and padding + + clickMsg := testMouseClick(tea.MouseLeft, clickX, clickY) + + path, ok := m.runDetailsFileAtMouse(clickMsg) + if !ok || path != targetHit.path { + t.Fatalf("runDetailsFileAtMouse failed: ok=%v, got=%q, want=%q", ok, path, targetHit.path) + } + + // Route the click through m.Update to verify end-to-end selection + updated, _ := m.Update(clickMsg) + m = updated.(model) + + if m.selectedFile != targetHit.path { + t.Fatalf("expected selectedFile %q after mouse click, got %q", targetHit.path, m.selectedFile) + } +} diff --git a/internal/tui/flush_test.go b/internal/tui/flush_test.go index 758a785ff..88fb23884 100644 --- a/internal/tui/flush_test.go +++ b/internal/tui/flush_test.go @@ -111,7 +111,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) { t.Fatal("precondition: settled cache should be populated") } - m = m.selectFile("internal/tui/sidebar.go") + m, _ = m.selectFile("internal/tui/sidebar.go") if m.altScreenSettledWidth != 0 { t.Fatal("selecting a file touched by a settled row must invalidate the cache") } diff --git a/internal/tui/model.go b/internal/tui/model.go index 9473de06a..960aab995 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1362,6 +1362,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return next, cmd } switch msg := msg.(type) { + case fileViewLoadedMsg: + return m.handleFileViewLoaded(msg) case uv.CellSizeEvent: if msg.Width > 0 && msg.Height > 0 { m.petCellPixelWidth = msg.Width @@ -1424,6 +1426,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.hasDarkBg = msg.IsDark() if m.themeMode != themeSystem { applyTheme(m.themeMode, m.hasDarkBg) + if m.fileView.active && m.fileView.mode == fileViewFull { + return m.startFileViewLoadCmd(m.chatColumnWidth()) + } } return m, nil case tea.MouseMsg: @@ -1650,6 +1655,18 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { case m.runDetailsOpen: if keyIs(msg, tea.KeyEsc) || m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) { m.runDetailsOpen = false + return m, nil + } + if keyIs(msg, tea.KeyEnter) && m.selectedFile != "" { + overlayWidth := minInt(72, maxInt(40, m.width-8)) + inner := maxInt(12, overlayWidth-4) + layout := m.runDetailsLayout(inner) + for _, h := range layout.fileHits { + if h.path == m.selectedFile { + return m.selectFile(m.selectedFile) + } + } + return m, nil } return m, nil case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }): @@ -1659,9 +1676,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // (so mid-sentence typing is never hijacked) and no modal is up (so a // permission prompt / ask-user / wizard keeps its own key handling). if keyText(msg) == "f" { - return m.setFileViewMode(fileViewFull), nil + return m.setFileViewMode(fileViewFull) } - return m.setFileViewMode(fileViewDiff), nil + return m.setFileViewMode(fileViewDiff) case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }) && canFireComposerGatedToggle(m.keyBindings.toggleMouse, defaultToggleMouseChord, m.composerValue() == ""): // Release/recapture the mouse so the user can drag-select and copy text // natively (mouse capture otherwise intercepts terminal selection). The @@ -2445,6 +2462,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } // A resumed/idle session may already hold agents; keep their short lifecycle // fade alive. No-op when the loop is already running or nothing animates. + if m.fileView.active && m.fileView.mode == fileViewFull { + var cmd tea.Cmd + m, cmd = m.startFileViewLoadCmd(m.chatColumnWidth()) + return m, tea.Batch(m.ensureSpinnerTick(), cmd) + } return m, m.ensureSpinnerTick() case permissionRequestMsg: // The agent goroutine that raised this request is BLOCKED waiting on the @@ -2914,10 +2936,50 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // A finished command tool may have mutated files git can see but no // changedFiles reports (npm create, heredoc writes, subagent edits) — // re-sweep so the FILES sidebar picks them up mid-turn. - if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) { - var sweep tea.Cmd - m, sweep = m.maybeGitSweep() - return m, sweep + if msg.row.kind == rowToolResult { + if isPlanCommandTool(msg.row.tool) { + var sweep tea.Cmd + m, sweep = m.maybeGitSweep() + if m.fileView.active { + target := m.fileView.path + if !filepath.IsAbs(target) { + target = filepath.Join(m.cwd, target) + } + defaultFileViewCache.invalidatePath(target) + m.fileView.requiredSourceRev++ + if m.fileView.mode == fileViewFull { + var loadCmd tea.Cmd + m, loadCmd = m.startFileViewRefreshCmd(m.chatColumnWidth()) + return m, tea.Batch(sweep, loadCmd) + } + } + return m, sweep + } + var loadCmds []tea.Cmd + for _, p := range msg.row.changedFiles { + target := p + if !filepath.IsAbs(target) { + target = filepath.Join(m.cwd, target) + } + rev := defaultFileViewCache.invalidatePath(target) + if m.fileView.active && (p == m.fileView.path || target == m.fileView.path) { + if rev > m.fileView.requiredSourceRev { + m.fileView.requiredSourceRev = rev + } else { + m.fileView.requiredSourceRev++ + } + if m.fileView.mode == fileViewFull { + var cmd tea.Cmd + m, cmd = m.startFileViewRefreshCmd(m.chatColumnWidth()) + if cmd != nil { + loadCmds = append(loadCmds, cmd) + } + } + } + } + if len(loadCmds) > 0 { + return m, tea.Batch(loadCmds...) + } } return m, nil case swarmSessionsMsg: @@ -2950,7 +3012,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.prState = msg.state return m, nil case gitSweepMsg: - return m.handleGitSweepMsg(msg), nil + m = m.handleGitSweepMsg(msg) + if m.fileView.active && m.fileView.mode == fileViewFull { + var cmd tea.Cmd + m, cmd = m.startFileViewRefreshCmd(m.chatColumnWidth()) + return m, cmd + } + return m, nil case prWatcherStartedMsg: if msg.stop == nil { return m, nil @@ -2962,6 +3030,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case bashResultMsg: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: msg.output}) + if m.fileView.active && m.fileView.mode == fileViewFull { + return m.startFileViewRefreshCmd(m.chatColumnWidth()) + } return m, nil case providerModelsDiscoveredMsg: return m.applyProviderModelsDiscovered(msg), nil @@ -4520,10 +4591,21 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) { // local preview and never changes the active palette. text := "" m, text = m.handleThemeCommand(item.Value) + var loadCmd tea.Cmd + if m.fileView.active && m.fileView.mode == fileViewFull { + m, loadCmd = m.startFileViewLoadCmd(m.chatColumnWidth()) + } if validThemeMode(item.Value) && !strings.Contains(text, "could not save theme preference") { - return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess) + next, noticeCmd := m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess) + if loadCmd != nil { + return next, tea.Batch(noticeCmd, loadCmd) + } + return next, noticeCmd } m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text}) + if loadCmd != nil { + return m, loadCmd + } } return m, cmd } @@ -4927,10 +5009,21 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { } text := "" m, text = m.handleThemeCommand(command.text) + var loadCmd tea.Cmd + if m.fileView.active && m.fileView.mode == fileViewFull { + m, loadCmd = m.startFileViewLoadCmd(m.chatColumnWidth()) + } if validThemeMode(command.text) && !strings.Contains(text, "could not save theme preference") { - return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess) + next, noticeCmd := m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess) + if loadCmd != nil { + return next, tea.Batch(noticeCmd, loadCmd) + } + return next, noticeCmd } m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text}) + if loadCmd != nil { + return m, loadCmd + } return m, nil case commandImage: m = m.handleImageCommand(command.text) diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index f52c0ab10..dec5f270e 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -154,6 +154,11 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if next, cmd, handled := m.handlePetMouse(msg); handled { return next, cmd } + if mouseLeftPress(msg) && m.runDetailsOpen { + if path, ok := m.runDetailsFileAtMouse(msg); ok { + return m.selectFile(path) + } + } if mouseLeftPress(msg) { switch { case m.providerWizard != nil: diff --git a/internal/tui/run_details.go b/internal/tui/run_details.go index 26cf3ef8b..289da493c 100644 --- a/internal/tui/run_details.go +++ b/internal/tui/run_details.go @@ -26,11 +26,23 @@ func (m model) runDetailsOverlay(width int) string { overlayWidth := minInt(72, maxInt(40, width-8)) overlayWidth = minInt(overlayWidth, width) inner := maxInt(12, overlayWidth-4) - lines := m.runDetailsLines(inner) + lines := m.runDetailsLayout(inner).lines return centerRenderedBlock(styledBlockFillTitle(overlayWidth, "Run details", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width) } +type runDetailsLayout struct { + lines []string + fileHits []fileHit + fileStart int +} + func (m model) runDetailsLines(width int) []string { + return m.runDetailsLayout(width).lines +} + +func (m model) runDetailsLayout(width int) runDetailsLayout { + var out runDetailsLayout + out.fileStart = -1 lines := make([]string, 0, 16) appendSection := func(header string, rows []string) { if len(rows) == 0 { @@ -48,8 +60,27 @@ func (m model) runDetailsLines(width int) []string { appendSection(m.sidebarAgentHeader(width), m.sidebarAgentLines(width)) appendSection(m.sidebarPlanHeader(width), m.sidebarPlanLines(width)) - fileLines, _ := m.sidebarFileLines(width) - appendSection(m.sidebarFilesHeader(width), fileLines) + fileLines, hits := m.sidebarFileLines(width) + if len(fileLines) > 0 { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, m.sidebarFilesHeader(width)) + out.fileStart = len(lines) + kept := fileLines + if len(kept) > runDetailsMaxItems { + kept = append(append([]string(nil), fileLines[:runDetailsMaxItems-1]...), " "+zeroTheme.faint.Render("… more in transcript")) + var filtered []fileHit + for _, h := range hits { + if h.lineOffset < runDetailsMaxItems-1 { + filtered = append(filtered, h) + } + } + hits = filtered + } + lines = append(lines, kept...) + out.fileHits = hits + } appendSection(sidebarHeader("ACTIVITY", width), m.sidebarActivityLines(width, runDetailsMaxItems)) if tokens := m.sidebarTokenText(); tokens != "" { if len(lines) > 0 { @@ -58,8 +89,10 @@ func (m model) runDetailsLines(width int) []string { lines = append(lines, zeroTheme.faint.Render(tokens)) } if len(lines) == 0 { - return []string{zeroTheme.faint.Render("No active run details yet.")} + out.lines = []string{zeroTheme.faint.Render("No active run details yet.")} + return out } lines = append(lines, "", zeroTheme.faint.Render("Esc or Ctrl+B closes")) - return lines + out.lines = lines + return out } diff --git a/internal/tui/run_details_test.go b/internal/tui/run_details_test.go index 4666d1109..1b9697f45 100644 --- a/internal/tui/run_details_test.go +++ b/internal/tui/run_details_test.go @@ -64,3 +64,31 @@ func TestRunDetailsOverlayHidesBehindPicker(t *testing.T) { t.Fatalf("run details must yield to a picker that owns the keyboard, got:\n%s", got) } } + +func TestRunDetailsHitsAlignWithSidebar(t *testing.T) { + m := filesPanelTestModel() + inner := 40 + fileLines, hits := m.sidebarFileLines(inner) + if len(hits) == 0 || len(fileLines) == 0 { + t.Fatal("sidebar hits") + } + layout := m.runDetailsLayout(inner) + if layout.fileStart < 0 { + t.Fatal("run details dropped FILES block identity") + } + if len(layout.fileHits) != len(hits) && len(fileLines) <= runDetailsMaxItems { + t.Fatalf("hit count %d vs sidebar %d", len(layout.fileHits), len(hits)) + } + for _, h := range layout.fileHits { + idx := layout.fileStart + h.lineOffset + if idx < 0 || idx >= len(layout.lines) { + t.Fatalf("hit %q offset %d outside details", h.path, h.lineOffset) + } + if h.lineOffset < 0 || h.lineOffset >= len(fileLines) { + t.Fatalf("hit %q offset %d outside sidebar lines", h.path, h.lineOffset) + } + if layout.lines[idx] != fileLines[h.lineOffset] { + t.Fatalf("hit map != rendered row for %q", h.path) + } + } +} diff --git a/internal/tui/session_controls.go b/internal/tui/session_controls.go index acde990a7..a7526eac2 100644 --- a/internal/tui/session_controls.go +++ b/internal/tui/session_controls.go @@ -786,6 +786,23 @@ func (m model) handleRewindCommand(args string) (model, string) { // pre-rewind scrollback above it stays, as scrollback cannot be un-printed. m.resetFlushFrontier("· rewound ·") + // Workspace files have been restored to checkpoint state: invalidate the file view + // cache and trigger an authoritative reload if full file view is active. + defaultFileViewCache.clear() + if m.fileView.active { + m.fileView.requiredSourceRev++ + if m.fileView.mode == fileViewFull { + var cmd tea.Cmd + m, cmd = m.startFileViewRefreshCmd(m.chatColumnWidth()) + if cmd != nil { + msg := cmd() + if loadedMsg, ok := msg.(fileViewLoadedMsg); ok { + m, _ = m.handleFileViewLoaded(loadedMsg) + } + } + } + } + summary := fmt.Sprintf("Rewound to sequence %d\n%d file(s) restored, %d deleted, %d skipped.", target, report.FilesRestored, report.FilesDeleted, len(report.Skipped)) if len(report.Skipped) > 0 { diff --git a/internal/tui/syntax_highlight.go b/internal/tui/syntax_highlight.go index 083a9fae3..05510be91 100644 --- a/internal/tui/syntax_highlight.go +++ b/internal/tui/syntax_highlight.go @@ -203,7 +203,15 @@ func highlightCodeAuto(code []string, lang string, measure int) ([]string, bool) } func highlightCodeForPath(code []string, path string, measure int, bg color.Color) ([]string, bool) { - return highlightCodeWithLexer(cachedLexerForPath(path), code, measure, bg) + return highlightCodeForPathWithTheme(code, path, measure, bg, zeroTheme) +} + +func highlightCodeForPathWithTheme(code []string, path string, measure int, bg color.Color, theme tuiTheme) ([]string, bool) { + backgrounds := make([]color.Color, len(code)) + for index := range backgrounds { + backgrounds[index] = bg + } + return highlightCodeWithLexerThemeAndLineBackgrounds(cachedLexerForPath(path), code, measure, theme, backgrounds, nil) } // highlightShellCommand styles a one-line command for a tool-card heading. @@ -394,6 +402,10 @@ func highlightCodeWithLexerAndSpans(lexer chroma.Lexer, code []string, measure i } func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string, measure int, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) { + return highlightCodeWithLexerThemeAndLineBackgrounds(lexer, code, measure, zeroTheme, backgrounds, spans) +} + +func highlightCodeWithLexerThemeAndLineBackgrounds(lexer chroma.Lexer, code []string, measure int, theme tuiTheme, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) { if measure < 4 { return nil, false } @@ -476,7 +488,7 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string, line, column := 0, 0 for _, token := range iterator.Tokens() { - style := tokenStyle(token.Type) + style := tokenStyleForTheme(theme, token.Type) for index, part := range strings.Split(token.Value, "\n") { if index > 0 { flushLine() diff --git a/internal/tui/theme_select.go b/internal/tui/theme_select.go index f029ddaee..ef605d4bf 100644 --- a/internal/tui/theme_select.go +++ b/internal/tui/theme_select.go @@ -92,6 +92,9 @@ func applyTheme(mode themeMode, terminalDark bool) themeMode { if defaultRenderCache != nil { defaultRenderCache.clear() // old-palette entries must not be reused } + if defaultFileViewCache != nil { + defaultFileViewCache.clear() + } return resolved }