Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 25 additions & 27 deletions src/prism/views/highway/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -214,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).
Expand All @@ -250,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
Expand All @@ -278,19 +275,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)
Expand Down
61 changes: 43 additions & 18 deletions src/prism/views/highway/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand All @@ -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("✔"),
Expand All @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion src/prism/views/linear/linear-new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions src/prism/views/linear/render-line.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/prism/views/porthole/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -295,15 +299,15 @@ 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,
})

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)
Expand Down
30 changes: 28 additions & 2 deletions src/prism/views/porthole/model_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package porthole

import (
"fmt"
"io"
"time"

Expand Down Expand Up @@ -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))
Expand Down
8 changes: 7 additions & 1 deletion src/prism/widgets/status/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
12 changes: 8 additions & 4 deletions src/prism/widgets/status/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions src/prism/widgets/status/model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package status

import (
"fmt"
"time"

"charm.land/bubbles/v2/progress"
Expand Down Expand Up @@ -41,12 +42,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
Expand Down Expand Up @@ -136,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)
}
8 changes: 4 additions & 4 deletions src/prism/widgets/status/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"))
Expand Down
Loading
Loading