From 3564f62b2625f91508c6f1c1807e601a9e82365e Mon Sep 17 00:00:00 2001 From: plastikfan Date: Tue, 23 Jun 2026 11:39:37 +0100 Subject: [PATCH 1/2] fix(prism): ensure directory count is accurately represented (#646) --- src/prism/views/highway/model.go | 21 +++++---- src/prism/views/highway/model_test.go | 61 ++++++++++++++++++-------- src/prism/views/porthole/model.go | 10 +++-- src/prism/views/porthole/model_test.go | 30 ++++++++++++- src/prism/widgets/status/accessors.go | 8 +++- src/prism/widgets/status/messages.go | 12 +++-- src/prism/widgets/status/model.go | 14 +++--- src/prism/widgets/status/update.go | 20 ++++----- src/prism/widgets/status/view.go | 46 +++++++++++++------ 9 files changed, 156 insertions(+), 66 deletions(-) diff --git a/src/prism/views/highway/model.go b/src/prism/views/highway/model.go index 3e44355..6e66217 100644 --- a/src/prism/views/highway/model.go +++ b/src/prism/views/highway/model.go @@ -201,7 +201,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { totalForProgress := msg.TotalFiles + msg.TotalDirs if totalForProgress > 0 { var cmd tea.Cmd - m.status, cmd = m.dispatchStatus(status.TotalMsg{Total: int(totalForProgress)}) //nolint:gosec // cast ok + m.status, cmd = m.dispatchStatus(status.TotalMsg{ + Total: int(totalForProgress), //nolint:gosec // cast ok + TotalFiles: int(msg.TotalFiles), //nolint:gosec // cast ok + TotalDirs: int(msg.TotalDirs), //nolint:gosec // cast ok + }) return m, cmd } return m, nil @@ -278,19 +282,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.track, _ = m.dispatchTrack(track.CompleteMsg{}) // Forward the final counts and elapsed to the status - // widget. The widget owns the percent calculation and - // the isDone flag from here on. The Done field must - // include both files AND dirs because the progress - // total was seeded with TotalFiles + TotalDirs, and - // recomputePercent derives percent from done/total. + // widget. We use the track child's counts (not msg.Files/ + // msg.Dirs from agenor metrics) to guarantee the displayed + // counts are always the same source as what was shown during + // navigation — the track widget's unique MotifMsg path count. + // This eliminates the jump that occurred when CompleteMsg + // overwrote with a potentially different agenor metric value. cmds := make([]tea.Cmd, 0, 2) m.status, _ = m.dispatchStatus(status.CountsMsg{ - Files: msg.Files, Dirs: msg.Dirs, Errors: len(msg.Errs), + Files: m.track.Files(), Dirs: m.track.Dirs(), Errors: len(msg.Errs), }) m.status, _ = m.dispatchStatus(status.ElapsedMsg{Elapsed: msg.Elapsed}) var cmd tea.Cmd m.status, cmd = m.dispatchStatus(status.DoneMsg{ - Done: msg.Files + msg.Dirs, IsDone: true, Err: m.ErrMsg, + Done: m.track.Files() + m.track.Dirs(), IsDone: true, Err: m.ErrMsg, }) if cmd != nil { cmds = append(cmds, cmd) diff --git a/src/prism/views/highway/model_test.go b/src/prism/views/highway/model_test.go index 4810b6a..4da2215 100644 --- a/src/prism/views/highway/model_test.go +++ b/src/prism/views/highway/model_test.go @@ -601,21 +601,35 @@ var _ = Describe("Model.Update - CompleteMsg", func() { Expect(updated.Done).To(BeTrue()) }) - It("sets files, dirs, errors and elapsed on the status widget", func() { + It("sets files, dirs, errors and elapsed on the status widget from track counts", func() { m := baseModel(1) + // Send motif events to populate the track widget's counts. + // The status widget reads from track at completion time so + // CompleteMsg must not overwrite with different values. + for i := range 3 { + m, _ = update(m, contract.MotifMsg{ + Path: fmt.Sprintf("/r/f%d.txt", i), IsDir: false, + }) + } + for i := range 2 { + m, _ = update(m, contract.MotifMsg{ + Path: fmt.Sprintf("/r/d%d", i), IsDir: true, + }) + } + Expect(m.track.Files()).To(Equal(3)) + Expect(m.track.Dirs()).To(Equal(2)) + updated, cmd := update(m, contract.CompleteMsg{ - Files: 42, Dirs: 7, Elapsed: 5 * time.Second, + Files: 99, Dirs: 99, Elapsed: 5 * time.Second, }) - // Cmd is tea.Batch wrapping the status spring's - // first-frame cmd. _ = cmd - // The counts now live on the status widget. The - // accessors are public on status.Model so this - // white-box test can assert on them directly. - Expect(updated.status.Files()).To(Equal(42)) - Expect(updated.status.Dirs()).To(Equal(7)) + // Counts come from the track widget, NOT from + // CompleteMsg.Files/Dirs, so the status widget shows + // the same values as during navigation. + Expect(updated.status.Files()).To(Equal(3)) + Expect(updated.status.Dirs()).To(Equal(2)) Expect(updated.status.Errors()).To(Equal(0)) Expect(updated.status.Elapsed()).To(Equal(5 * time.Second)) }) @@ -641,31 +655,42 @@ var _ = Describe("Model.Update - CompleteMsg", func() { // (total=100 but only 85 items visited), the bar stays // at 85% and "✔ complete" does NOT show because progress // has not fully covered the preview estimate. + // + // Counts are now sourced from the track widget's unique + // MotifMsg path tracking (not from CompleteMsg.Files/Dirs), + // so the displayed values always match what was shown + // during navigation. m := baseModel(1) m, _ = update(m, tea.WindowSizeMsg{Width: 120}) // Seed the total via CensusMsg (preview estimate). updated, _ := update(m, contract.CensusMsg{TotalFiles: 100}) - // Drive done below total. - for i := range 85 { + // Drive done below total — send 80 file + 5 dir motiffs + // to match the expected completion breakdown. + for i := range 80 { updated, _ = update(updated, contract.MotifMsg{ Path: fmt.Sprintf("/r/f%d.txt", i), IsDir: false, }) } + for i := range 5 { + updated, _ = update(updated, contract.MotifMsg{ + Path: fmt.Sprintf("/r/d%d", i), IsDir: true, + }) + } Expect(updated.status.Done()).To(Equal(85)) Expect(updated.status.Percent()).To(Equal(85)) + Expect(updated.track.Files()).To(Equal(80)) + Expect(updated.track.Dirs()).To(Equal(5)) - updated, cmd := update(updated, contract.CompleteMsg{Files: 80, Dirs: 5}) + updated, cmd := update(updated, contract.CompleteMsg{Files: 0, Dirs: 0}) _ = cmd // Done = Files+Dirs = 85, ratio = 85/100 = 85%. - // The displayed file/dir counts use max(previous, msg) so - // the tracker's count (85 files from 85 file-MotifMsgs) - // is retained rather than overwritten by CompleteMsg's - // lower breakdown (80 files + 5 dirs). + // The displayed file/dir counts come from the track widget, + // not from CompleteMsg — so the 85 MotifMsgs are preserved. Expect(updated.status.IsDone()).To(BeTrue()) Expect(updated.status.Percent()).To(Equal(85), "bar shows the natural done/total ratio (85/100)") - Expect(updated.status.Files()).To(Equal(85)) + Expect(updated.status.Files()).To(Equal(80)) Expect(updated.status.Dirs()).To(Equal(5)) Expect(updated.status.View().Content).To(ContainSubstring("85%")) Expect(updated.status.View().Content).NotTo(ContainSubstring("✔"), @@ -680,7 +705,7 @@ var _ = Describe("Model.Update - CompleteMsg", func() { Path: fmt.Sprintf("/r/f%d.txt", i), IsDir: false, }) } - m2, _ = update(m2, contract.CompleteMsg{Files: 10, Dirs: 0}) + m2, _ = update(m2, contract.CompleteMsg{Files: 0, Dirs: 0}) Expect(m2.status.Percent()).To(Equal(100)) Expect(m2.status.View().Content).To(ContainSubstring("100%")) Expect(m2.status.View().Content).To(ContainSubstring("✔ complete")) diff --git a/src/prism/views/porthole/model.go b/src/prism/views/porthole/model.go index c1ed904..b793b2d 100644 --- a/src/prism/views/porthole/model.go +++ b/src/prism/views/porthole/model.go @@ -285,7 +285,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // every ContentLineMsg (file OR dir) increments done. total := int(msg.TotalFiles + msg.TotalDirs) //nolint:gosec // ok var cmd tea.Cmd - m.status, cmd = m.dispatchStatus(status.TotalMsg{Total: total}) + m.status, cmd = m.dispatchStatus(status.TotalMsg{ + Total: total, + TotalFiles: int(msg.TotalFiles), //nolint:gosec // ok + TotalDirs: int(msg.TotalDirs), //nolint:gosec // ok + }) if cmd != nil { return &m, tea.Batch(cmd) } @@ -295,7 +299,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ApplyCompletion(msg.Errs, msg.Elapsed) m.status, _ = m.dispatchStatus(status.CountsMsg{ - Files: msg.Files, Dirs: msg.Dirs, Errors: len(msg.Errs), + Files: m.countedFiles, Dirs: m.countedDirs, Errors: len(msg.Errs), }) m.status, _ = m.dispatchStatus(status.ElapsedMsg{ Elapsed: msg.Elapsed, @@ -303,7 +307,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.status, cmd = m.dispatchStatus(status.DoneMsg{ - Done: msg.Files + msg.Dirs, IsDone: true, Err: m.ErrMsg, + Done: m.countedFiles + m.countedDirs, IsDone: true, Err: m.ErrMsg, }) if cmd != nil { return &m, tea.Batch(cmd) diff --git a/src/prism/views/porthole/model_test.go b/src/prism/views/porthole/model_test.go index fad0d80..854dd6b 100644 --- a/src/prism/views/porthole/model_test.go +++ b/src/prism/views/porthole/model_test.go @@ -1,6 +1,7 @@ package porthole import ( + "fmt" "io" "time" @@ -151,11 +152,36 @@ var _ = Describe("Porthole Model", Ordered, func() { // Preview total (100) overcounts actual (85). m, _ = update(m, contract.CensusMsg{TotalFiles: 100}) - m, cmd := update(m, contract.CompleteMsg{Files: 80, Dirs: 5, Elapsed: 2 * time.Second}) + + // Send ContentLineMsgs to populate the model's live + // file/dir counts. CompleteMsg now forwards these + // instead of using its own msg.Files/msg.Dirs. + for i := range 80 { + m, _ = update(m, ContentLineMsg{ + Line: fmt.Sprintf("file %d", i), + Params: RenderParams{ + NodeParams: contract.NodeParams{IsDir: false}, + }, + }) + } + for i := range 5 { + m, _ = update(m, ContentLineMsg{ + Line: fmt.Sprintf("dir %d", i), + Params: RenderParams{ + NodeParams: contract.NodeParams{IsDir: true}, + }, + }) + } + Expect(m.countedFiles).To(Equal(80)) + Expect(m.countedDirs).To(Equal(5)) + + m, cmd := update(m, contract.CompleteMsg{ + Files: 0, Dirs: 0, Elapsed: 2 * time.Second, + }) _ = cmd Expect(m.status.IsDone()).To(BeTrue()) - // Done = Files + Dirs = 85, total = 100 → 85%. + // Done = countedFiles + countedDirs = 85, total = 100 → 85%. Expect(m.status.Percent()).To(Equal(85)) Expect(m.status.Files()).To(Equal(80)) Expect(m.status.Dirs()).To(Equal(5)) diff --git a/src/prism/widgets/status/accessors.go b/src/prism/widgets/status/accessors.go index 8e0b178..2fdde83 100644 --- a/src/prism/widgets/status/accessors.go +++ b/src/prism/widgets/status/accessors.go @@ -28,9 +28,15 @@ func (m Model) Elapsed() time.Duration { return m.elapsed } // Percent returns the current percent (0-100). func (m Model) Percent() int { return m.percent } -// Total returns the current total count. +// Total returns the current total count (files + dirs sum). func (m Model) Total() int { return m.total } +// TotalFiles returns the preview file count, if set. +func (m Model) TotalFiles() int { return m.totalFiles } + +// TotalDirs returns the preview directory count, if set. +func (m Model) TotalDirs() int { return m.totalDirs } + // Done returns the current done count. func (m Model) Done() int { return m.done } diff --git a/src/prism/widgets/status/messages.go b/src/prism/widgets/status/messages.go index 0fd036a..9c0adab 100644 --- a/src/prism/widgets/status/messages.go +++ b/src/prism/widgets/status/messages.go @@ -37,11 +37,15 @@ type PercentMsg struct { Percent int } -// TotalMsg declares the expected total count so subsequent -// IncDoneMsg / DoneMsg messages can recompute the percent. When -// Total is zero the percent is not recomputed from counts. +// TotalMsg declares the expected total file and directory counts +// from a preview traversal. Total is the sum (TotalFiles + TotalDirs) +// used for progress bar computation. TotalFiles and TotalDirs are the +// individual preview counts displayed in the status row so the user +// can compare live progress against the preview estimate. type TotalMsg struct { - Total int + Total int + TotalFiles int + TotalDirs int } // DoneMsg records the final counts and marks the widget as diff --git a/src/prism/widgets/status/model.go b/src/prism/widgets/status/model.go index 57d5b01..1b3161d 100644 --- a/src/prism/widgets/status/model.go +++ b/src/prism/widgets/status/model.go @@ -41,12 +41,14 @@ type Model struct { elapsed time.Duration // progress (embedded bubbles v2 progress). Drives the spring animation. - inner progress.Model - width int - percent int - total int - done int - hasTotal bool + inner progress.Model + width int + percent int + total int + totalFiles int + totalDirs int + done int + hasTotal bool // completion isDone bool diff --git a/src/prism/widgets/status/update.go b/src/prism/widgets/status/update.go index 9764520..f6b6f32 100644 --- a/src/prism/widgets/status/update.go +++ b/src/prism/widgets/status/update.go @@ -24,16 +24,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case CountsMsg: - // CountsMsg arrives from two sources that can disagree: - // • MotifMsg handler — track.Dirs() counts ALL unique - // dir MotifMsgs (including skip events) - // • CompleteMsg handler — msg.Dirs from the traversal - // result's DirsVisited, which only counts handler - // invocations (skips excluded) - // Using max() prevents the displayed count from dropping - // when CompleteMsg overwrites with a lower value. - m.files = max(m.files, msg.Files) - m.dirs = max(m.dirs, msg.Dirs) + // CountsMsg now comes from a single source (the view model's + // live tracking), so the values are always monotonically + // increasing during navigation and match the final values at + // completion. Direct assignment replaces the previous max() + // hack that masked a disagreement between MotifMsg-based and + // agenor-metric-based counts. + m.files = msg.Files + m.dirs = msg.Dirs m.errors = msg.Errors m.skipped = msg.Skipped return m, nil @@ -49,6 +47,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case TotalMsg: m.total, m.hasTotal = msg.Total, true + m.totalFiles = msg.TotalFiles + m.totalDirs = msg.TotalDirs m = m.recomputePercent() cmd := m.inner.SetPercent(float64(m.percent) / 100.0) return m, cmd diff --git a/src/prism/widgets/status/view.go b/src/prism/widgets/status/view.go index e1e7697..cdb1f90 100644 --- a/src/prism/widgets/status/view.go +++ b/src/prism/widgets/status/view.go @@ -44,26 +44,44 @@ func (m Model) View() tea.View { progressIdx := -1 var elapsedContent string - // Files segment + // Files segment — show preview total in parentheses when available if m.fields.ShowFiles { icon := m.styles.TreeIcons[contract.TreeIconFile] - label := compactLabel.Render(icon + " files:") - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) + if m.hasTotal && m.totalFiles > 0 { + label := compactLabel.Render(fmt.Sprintf("%s files(%d):", icon, m.totalFiles)) + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) + } else { + label := compactLabel.Render(icon + " files:") + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) + } } - // Dirs segment + // Dirs segment — show preview total in parentheses when available if m.fields.ShowDirs { icon := m.styles.TreeIcons[contract.TreeIconDirectory] - label := compactLabel.Render(icon + " dirs:") - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%3d", m.dirs)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) + if m.hasTotal && m.totalDirs > 0 { + label := compactLabel.Render(fmt.Sprintf("%s dirs(%d):", icon, m.totalDirs)) + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.dirs)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) + } else { + label := compactLabel.Render(icon + " dirs:") + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.dirs)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) + } } // Errors segment From 9a74266d9a79519d8bb7482e428da01416a42d1b Mon Sep 17 00:00:00 2001 From: plastikfan Date: Fri, 26 Jun 2026 13:15:25 +0100 Subject: [PATCH 2/2] fix(prism): ensure directory count is correct during navigation (#646) --- src/prism/views/highway/model.go | 31 ++++------ src/prism/views/linear/linear-new.go | 2 +- src/prism/views/linear/render-line.go | 4 +- src/prism/widgets/status/model.go | 10 ++++ src/prism/widgets/status/status_test.go | 8 +-- src/prism/widgets/status/update.go | 6 -- src/prism/widgets/status/view.go | 76 +++++++++---------------- src/prism/widgets/track/track_test.go | 8 +-- src/prism/widgets/track/update.go | 16 ++++-- 9 files changed, 70 insertions(+), 91 deletions(-) diff --git a/src/prism/views/highway/model.go b/src/prism/views/highway/model.go index 6e66217..72daad2 100644 --- a/src/prism/views/highway/model.go +++ b/src/prism/views/highway/model.go @@ -218,17 +218,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case contract.MotifMsg: - if m.Done { - // Traversal already completed; forward the motif - // to the track child for lane rendering/data but - // do NOT update status progress. Without this - // guard a late MotifMsg arriving after CompleteMsg - // dispatches an IncDoneMsg which recomputes the - // percent from done/total and overwrites the 100% - // that DoneMsg set. - m.track, _ = m.dispatchTrack(msg) - return m, nil - } // Track the pre-dispatch file/dir counts so we can // detect whether the motif was new (i.e. the track // child's dedup map saw the path for the first time). @@ -254,20 +243,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // New motif: forward the increment to the status - // widget so the percent / progress bar reflects the - // new total. We read the post-update counts from the + // New motif: forward the count update to the status + // widget. We read the post-update counts from the // track child (track.Files() / track.Dirs()). - cmds := make([]tea.Cmd, 0, 1) + // + // When the traversal has not yet completed we also + // forward an IncDoneMsg so the progress bar reflects + // the new ratio. After completion (m.Done) we skip + // IncDoneMsg to keep the progress bar frozen at the + // CompleteMsg snapshot; only the count display is + // updated so late workers' results are still visible. var cmd tea.Cmd - m.status, cmd = m.dispatchStatus(status.IncDoneMsg{N: 1}) - if cmd != nil { - cmds = append(cmds, cmd) + if !m.Done { + m.status, cmd = m.dispatchStatus(status.IncDoneMsg{N: 1}) } m.status, _ = m.dispatchStatus(status.CountsMsg{ Files: m.track.Files(), Dirs: m.track.Dirs(), Errors: m.Errors, }) - return m, tea.Batch(cmds...) + return m, cmd case contract.CompleteMsg: // Capture the first non-nil error message for the diff --git a/src/prism/views/linear/linear-new.go b/src/prism/views/linear/linear-new.go index c96eede..d200835 100644 --- a/src/prism/views/linear/linear-new.go +++ b/src/prism/views/linear/linear-new.go @@ -15,7 +15,7 @@ func New(palette contract.Palette, writer io.Writer, opts ...Option) (contract.R return nil, fmt.Errorf("linear.New: %w", err) } - width := 90 + width := 104 if w, _, err := term.GetSize(0); err == nil && w > 0 { width = w } diff --git a/src/prism/views/linear/render-line.go b/src/prism/views/linear/render-line.go index 5a98c1c..19f4d2b 100644 --- a/src/prism/views/linear/render-line.go +++ b/src/prism/views/linear/render-line.go @@ -131,8 +131,8 @@ func buildBranchPrefix(depth uint, isLast bool, branchStack []bool, treeIcons co return "" } - vertW := len([]rune(treeIcons[contract.TreeIconBranchVertical])) - indentW := len([]rune(treeIcons[contract.TreeIconBranchIndent])) + vertW := lipgloss.Width(treeIcons[contract.TreeIconBranchVertical]) + indentW := lipgloss.Width(treeIcons[contract.TreeIconBranchIndent]) colW := vertW + indentW var b strings.Builder diff --git a/src/prism/widgets/status/model.go b/src/prism/widgets/status/model.go index 1b3161d..3296aac 100644 --- a/src/prism/widgets/status/model.go +++ b/src/prism/widgets/status/model.go @@ -1,6 +1,7 @@ package status import ( + "fmt" "time" "charm.land/bubbles/v2/progress" @@ -138,3 +139,12 @@ func (m Model) IsDone() bool { return m.isDone } // constant. Host views (highway, porthole) consult this to budget // vertical space for the status row without re-rendering. func (m Model) Height() int { return 1 } + +// labelText returns the label string for a status segment. +// Shows the total in parentheses when hasTotal is true and total > 0. +func (m Model) labelText(icon, suffix string, total int, hasTotal bool) string { + if hasTotal && total > 0 { + return fmt.Sprintf("%s%s(%d):", icon, suffix, total) + } + return fmt.Sprintf("%s%s:", icon, suffix) +} diff --git a/src/prism/widgets/status/status_test.go b/src/prism/widgets/status/status_test.go index 04b86c9..b45b4cf 100644 --- a/src/prism/widgets/status/status_test.go +++ b/src/prism/widgets/status/status_test.go @@ -85,16 +85,16 @@ var _ = Describe("Render", func() { ShowElapsed: true, } - // Width 90 (not 80) — 4 icon-based segments + elapsed - // need ~87 cells total with compact labels, and the Row - // drops the right-zone (elapsed) when it does not fit. + // Width 104 — 4 segments (files fixed at 16, dirs fixed at 14, + // errors and skipped natural) + elapsed need 99 cells to + // avoid the Row dropping the right-zone. output := status.Render(status.Config{ Files: 42, Dirs: 7, Errors: 0, Skipped: 3, Elapsed: 5 * time.Second, - }, styles, fields, 90) + }, styles, fields, 104) Expect(output).To(ContainSubstring("🔖 files:")) Expect(output).To(ContainSubstring("📁 dirs:")) diff --git a/src/prism/widgets/status/update.go b/src/prism/widgets/status/update.go index f6b6f32..d09d3cd 100644 --- a/src/prism/widgets/status/update.go +++ b/src/prism/widgets/status/update.go @@ -24,12 +24,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case CountsMsg: - // CountsMsg now comes from a single source (the view model's - // live tracking), so the values are always monotonically - // increasing during navigation and match the final values at - // completion. Direct assignment replaces the previous max() - // hack that masked a disagreement between MotifMsg-based and - // agenor-metric-based counts. m.files = msg.Files m.dirs = msg.Dirs m.errors = msg.Errors diff --git a/src/prism/widgets/status/view.go b/src/prism/widgets/status/view.go index cdb1f90..c612642 100644 --- a/src/prism/widgets/status/view.go +++ b/src/prism/widgets/status/view.go @@ -32,56 +32,42 @@ type segment struct { func (m Model) View() tea.View { borderStyle := m.styles.BorderStyle - // Use compact labels with natural width instead of the themed - // 16-cell minimum. The themed width is designed for multi-line - // alignment in other views; on a single-row status line it only - // wastes space and causes overflow. + // Use per-segment fixed-width label styles so the files and dirs + // labels always occupy the same number of cells regardless of + // whether the total is shown in parentheses ("files:") or not + // ("files(20):"). This prevents the layout from jumping ~5 cells + // when CensusMsg arrives, which shifts the dirs value left/right + // on every frame. Skipped and elapsed don't have a hasTotal + // transition, so they use natural width (Width(0)) to avoid the + // theme's 16-cell default padding. compactLabel := m.styles.SummaryLabelStyle.Width(0) + filesLabel := m.styles.SummaryLabelStyle.Width(16) + dirsLabel := m.styles.SummaryLabelStyle.Width(14) segments := make([]segment, 0, 9) - // progressIdx tracks at which index (if any) the progress - // segment was appended, so we can conditionally drop it. progressIdx := -1 var elapsedContent string - // Files segment — show preview total in parentheses when available + // Files segment if m.fields.ShowFiles { icon := m.styles.TreeIcons[contract.TreeIconFile] - if m.hasTotal && m.totalFiles > 0 { - label := compactLabel.Render(fmt.Sprintf("%s files(%d):", icon, m.totalFiles)) - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) - } else { - label := compactLabel.Render(icon + " files:") - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) - } + label := filesLabel.Render(m.labelText(icon, " files", m.totalFiles, m.hasTotal)) + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.files)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) } - // Dirs segment — show preview total in parentheses when available + // Dirs segment if m.fields.ShowDirs { icon := m.styles.TreeIcons[contract.TreeIconDirectory] - if m.hasTotal && m.totalDirs > 0 { - label := compactLabel.Render(fmt.Sprintf("%s dirs(%d):", icon, m.totalDirs)) - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.dirs)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) - } else { - label := compactLabel.Render(icon + " dirs:") - value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.dirs)) - segments = append(segments, segment{ - content: " " + label + " " + value + " ", - separator: true, - }) - } + label := dirsLabel.Render(m.labelText(icon, " dirs", m.totalDirs, m.hasTotal)) + value := m.styles.SummaryValueStyle.Render(fmt.Sprintf("%4d", m.dirs)) + segments = append(segments, segment{ + content: " " + label + " " + value + " ", + separator: true, + }) } // Errors segment @@ -95,7 +81,7 @@ func (m Model) View() tea.View { }) } - // Skipped segment + // Skipped segment (no hasTotal transition, uses natural width) if m.fields.ShowSkipped { icon := m.styles.TreeIcons[contract.TreeIconSkipped] label := compactLabel.Render(icon + " skipped:") @@ -106,7 +92,7 @@ func (m Model) View() tea.View { }) } - // Progress segment - driven by the embedded bubbles progress. + // Progress segment if m.fields.ShowProgress && (m.percent > 0 || (m.hasTotal && m.total > 0)) { progressView := m.inner.View() if progressView != "" { @@ -129,14 +115,6 @@ func (m Model) View() tea.View { } msg = " " + m.styles.ErrorStyle.Render(label) + " " } else if m.hasTotal && m.percent >= 100 { - // "✔ complete" appears only when ALL of: - // 1. the last worker has finished (isDone), - // 2. a real total was provided (hasTotal), and - // 3. the progress bar has reached 100% (done >= total). - // Without a real total, PercentMsg drives the bar from - // elapsed time and can reach 100% before navigation is - // actually complete. Requiring hasTotal prevents the - // message from appearing with fake progress data. msg = " " + m.styles.ProgressStyle.Render("✔ complete") + " " } if msg != "" { @@ -147,7 +125,7 @@ func (m Model) View() tea.View { } } - // Elapsed segment (always last, right-aligned) + // Elapsed segment (always last, right-aligned, no hasTotal transition) if m.fields.ShowElapsed { icon := m.styles.TreeIcons[contract.TreeIconElapsed] label := compactLabel.Render(icon + " elapsed:") diff --git a/src/prism/widgets/track/track_test.go b/src/prism/widgets/track/track_test.go index db80c96..034d69c 100644 --- a/src/prism/widgets/track/track_test.go +++ b/src/prism/widgets/track/track_test.go @@ -419,7 +419,7 @@ var _ = Describe("Model.Update - CensusMsg", func() { // --------------------------------------------------------------------------- var _ = Describe("Model.Update - CompleteMsg", func() { - It("clears the counted map so subsequent motifs increment again", func() { + It("preserves the dedup map so late motifs do NOT increment counters again", func() { m := baseModel(1) updated, _ := update(m, contract.MotifMsg{ Path: "/root/a.txt", IsDir: false, @@ -427,12 +427,12 @@ var _ = Describe("Model.Update - CompleteMsg", func() { Expect(updated.files).To(Equal(1)) updated, _ = update(updated, CompleteMsg{}) - // Same path after CompleteMsg: dedup map is empty, - // so this is treated as a new path. + // Same path after CompleteMsg: dedup map is intact, + // so this is treated as a duplicate and NOT counted. updated, _ = update(updated, contract.MotifMsg{ Path: "/root/a.txt", IsDir: false, }) - Expect(updated.files).To(Equal(2)) + Expect(updated.files).To(Equal(1)) }) It("returns no cmd", func() { diff --git a/src/prism/widgets/track/update.go b/src/prism/widgets/track/update.go index 6d3b319..f9c75fe 100644 --- a/src/prism/widgets/track/update.go +++ b/src/prism/widgets/track/update.go @@ -73,12 +73,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case CompleteMsg: - // Flush signal. Clear the dedup map so any further - // MotifMsg is a no-op dedup-wise. (We intentionally do - // NOT add a guard that drops motifs received after - // CompleteMsg, to preserve the pre-refactor behaviour - // where late motifs were still applied to the lane.) - m.counted = make(map[string]bool) + // Flush signal. Do NOT clear the dedup map — late + // MotifMsg that arrive after CompleteMsg must still be + // de-duped against previously-seen paths so the track + // child's file/dir counters stay frozen. Without this, + // a MotifMsg for an already-counted path increments + // the counter a second time, making the internal count + // inconsistent with the status widget's frozen display. + // MotifMsg are still forwarded to lanes (via the m.Done + // guard in highway model.go) for rendering; only the + // counter increment is suppressed by the intact map. // Freeze all lanes: the traversal is done, so no more // MotifMsg will arrive to re-activate any lane. Setting