cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Shared workbooks: restoring kept work, cancelling dialogs, tests

Dami committed 8eba9f9
Restoring kept changes opens the room again on them when nobody else
is in it. Cancelling the chart and pivot editors undoes the user's
own steps since they opened (Checkpoint, UndoTo), whatever others did
meanwhile. Tests: sessions seeing each other, per-user undo and its
refusals, entries over others' changes, saves and quitting, one
writer, notebook runs finished by the room's keeper, sessions typing
at once in real programs (race detector), an edit reaching another
session within a frame, and over SSH three sessions sharing a file
whose unsaved work the server keeps once.
commit
8eba9f93ab9a237a099d1d45776f34e5366c6983
parent
eb5e6ac67083b0fbf446864780360c68ae71b7e4
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.0 of 3, Jev's estimate
11 files changed, +322 -17
Ainternal/serve/share_test.go62++++++++++++
Minternal/sheet/authors.go15+++
Minternal/ui/charteditor.go7+-
Minternal/ui/chartmacro.go2+-
Minternal/ui/charts.go4+-
Minternal/ui/pivot.go6+-
Minternal/ui/pivoteditor.go6+-
Minternal/ui/recovery.go4+
Minternal/ui/share.go24++++-
Minternal/ui/share_test.go52++++++++++
Ainternal/ui/sharelive_test.go157++++++++++++++++++++++++++++++
A internal/serve/share_test.go +62 -0
@@ -0,0 +1,62 @@
1+package serve2+3+import (4+ "context"5+ "io"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+)11+12+// Two sessions opening one file share its workbook: what one types the13+// other shows at once, the first to quit leaves it to the other without14+// asking, and stopping the server keeps the unsaved work once.15+func TestSessionsShareAFile(t *testing.T) {16+ key := newKey(t)17+ srv, addr, dir := testServer(t, nil, key)18+ saveSheet(t, filepath.Join(dir, "book.012"), "saved")19+ c, err := dial(addr, key, srv.HostKey())20+ if err != nil {21+ t.Fatal(err)22+ }23+ defer c.Close()24+ var terms []*term25+ for range 3 {26+ tm, err := openExec(t, c, "book.012")27+ if err != nil {28+ t.Fatal(err)29+ }30+ tm.waitFor(t, "saved")31+ terms = append(terms, tm)32+ }33+ a, b, cy := terms[0], terms[1], terms[2]34+ io.WriteString(a.stdin, "\x1b[Bfrom a\r")35+ b.waitFor(t, "from a", "modified")36+ cy.waitFor(t, "from a")37+38+ io.WriteString(b.stdin, "\x11") // Ctrl+Q: a and cy still have it39+ b.waitClosed(t)40+ a.waitFor(t, "from a")41+42+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)43+ defer cancel()44+ if err := srv.Shutdown(ctx); err != nil {45+ t.Fatal(err)46+ }47+ a.waitClosed(t)48+ cy.waitClosed(t)49+ files, _ := filepath.Glob(filepath.Join(dir, ".012-recovery", "*"))50+ if len(files) != 1 {51+ t.Fatalf("recovery files %v; want one for the shared workbook", files)52+ }53+ kept := 054+ for _, tm := range []*term{a, cy} {55+ if strings.Contains(tm.output(), "unsaved changes kept in") {56+ kept++57+ }58+ }59+ if kept != 1 {60+ t.Errorf("%d sessions said they kept the changes", kept)61+ }62+}
M internal/sheet/authors.go +15 -0
M internal/ui/charteditor.go +2 -5
M internal/ui/chartmacro.go +1 -1
M internal/ui/charts.go +2 -2
M internal/ui/pivot.go +3 -3
M internal/ui/pivoteditor.go +2 -4
M internal/ui/recovery.go +4 -0
M internal/ui/share.go +22 -2
@@ -53,6 +53,9 @@ type joinReq struct {
5353 key, name string5454 book *sheet.Workbook5555 stamp stamp56+ // restored is the recovery file book was read from: the room opens57+ // again on it, as unsaved changes.58+ restored string5659 }5760 5861 // roomMsg tells the model its room changed.
@@ -127,11 +130,18 @@ func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {
127130 m.reset(s, req.name)128131 m.share.seat, m.share.seen = seat, seat.Last()129132 m.nb.runs = seat.Value("nb", func() any { return &nbRuns{} }).(*nbRuns)130- if created {133+ switch {134+ case created && req.restored != "":135+ m.disk, m.saved, m.changed, m.recovered = req.stamp, -1, true, req.restored136+ v := m.savedState()137+ v.State = -1138+ seat.SetSaved(v)139+ m.note = "Restored the kept changes; save to keep them"140+ case created:131141 m.disk = req.stamp132142 seat.SetSaved(m.savedState())133143 m.offerRecovery(req.name)134- } else {144+ default:135145 m.syncSaved()136146 }137147 if peers := seat.Peers(); len(peers) > 0 {
@@ -140,6 +150,16 @@ func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {
140150 return tea.Batch(m.syncFollowers(), m.notebookSync())141151 }142152 153+// restoreShared opens the room again on kept changes, when nobody else154+// is in it: others are working on the workbook as it is.155+func (m *Model) restoreShared(seat *room.Seat, msg restoredMsg) {156+ if peers := seat.Peers(); len(peers) > 0 {157+ m.warn = peerNames(peers) + " have " + m.displayName() + " open: restore the kept changes once you're the only one here"158+ return159+ }160+ m.share.want = &joinReq{key: seat.Key(), name: msg.name, book: msg.sheet.Book(), stamp: m.disk, restored: msg.file}161+}162+143163 // savedState is the room's file as this model last saved or opened it.144164 func (m *Model) savedState() room.Saved {145165 key := ""
M internal/ui/share_test.go +52 -0
@@ -9,6 +9,7 @@ import (
99 tea "charm.land/bubbletea/v2"1010 "github.com/charmbracelet/x/ansi"1111 12+ "github.com/FelineStateMachine/012/internal/config"1213 "github.com/FelineStateMachine/012/internal/confine"1314 "github.com/FelineStateMachine/012/internal/room"1415 "github.com/FelineStateMachine/012/internal/sheet"
@@ -54,6 +55,7 @@ func (r *servedRooms) open(user, name string) *seshion {
5455 s.send(tea.WindowSizeMsg{Width: 80, Height: 20})5556 r.all = append(r.all, s)5657 r.sync()58+ s.sh.turn(func() { s.m.nb.still = true }) // entering the room made its notebook state anew5759 return s5860 }5961
@@ -253,3 +255,53 @@ func TestSharedOneWriter(t *testing.T) {
253255 t.Error("ann typed after handing over")254256 }255257 }258+259+// shell lets s's notebook cells run, with a fake nu answering from out.260+func (s *seshion) shell(out map[string]string) *fakeNu {261+ path := filepath.Join(s.t.TempDir(), "config")262+ os.WriteFile(path, []byte("serve-shell = true\nshell = on\n"), 0o600)263+ load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }264+ nu := &fakeNu{out: out}265+ s.sh.turn(func() {266+ s.m.Configure(Settings{Config: load(), Reload: load})267+ s.m.SetShellRunner(nu)268+ })269+ return nu270+}271+272+// A shared notebook's runs are the room's: whoever runs a cell, the273+// one keeping the room finishes it, everyone sees its output and count,274+// and they go on once the one who started them leaves.275+func TestSharedNotebookRuns(t *testing.T) {276+ r := newRooms(t, room.Edit)277+ ann := r.open("ann", "@nb")278+ bob := r.open("bob", "@nb")279+ out := map[string]string{"ls": lsOut}280+ ann.shell(out)281+ bob.shell(out)282+ ann.run(ann.m.runCommand("nb.open"))283+ r.sync()284+ ann.press("<esc>", "b", "<enter>")285+ ann.send(pasteMsg("files = ls"))286+ ann.press("<esc>", "<ctrl+enter>")287+ r.sync()288+ bob.run(bob.m.runCommand("nb.open"))289+ if !strings.Contains(bob.screen(), "a.txt") || !strings.Contains(bob.screen(), "Out[1]:") {290+ t.Fatalf("bob's notebook:\n%s", bob.screen())291+ }292+ bob.press("<down>", "<ctrl+enter>")293+ r.sync()294+ if !strings.Contains(ann.screen(), "Out[2]:") || !strings.Contains(bob.screen(), "Out[2]:") {295+ t.Errorf("bob's run, finished by ann who keeps the room:\n%s", ann.screen())296+ }297+ if ann.m.LeaveRoom() {298+ t.Fatal("ann was the last")299+ }300+ r.all = r.all[1:]301+ r.sync()302+ bob.press("<ctrl+enter>")303+ r.sync()304+ if !strings.Contains(bob.screen(), "Out[3]:") {305+ t.Errorf("bob, keeping the room now, didn't finish his run:\n%s", bob.screen())306+ }307+}
A internal/ui/sharelive_test.go +157 -0
@@ -0,0 +1,157 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "slices"7+ "strings"8+ "sync"9+ "testing"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/room"16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// watched is a program's model that notes when a frame first shows a20+// text: a session watching another's edit arrive.21+type watched struct {22+ *Shared23+ mu sync.Mutex24+ want string25+ seen chan time.Time26+}27+28+func (w *watched) Update(msg tea.Msg) (tea.Model, tea.Cmd) {29+ _, cmd := w.Shared.Update(msg)30+ return w, cmd31+}32+33+func (w *watched) View() tea.View {34+ v := w.Shared.View()35+ w.mu.Lock()36+ defer w.mu.Unlock()37+ if w.want != "" && strings.Contains(ansi.Strip(v.Content), w.want) {38+ w.want = ""39+ w.seen <- time.Now()40+ }41+ return v42+}43+44+// expect has the next frame showing text noted.45+func (w *watched) expect(text string) {46+ w.mu.Lock()47+ defer w.mu.Unlock()48+ w.want = text49+}50+51+// program runs a served session in a room in a real Bubble Tea program,52+// with no terminal, until the test ends.53+func program(t *testing.T, r *servedRooms, user, name string) (*watched, *tea.Program) {54+ m := New(sheet.New(), "")55+ m.Serve(r.root, []string{"TERM=xterm-256color"})56+ m.ShareRooms(r.reg, user)57+ m.OpenOnStart(name)58+ w := &watched{Shared: InRooms(Guard(m)), seen: make(chan time.Time, 1)}59+ p := tea.NewProgram(w, tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutSignals(), tea.WithWindowSize(80, 24), tea.WithFPS(FrameRate))60+ ctx, cancel := context.WithCancel(context.Background())61+ w.Attach(ctx, p)62+ done := make(chan struct{})63+ go func() {64+ p.Run()65+ close(done)66+ }()67+ t.Cleanup(func() {68+ p.Quit()69+ <-done70+ cancel()71+ })72+ return w, p73+}74+75+// An edit in one session reaches another's screen within a frame: the76+// other's model has taken it and drawn a frame showing it before a77+// frame's time has passed, so the next frame its terminal gets holds it.78+func TestSharedEditWithinAFrame(t *testing.T) {79+ if testing.Short() {80+ t.Skip("times frames")81+ }82+ unlock := benchLock(t)83+ defer unlock()84+ r := newRooms(t, room.Edit)85+ ann := r.open("ann", "@fast")86+ bob, _ := program(t, r, "bob", "@fast")87+ deadline := time.Now().Add(5 * time.Second)88+ for shared := false; !shared && time.Now().Before(deadline); {89+ ann.sh.turn(func() { shared = ann.m.shared() })90+ time.Sleep(time.Millisecond)91+ }92+ var took []time.Duration93+ for i := range 30 {94+ text := "v" + strings.Repeat("x", i%5) + string(rune('a'+i%26))95+ ann.press(text)96+ bob.expect(text)97+ start := time.Now()98+ ann.press("<enter>")99+ select {100+ case at := <-bob.seen:101+ took = append(took, at.Sub(start))102+ case <-time.After(2 * time.Second):103+ t.Fatalf("bob never showed %q", text)104+ }105+ ann.press("<up>") // the same cell again, on bob's screen106+ }107+ slices.Sort(took)108+ median := took[len(took)/2]109+ t.Logf("an edit reached the other session in %v (median), %v at worst", median, took[len(took)-1])110+ if median > FrameInterval {111+ t.Errorf("median %v, more than a frame (%v)", median, FrameInterval)112+ }113+}114+115+// Sessions typing at once in one room, each in its own program: every116+// entry lands, and the race detector finds nothing shared unguarded.117+func TestSharedSessionsTypeAtOnce(t *testing.T) {118+ r := newRooms(t, room.Edit)119+ var ps []*tea.Program120+ for _, user := range []string{"ann", "bob", "cy"} {121+ _, p := program(t, r, user, "@busy")122+ ps = append(ps, p)123+ }124+ time.Sleep(50 * time.Millisecond) // everyone's in125+ var wg sync.WaitGroup126+ for i, p := range ps {127+ wg.Add(1)128+ go func() {129+ defer wg.Done()130+ for k := range i + 1 { // each to its own column131+ p.Send(tea.KeyPressMsg{Code: tea.KeyRight})132+ _ = k133+ }134+ for row := range 20 {135+ for _, c := range "n" + string(rune('0'+row%10)) {136+ p.Send(tea.KeyPressMsg{Code: c, Text: string(c)})137+ }138+ p.Send(tea.KeyPressMsg{Code: tea.KeyEnter})139+ }140+ }()141+ }142+ wg.Wait()143+ // Look as one more participant would, in a turn of its own.144+ look, _ := r.reg.Join("@busy", &peerLink{name: "look", kick: make(chan struct{}, 1)}, nil)145+ deadline := time.Now().Add(5 * time.Second)146+ for {147+ n := 0148+ look.Do(func(w *sheet.Workbook) { n = w.Sheets()[0].Len() })149+ if n == 60 {150+ break151+ }152+ if time.Now().After(deadline) {153+ t.Fatalf("%d of 60 entries landed", n)154+ }155+ time.Sleep(10 * time.Millisecond)156+ }157+}