- commit
- 1b2eedc60c2e328f6b080ea49a383ce208ee7127
- parent
- a48fbb257a6cd55bbb5d862f9a124557d57af2a7
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 37 | ++++++++-- |
| M | internal/ | 28 | ++++++-- |
| M | internal/ | 13 | ++- |
| M | internal/ | 18 | +++++ |
| M | internal/ | 35 | +++++---- |
| M | internal/ | 36 | ++++++++++ |
| M | internal/ | 46 | ++++++++---- |
| M | internal/ | 42 | ++++++++---- |
| A | internal/ | 112 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 6 | +- |
@@ -5,12 +5,36 @@ split while agents work. It shows each change's artifacts, task progress, and sp
55 activity feed of what was touched.66 77 ```8- lazyopenspec │ ~/code/tally │ main +3 uncommitted │ 5 active, 1 ready to archive FOLLOW │ ● last edit just now9-╭─[1] Changes─────────────────────────────╮╭─Change harden-session-security───────────────────────────────────────╮10-│ add-csv-export-filters PDST ▰▰▰▰▰ 8/8││ harden-session-security spec-driven edited 2s ago │11-│▌● harden-session-security M PDST ▰▰▱▱ 5/10││ ✓ proposal ✓ design ✓ specs ✓ tasks ▰▰▰▰▰▱▱▱▱▱▱▱ 5/10 Δ +1 ~1 -1 →1 │12-╭─[4] Activity────────────────────────────╮│ proposal.md │ design.md │ ● tasks.md M │ Δ user-auth │13-│ 14:05:25 ▸ harden-session… tasks 4/10 → 5/10 ...8+ lazyopenspec │ /private/tmp/los-shot │ main +1 uncommitted │ 5 active, 1 rea… FOLLOW │ ● last edit just now9+╭─[1] Changes──────────────────────────────╮╭─Change harden-session-security─────────────────────────────────────────╮10+│ add-csv-export-filters PDST ▰▰▰▰▰ 8/8││ harden-session-security spec-driven edited just now │11+│ add-due-dates PDST ▱▱▱▱▱ ││ A September penetration test flagged our 30-day idle sessions (90 days…│12+│ add-task-search PDST ▱▱▱▱▱ ││ ✓ proposal ✓ design ✓ specs ✓ tasks ▰▰▰▰▰▰▱▱▱▱▱▱ 5/10 Δ +1 ~1 -1 …│13+│ add-workspace-invites PDST ▱▱▱▱▱ ││ proposal.md │ design.md │ ● tasks.md M │ Δ user-auth │14+│▌● harden-session-secu… M PDST ▰▰▱▱▱ 5/10││────────────────────────────────────────────────────────────────────────│15+│ ││ │16+│ ││ ## 1. Session storage │17+│ ││ │18+│ ││ [✓] 1.1 Add nullable absolute_expires_at to sessions │19+│ ││ (migration 0042) │20+│ ││ [✓] 1.2 Backfill absolute_expires_at = now() + interval '7 │21+│ ││ days' in batches of 5,000 │22+│ ││ │23+╰─5 of 5───────────────────────────────────╯│ ## 2. Expiry and revocation │24+╭─[2] Specs────────────────────────────────╮│ │25+│ data-export Δ1 3 req││ [✓] 2.1 Enforce 12h idle timeout in session middleware │26+│ notifications 2 req││ [✓] 2.2 Enforce 7-day absolute lifetime (treat NULL as │27+│ task-management Δ1 4 req││ created_at + 7d ) │28+│ user-auth Δ1 4 req││ [✓] Unit test: session used 1h ago but created 8 days ago is │29+╰─1 of 4───────────────────────────────────╯│ rejected │30+╭─[3] Archive──────────────────────────────╮│ [ ] Unit test: NULL absolute_expires_at falls back to │31+│ 09-23 add-email-notif… PDST ▰▰▰▰▰ 7/7││ created_at │32+│ 08-19 add-user-auth PDST ▰▰▰▰▰ 10/10││ [ ] 2.3 Add GET /account/sessions and DELETE │33+╰─1 of 2───────────────────────────────────╯│ /account/sessions/:id │34+╭─[4] Activity─────────────────────────────╮│ [ ] 2.4 Build Active sessions page with "This device" label │35+│ 14:26:02 ▸ harden-sessio… tasks 4/10 → 5…││ │36+╰─1 of 1───────────────────────────────────╯╰─ 0%───────────────────────────────────────────────────────────────────╯37+ j/k move enter read [ ] tab d diff e edit s sort f follow z zoom ? help q quit1438 ```1539 1640 ## Install / run@@ -38,6 +62,7 @@ Requires Go 1.27+. `git` is optional: without it, the uncommitted markers and di
3862 for that file (untracked files show in full), so you see what an agent changed since the last commit.3963 4064 Freshness: `●` bright = edited in the last 15s, dim = last 2 minutes. `M` / `+` = uncommitted / untracked in git.65+Git is run with `GIT_OPTIONAL_LOCKS=0`, so the viewer never holds `.git/index.lock` while agents are committing.4166 4267 ## Keys4368 @@ -5,6 +5,9 @@ package gitx
55 import (66 "bytes"77 "context"8+ "errors"9+ "fmt"10+ "os"811 "os/exec"912 "path/filepath"1013 "strings"@@ -27,9 +30,16 @@ func run(dir string, args ...string) (string, error) {
2730 defer cancel()2831 cmd := exec.CommandContext(ctx, "git", args...)2932 cmd.Dir = dir30- var out bytes.Buffer33+ // Never take .git/index.lock: status/diff would otherwise refresh the34+ // index and make an agent's concurrent `git add`/`git commit` fail.35+ cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0")36+ var out, errOut bytes.Buffer3137 cmd.Stdout = &out38+ cmd.Stderr = &errOut3239 err := cmd.Run()40+ if err != nil && errOut.Len() > 0 {41+ err = fmt.Errorf("%w: %s", err, strings.TrimSpace(errOut.String()))42+ }3343 return out.String(), err3444 }3545 @@ -62,8 +72,8 @@ func Read(root, openspecDir string) Status {
6272 continue6373 }6474 code, path := e[:2], e[3:]65- if code[0] == 'R' || code[0] == 'C' {66- i++ // -z puts the rename source in the next field75+ if strings.ContainsAny(code, "RC") {76+ i++ // -z puts the rename/copy source in the next field6777 }6878 s.Dirty[filepath.Join(top, filepath.FromSlash(path))] = code6979 }@@ -73,13 +83,19 @@ func Read(root, openspecDir string) Status {
7383 // Diff returns the working-tree diff of path against HEAD. Untracked files7484 // are diffed against /dev/null so new artifacts show up in full.7585 func Diff(root, path string, untracked bool) (string, error) {86+ if !untracked {87+ if _, err := run(root, "rev-parse", "--verify", "--quiet", "HEAD"); err != nil {88+ untracked = true // no commits yet: show the whole file as added89+ }90+ }7691 if untracked {77- out, err := run(root, "diff", "--no-color", "--no-index", "--", "/dev/null", path)92+ out, err := run(root, "diff", "--no-color", "--no-ext-diff", "--no-index", "--", "/dev/null", path)7893 // --no-index exits 1 when files differ.79- if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {94+ var exitErr *exec.ExitError95+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {8096 err = nil8197 }8298 return out, err8399 }84- return run(root, "diff", "--no-color", "HEAD", "--", path)100+ return run(root, "diff", "--no-color", "--no-ext-diff", "HEAD", "--", path)85101 }@@ -51,12 +51,17 @@ func Diff(prev, next *Snapshot, now time.Time) []Event {
5151 5252 prevChanges := indexChanges(prev.Changes)5353 nextChanges := indexChanges(next.Changes)54- // Only archive entries that are new in this snapshot can explain a change55- // disappearing; older archives may reuse the same name.54+ // Map each archived name to the entry that best explains an active change55+ // disappearing: prefer one new in this snapshot (older archives may reuse56+ // the name), else the newest, which covers copy-then-delete archiving57+ // where the copy landed in an earlier snapshot.5658 prevArchive := indexChanges(prev.Archive)5759 archivedNames := map[string]string{}58- for _, c := range next.Archive {59- if _, old := prevArchive[c.ID]; !old {60+ for _, c := range next.Archive { // newest first61+ cur, seen := archivedNames[c.Name]62+ _, curOld := prevArchive[cur]63+ _, isOld := prevArchive[c.ID]64+ if !seen || (curOld && !isOld) {6065 archivedNames[c.Name] = c.ID6166 }6267 }@@ -338,3 +338,21 @@ func TestScanMissingDir(t *testing.T) {
338338 t.Errorf("Changed(fixture -> empty) = %d paths, want %d", len(back), len(full))339339 }340340 }341+342+// Archiving by copy-then-delete: the archive copy shows up in one snapshot343+// while the change is still active, and the change vanishes in the next.344+func TestDiffArchiveByCopyThenDelete(t *testing.T) {345+ root := copyFixture(t)346+ changes := filepath.Join(root, "openspec", "changes")347+ if err := os.CopyFS(filepath.Join(changes, "archive", "2026-10-13-add-due-dates"), os.DirFS(filepath.Join(changes, "add-due-dates"))); err != nil {348+ t.Fatal(err)349+ }350+ prev := mustLoad(t, root)351+ if err := os.RemoveAll(filepath.Join(changes, "add-due-dates")); err != nil {352+ t.Fatal(err)353+ }354+ now := time.Now()355+ assertEvents(t, Diff(prev, mustLoad(t, root), now), now, []ev{356+ {EventArchived, ItemArchive, "2026-10-13-add-due-dates", "", "archived"},357+ })358+}@@ -83,12 +83,9 @@ func loadSpecs(dir string) ([]Spec, error) {
8383 var specs []Spec8484 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {8585 if err != nil {86- if errors.Is(err, fs.ErrNotExist) {87- return nil88- }89- return err86+ return skip(d) // unreadable or vanished mid-walk; show the rest9087 }91- if d.IsDir() || d.Name() != "spec.md" {88+ if !d.Type().IsRegular() || d.Name() != "spec.md" {9289 return nil9390 }9491 rel, err := filepath.Rel(dir, filepath.Dir(path))@@ -156,19 +153,22 @@ func loadChange(dir string, archived bool) (Change, error) {
156153 157154 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {158155 if err != nil {159- if errors.Is(err, fs.ErrNotExist) {160- return nil161- }162- return err156+ return skip(d) // unreadable or vanished mid-walk; show the rest163157 }164- if d.IsDir() {165- return nil158+ if !d.Type().IsRegular() {159+ return nil // directories, and FIFOs/sockets that would block a read166160 }167161 rel, err := filepath.Rel(dir, path)168162 if err != nil {169- return err163+ return nil170164 }171165 rel = filepath.ToSlash(rel)166+ if rel != ".openspec.yaml" && !strings.HasSuffix(rel, ".md") {167+ if fi, err := d.Info(); err == nil && fi.ModTime().After(c.ModTime) {168+ c.ModTime = fi.ModTime()169+ }170+ return nil171+ }172172 src, mod, err := readFile(path)173173 if err != nil {174174 return nil@@ -180,9 +180,6 @@ func loadChange(dir string, archived bool) (Change, error) {
180180 c.Schema = ParseSchema(src)181181 return nil182182 }183- if !strings.HasSuffix(rel, ".md") {184- return nil185- }186183 a := Artifact{Kind: classify(rel), Rel: rel, Path: path, ModTime: mod, Content: src}187184 switch a.Kind {188185 case KindProposal:@@ -205,6 +202,14 @@ func loadChange(dir string, archived bool) (Change, error) {
205202 return c, err206203 }207204 205+// skip tells WalkDir to continue past an entry it could not read.206+func skip(d fs.DirEntry) error {207+ if d != nil && d.IsDir() {208+ return fs.SkipDir209+ }210+ return nil211+}212+208213 func classify(rel string) ArtifactKind {209214 switch rel {210215 case "proposal.md":@@ -5,6 +5,7 @@ import (
55 "path/filepath"66 "reflect"77 "strings"8+ "syscall"89 "testing"910 "time"1011 )@@ -406,3 +407,38 @@ func TestFindRoot(t *testing.T) {
406407 }407408 })408409 }410+411+// A FIFO in a change dir must not block Load: reading it would hang forever.412+func TestLoadIgnoresFIFO(t *testing.T) {413+ root := copyFixture(t)414+ if err := syscall.Mkfifo(filepath.Join(root, "openspec", "changes", "add-due-dates", "pipe.md"), 0o644); err != nil {415+ t.Skip("mkfifo unsupported:", err)416+ }417+ done := make(chan *Snapshot, 1)418+ go func() { done <- mustLoad(t, root) }()419+ select {420+ case s := <-done:421+ if len(s.Changes) != 5 {422+ t.Errorf("Changes = %v, want the 5 fixture changes", changeIDs(s.Changes))423+ }424+ case <-time.After(5 * time.Second):425+ t.Fatal("Load blocked on a FIFO")426+ }427+}428+429+// One unreadable directory hides only its own contents.430+func TestLoadSkipsUnreadableDirs(t *testing.T) {431+ if os.Geteuid() == 0 {432+ t.Skip("root ignores directory permissions")433+ }434+ root := copyFixture(t)435+ locked := filepath.Join(root, "openspec", "specs", "notifications")436+ if err := os.Chmod(locked, 0); err != nil {437+ t.Fatal(err)438+ }439+ t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })440+ s := mustLoad(t, root)441+ if len(s.Specs) != 3 || len(s.Changes) != 5 {442+ t.Errorf("Specs = %d, Changes = %d; want 3 readable specs and 5 changes", len(s.Specs), len(s.Changes))443+ }444+}@@ -147,7 +147,11 @@ func (m *Model) syncMain() tea.Cmd {
147147 dirty := m.git.Dirty[tb.path]148148 149149 viewKey := fmt.Sprintf("%s|%s|%t", t.key(), tb.key, m.diffMode)150- contentKey := fmt.Sprintf("%s|%d|%s|%dx%d|%t", viewKey, tb.mod.UnixNano(), dirty, w, vpH, m.theme.isDark)150+ state := "loading"151+ if m.snap != nil {152+ state = fmt.Sprint(m.snap.Exists)153+ }154+ contentKey := fmt.Sprintf("%s|%d|%s|%dx%d|%t|%s", viewKey, tb.mod.UnixNano(), dirty, w, vpH, m.theme.isDark, state)151155 if contentKey == m.mainKey && m.vp.Width() == w && m.vp.Height() == vpH {152156 return nil153157 }@@ -188,21 +192,26 @@ func (m *Model) diffContent(tb tab, dirty string) (string, tea.Cmd) {
188192 return m.theme.muted.Render("\n No uncommitted changes to " + m.relPath(tb.path) + "."), nil189193 }190194 key := fmt.Sprintf("%s|%d|%s", tb.path, tb.mod.UnixNano(), dirty)191- if d, ok := m.diffs[key]; ok {192- if d.err != nil {193- return m.theme.remSt.Render("\n git diff failed: " + d.err.Error()), nil195+ prev, havePrev := m.diffs[tb.path]196+ var cmd tea.Cmd197+ if (!havePrev || prev.key != key) && !m.diffReq[key] {198+ m.diffReq[key] = true199+ root, path := m.opts.Root, tb.path200+ untracked := dirty == "??"201+ cmd = func() tea.Msg {202+ text, err := gitx.Diff(root, path, untracked)203+ return diffMsg{path: path, key: key, text: text, err: err}194204 }195- return m.colorDiff(d.text), nil196205 }197- if m.diffReq[key] {198- return m.theme.muted.Render("\n Loading diff…"), nil199- }200- m.diffReq[key] = true201- root, path := m.opts.Root, tb.path202- untracked := dirty == "??"203- return m.theme.muted.Render("\n Loading diff…"), func() tea.Msg {204- text, err := gitx.Diff(root, path, untracked)205- return diffMsg{key: key, text: text, err: err}206+ switch {207+ case !havePrev:208+ // Nothing to show yet. A stale diff for the same file is better than a209+ // placeholder: it keeps the scroll position while the new one loads.210+ return m.theme.muted.Render("\n Loading diff…"), cmd211+ case prev.err != nil:212+ return m.theme.remSt.Render("\n git diff failed: " + prev.err.Error()), cmd213+ default:214+ return m.colorDiff(prev.text), cmd206215 }207216 }208217 @@ -283,7 +292,11 @@ func (m Model) changeHeader(c spec.Change, w int) []string {
283292 line1 := spread(" "+th.bold.Render(c.Name), th.muted.Render(right)+" ", w)284293 285294 summary := c.Summary286- if summary == "" {295+ switch {296+ case summary != "":297+ case c.Has(spec.KindProposal):298+ summary = "(proposal is empty so far)"299+ default:287300 summary = "(no proposal yet)"288301 }289302 line2 := " " + th.muted.Render(summary)@@ -443,6 +456,9 @@ func (r *mdRenderer) render(path string, mod time.Time, src string, width int, s
443456 if err != nil {444457 return src445458 }459+ if len(r.renderers) >= 4 { // one per width; drop old sizes after resizes460+ clear(r.renderers)461+ }446462 r.renderers[rkey] = tr447463 }448464 out, err := tr.Render(src)@@ -55,7 +55,8 @@ type (
5555 }5656 gitMsg gitx.Status5757 diffMsg struct {58- key string58+ path string59+ key string // path + mtime + git status the diff was taken at5960 text string6061 err error6162 }@@ -85,6 +86,8 @@ type Model struct {
8586 err error8687 git gitx.Status8788 gitAt time.Time89+ gitBusy bool // a git read is in flight90+ gitAgain bool // another read was requested while busy8891 events []spec.Event // newest first8992 now time.Time9093 lastSeen time.Time // time of the most recent event@@ -95,8 +98,8 @@ type Model struct {
9598 mainKey string // identity of the content currently in vp9699 viewKey string // target/tab/mode shown in vp, for scroll memory97100 renderer *mdRenderer98- diffs map[string]diffMsg99- diffReq map[string]bool101+ diffs map[string]diffMsg // latest diff per path102+ diffReq map[string]bool // diff keys currently being fetched100103 }101104 102105 // New constructs the root model.@@ -122,7 +125,7 @@ func New(opts Options) Model {
122125 123126 // Init starts the first load, git read, and poll loop.124127 func (m Model) Init() tea.Cmd {125- return tea.Batch(tea.RequestBackgroundColor, m.scanCmd(), m.gitCmd())128+ return tea.Batch(tea.RequestBackgroundColor, m.scanCmd())126129 }127130 128131 func (m Model) scanCmd() tea.Cmd {@@ -138,7 +141,14 @@ func (m Model) loadCmd() tea.Cmd {
138141 }139142 }140143 141-func (m Model) gitCmd() tea.Cmd {144+// refreshGit starts a git read unless one is in flight, in which case it145+// queues exactly one more so results never arrive out of order.146+func (m *Model) refreshGit() tea.Cmd {147+ if m.gitBusy {148+ m.gitAgain = true149+ return nil150+ }151+ m.gitBusy, m.gitAt = true, m.now142152 root := m.opts.Root143153 dir := filepath.Join(root, spec.DirName)144154 return func() tea.Msg { return gitMsg(gitx.Read(root, dir)) }@@ -154,6 +164,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154164 switch msg := msg.(type) {155165 case tea.WindowSizeMsg:156166 m.width, m.height = msg.Width, msg.Height167+ m.clampAll()157168 158169 case tea.BackgroundColorMsg:159170 m.theme = newTheme(msg.IsDark())@@ -174,22 +185,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174185 m.now = time.Time(msg)175186 cmds = append(cmds, m.scanCmd())176187 if m.now.Sub(m.gitAt) >= gitEvery {177- m.gitAt = m.now178- cmds = append(cmds, m.gitCmd())188+ cmds = append(cmds, m.refreshGit())179189 }180190 181191 case loadedMsg:182192 m.loading = false183193 m.err = msg.err184- if msg.err == nil {194+ if msg.err != nil {195+ m.fp = nil // retry on the next poll even if nothing else changes196+ } else {185197 cmds = append(cmds, m.applySnapshot(msg.snap)...)186198 }187199 188200 case gitMsg:189201 m.git = gitx.Status(msg)202+ m.gitBusy = false203+ if m.gitAgain {204+ m.gitAgain = false205+ cmds = append(cmds, m.refreshGit())206+ }190207 191208 case diffMsg:192- m.diffs[msg.key] = msg209+ m.diffs[msg.path] = msg193210 delete(m.diffReq, msg.key)194211 m.mainKey = ""195212 @@ -246,10 +263,7 @@ func (m *Model) applySnapshot(next *spec.Snapshot) []tea.Cmd {
246263 m.followEvent(events[len(events)-1])247264 }248265 m.clampAll()249- if prev != nil {250- return []tea.Cmd{m.gitCmd()}251- }252- return nil266+ return []tea.Cmd{m.refreshGit()}253267 }254268 255269 // followEvent moves the selection to the item an event refers to.@@ -428,7 +442,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
428442 m.clampAll()429443 case "r":430444 m.loading = true431- return tea.Batch(m.loadCmd(), m.gitCmd())445+ return tea.Batch(m.loadCmd(), m.refreshGit())432446 case "c":433447 m.events = nil434448 m.cursors[panelActivity] = 0@@ -0,0 +1,112 @@
1+package ui2+3+import (4+ "path/filepath"5+ "strings"6+ "testing"7+ "time"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/dami/lazyopenspec/internal/spec"13+)14+15+func fixtureModel(t *testing.T, w, h int) Model {16+ t.Helper()17+ root, err := filepath.Abs("../../testdata/fixture")18+ if err != nil {19+ t.Fatal(err)20+ }21+ snap, err := spec.Load(root)22+ if err != nil {23+ t.Fatal(err)24+ }25+ m := New(Options{Root: root, Interval: time.Hour})26+ return step(t, step(t, m, tea.WindowSizeMsg{Width: w, Height: h}), loadedMsg{snap: snap})27+}28+29+func step(t *testing.T, m Model, msg tea.Msg) Model {30+ t.Helper()31+ next, _ := m.Update(msg)32+ return next.(Model)33+}34+35+func key(s string) tea.KeyPressMsg {36+ switch s {37+ case "tab":38+ return tea.KeyPressMsg{Code: tea.KeyTab}39+ case "enter":40+ return tea.KeyPressMsg{Code: tea.KeyEnter}41+ }42+ return tea.KeyPressMsg{Code: rune(s[0]), Text: s}43+}44+45+// Every frame must be exactly the terminal size, at any size, in any panel.46+func TestViewFitsTerminal(t *testing.T) {47+ for _, size := range [][2]int{{160, 45}, {80, 24}, {40, 10}, {20, 5}, {3, 2}, {1, 1}} {48+ m := fixtureModel(t, size[0], size[1])49+ for _, k := range []string{"", "tab", "j", "]", "z", "?", "x", "2", "enter", "d"} {50+ if k != "" {51+ m = step(t, m, key(k))52+ }53+ lines := strings.Split(m.View().Content, "\n")54+ if len(lines) != size[1] {55+ t.Fatalf("%dx%d after %q: %d lines, want %d", size[0], size[1], k, len(lines), size[1])56+ }57+ for i, l := range lines {58+ if w := ansi.StringWidth(l); w > size[0] {59+ t.Fatalf("%dx%d after %q: line %d is %d wide", size[0], size[1], k, i, w)60+ }61+ }62+ }63+ }64+}65+66+func TestSelectionFollowsIDAcrossReloads(t *testing.T) {67+ m := fixtureModel(t, 120, 40)68+ m = step(t, m, key("j")) // add-due-dates69+ if got := m.selected[panelChanges]; got != "add-due-dates" {70+ t.Fatalf("selected %q", got)71+ }72+ // A change sorting before it appears; the cursor should stay on add-due-dates.73+ next := *m.snap74+ next.Changes = append([]spec.Change{{ID: "aaa-new", Name: "aaa-new"}}, next.Changes...)75+ m = step(t, m, loadedMsg{snap: &next})76+ if got := m.idAt(panelChanges, m.cursors[panelChanges]); got != "add-due-dates" {77+ t.Errorf("after reload cursor on %q, want add-due-dates", got)78+ }79+ if len(m.events) != 1 || m.events[0].ID != "aaa-new" {80+ t.Errorf("events = %+v", m.events)81+ }82+}83+84+func TestResizeKeepsCursorVisible(t *testing.T) {85+ m := fixtureModel(t, 120, 60)86+ for range 4 {87+ m = step(t, m, key("j"))88+ }89+ m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 16})90+ h := m.geometry().panels[panelChanges].h - 291+ c, off := m.cursors[panelChanges], m.offsets[panelChanges]92+ if c < off || c >= off+h {93+ t.Errorf("cursor %d not within visible rows [%d,%d)", c, off, off+h)94+ }95+}96+97+func TestAllocateFillsTotal(t *testing.T) {98+ share := [numLists]int{35, 25, 15, 25}99+ for _, total := range []int{4, 10, 37, 100} {100+ rows := allocate([numLists]int{50, 3, 2, 1}, share, total, panelChanges)101+ sum := 0102+ for _, r := range rows {103+ if r < 1 {104+ t.Errorf("total %d: panel got %d rows", total, r)105+ }106+ sum += r107+ }108+ if sum != total {109+ t.Errorf("total %d: allocated %d (%v)", total, sum, rows)110+ }111+ }112+}@@ -40,7 +40,11 @@ func (m Model) render() string {
4040 for len(lines) < m.height-footerRows {4141 lines = append(lines, "")4242 }43- return strings.Join(append(lines, m.renderFooter()), "\n")43+ lines = append(lines, m.renderFooter())44+ for i, l := range lines {45+ lines[i] = ansi.Truncate(l, m.width, "")46+ }47+ return strings.Join(lines, "\n")4448 }4549 4650 func (m Model) renderHeader() string {