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
| A | internal/ | 62 | ++++++++++++ |
| M | internal/ | 15 | +++ |
| M | internal/ | 7 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 4 | + |
| M | internal/ | 24 | ++++- |
| M | internal/ | 52 | ++++++++++ |
| A | internal/ | 157 | ++++++++++++++++++++++++++++++ |
@@ -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+}@@ -76,6 +76,21 @@ func (w *Workbook) OpsSince(seq uint64) ([]Op, uint64) {
7676 return slices.Clone(h.log[i:]), h.seq7777 }7878 79+// Checkpoint marks where the history stands, for UndoTo.80+func (w *Workbook) Checkpoint() int { return int(w.hist.seq) }81+82+// UndoTo undoes the current author's steps made after checkpoint c, as83+// cancelling a dialog does, whatever others changed meanwhile; it stops84+// at a step it can't undo.85+func (w *Workbook) UndoTo(c int) {86+ h := &w.hist87+ for i := h.mine(w.author); i >= 0 && h.undo[i].seq > uint64(c); i = h.mine(w.author) {88+ if _, ok := w.Undo(); !ok {89+ return90+ }91+ }92+}93+7994 // LastOp is the latest operation's sequence number, 0 before any.8095 func (w *Workbook) LastOp() uint64 { return w.hist.seq }8196 @@ -69,7 +69,7 @@ type chartHost interface {
6969 type chartEditor struct {7070 m chartHost // the model, through what the editor needs of it7171 i int72- start int // the sheet's state before editing, to undo back to72+ start int // the history before editing (Checkpoint), to undo back to7373 before sheet.Chart // the chart when the editor opened, for a macro7474 isNew bool7575 axes bool // showing the axis options@@ -130,10 +130,7 @@ func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {
130130 e.keep()131131 m.selectChart(e.i)132132 case "esc":133- s := m.sheetShown()134- for s.StateID() != e.start && s.CanUndo() {135- s.Undo()136- }133+ m.sheetShown().Book().UndoTo(e.start)137134 m.syncChanged()138135 m.closeOverlay()139136 m.forgetChart()@@ -229,7 +229,7 @@ func (m *Model) answerEditChart(text string) (tea.Cmd, error) {
229229 if i < 0 {230230 return nil, fmt.Errorf("there's no chart on %s", m.sheet.Name())231231 }232- return nil, m.openChartEditor(i, false, m.sheet.StateID()).answer(fields)232+ return nil, m.openChartEditor(i, false, m.book().Checkpoint()).answer(fields)233233 }234234 235235 // answer makes the chart the answer's fields say and closes the editor.@@ -35,7 +35,7 @@ func init() {
3535 answer: (*Model).answerInsertChart},3636 &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",3737 enabled: hasCharts, run: func(m *Model) tea.Cmd {38- m.openChartEditor(m.targetChart(), false, m.sheet.StateID())38+ m.openChartEditor(m.targetChart(), false, m.book().Checkpoint())3939 return nil4040 },4141 answer: (*Model).answerEditChart},@@ -95,7 +95,7 @@ func (m *Model) insertChart() tea.Cmd {
9595 m.note = "Select the data to chart first"9696 return nil9797 }98- start := m.sheet.StateID()98+ start := m.book().Checkpoint()9999 c := m.sheet.GuessChart(r)100100 c.W = clamp(newChartW, sheet.MinChartW, max(m.width-m.hdrW()-2, sheet.MinChartW))101101 c.H = clamp(newChartH, sheet.MinChartH, max(m.visibleRows()-1, sheet.MinChartH))@@ -35,14 +35,14 @@ func init() {
3535 &command{id: "data.pivot_edit", title: "Edit pivot table", desc: "Change the pivot table's rows, columns, values and filters",3636 enabled: func(m *Model) bool { _, ok := m.sheet.Pivot(); return ok },3737 run: func(m *Model) tea.Cmd {38- m.openPivotEditor(m.sheet.StateID(), nil)38+ m.openPivotEditor(m.book().Checkpoint(), nil)3939 return nil4040 },4141 answer: func(m *Model, text string) (tea.Cmd, error) {4242 if _, ok := m.sheet.Pivot(); !ok {4343 return nil, errors.New(m.sheet.Name() + " has no pivot table")4444 }45- return nil, m.openPivotEditor(m.sheet.StateID(), nil).answer(m, text)45+ return nil, m.openPivotEditor(m.book().Checkpoint(), nil).answer(m, text)4646 }},4747 &command{id: "data.frequency", title: "Frequency table (column stats)", desc: "Count each value of the active column, most frequent first, on a new sheet, as Sheets' Column stats do",4848 run: (*Model).frequency},@@ -167,7 +167,7 @@ func (m *Model) createPivot() tea.Cmd {
167167 m.note = "Select the data to summarize first: a row of headers and the rows below it"168168 return nil169169 }170- src, start := m.sheet, m.sheet.StateID()170+ src, start := m.sheet, m.book().Checkpoint()171171 s, err := m.book().CreatePivot(src, r, "", sheet.NewPivot(src, r))172172 if err != nil {173173 m.fail(err.Error())@@ -24,7 +24,7 @@ import (
2424 // created.2525 type pivotEditor struct {2626 m pivotHost // the model, through what the editor needs of it27- start int // the workbook's state before editing, to undo back to27+ start int // the history before editing (Checkpoint), to undo back to2828 back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc2929 before string // the pivot when the editor opened, as a macro answers it3030 msg string // why the last change was refused, for the status line@@ -223,9 +223,7 @@ func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {
223223 // cancel undoes the editor's changes and closes it; a pivot just created224224 // goes with its sheet.225225 func (e *pivotEditor) cancel(m pivotHost) {226- for m.sheetShown().StateID() != e.start && m.sheetShown().CanUndo() {227- m.sheetShown().Undo()228- }226+ m.book().UndoTo(e.start)229227 m.closeOverlay()230228 m.afterSheetsChange(e.back, m.book().Active())231229 m.syncChanged()@@ -340,6 +340,10 @@ func (m *Model) restored(msg restoredMsg) {
340340 m.fail("Couldn't restore the kept changes: " + msg.err.Error())341341 return342342 }343+ if seat := m.share.seat; seat != nil {344+ m.restoreShared(seat, msg)345+ return346+ }343347 disk := m.disk344348 m.reset(msg.sheet, msg.name)345349 m.disk, m.saved, m.changed = disk, -1, true@@ -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 := ""@@ -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+}@@ -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+}