cat / 012
clone

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

Serve: sessions opening one file share its workbook

Dami committed 75f690f
012 serve holds a room per file (or @name): its sessions work on one
workbook, each turn under the room's lock (ui.Shared), steps made in
the participant's name. Each has its own cursor, scroll, selection and
theme; the others' pointers show in their colors with their initials
on the row header, their names on the status line, and cells they
changed lately marked. Undo takes back one's own steps and says whose
later change is in the way. Saves are the room's, quitting while
others remain asks nothing, and the last one out keeps unsaved work.
Linked files are followed and notebook cells run by the room, results
reaching whoever keeps it. --share view makes one writer, who hands
writing over; --share off keeps a copy per session.
commit
75f690faebe146513b02ca403127fbc4c8b757d3
parent
3242d809cf362430d0ed89c6b983012695345b28
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.3 of 3, Jev's estimate
39 files changed, +2048 -88
Mcmd/012/serve.go4+-
Mcmd/012/serve_test.go4+-
Mdocs/reference/config.md11+
Minternal/config/registry.go3+
Ainternal/room/registry.go112+++++++++
Ainternal/room/room.go376++++++++++++++++++++++++++++++
Ainternal/room/room_test.go169+++++++++++++
Minternal/serve/open.go3+
Minternal/serve/options.go8+-
Minternal/serve/serve.go30++-
Minternal/sheet/authors.go11+
Minternal/ui/commands.go2+-
Minternal/ui/crash.go20++-
Minternal/ui/dialog.go2+-
Minternal/ui/edit.go6+
Minternal/ui/entry.go5+
Minternal/ui/file.go20+-
Minternal/ui/follow.go8+-
Minternal/ui/menubar.go1+
Minternal/ui/model.go9+-
Minternal/ui/model_test.go20+-
Minternal/ui/nbrun.go89++++---
Minternal/ui/nbscreen.go6+-
Minternal/ui/nbstream.go30+-
Minternal/ui/nbstream_test.go6+-
Minternal/ui/notebook.go6+-
Minternal/ui/notebook_test.go2+-
Minternal/ui/panel.go7+-
Minternal/ui/pipe_test.go2+-
Minternal/ui/pivot.go3+
Minternal/ui/recovery.go7+
Ainternal/ui/rooms.go189+++++++++++++++
Ainternal/ui/share.go289+++++++++++++++++++++++
Ainternal/ui/share_test.go255++++++++++++++++++++
Ainternal/ui/sharecmds.go178++++++++++++++
Ainternal/ui/sharedraw.go186+++++++++++++++
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go41+++
Minternal/ui/view.go14+-
M cmd/012/serve.go +3 -1
M cmd/012/serve_test.go +2 -2
M docs/reference/config.md +11 -0
M internal/config/registry.go +3 -0
A internal/room/registry.go +112 -0
@@ -0,0 +1,112 @@
1+package room2+3+import (4+ "sync"5+ "time"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Registry is the rooms a server holds, by key: a file's path, or @name11+// for a named room.12+type Registry struct {13+ mode Mode14+ mu sync.Mutex15+ rooms map[string]*Room16+}17+18+// NewRegistry holds rooms whose participants edit as mode says.19+func NewRegistry(mode Mode) *Registry {20+ return &Registry{mode: mode, rooms: map[string]*Room{}}21+}22+23+// Has reports whether a room with key is open.24+func (g *Registry) Has(key string) bool {25+ g.mu.Lock()26+ defer g.mu.Unlock()27+ return g.rooms[key] != nil28+}29+30+// Rooms is how many rooms are open.31+func (g *Registry) Rooms() int {32+ g.mu.Lock()33+ defer g.mu.Unlock()34+ seen := map[*Room]bool{}35+ for _, r := range g.rooms {36+ seen[r] = true37+ }38+ return len(seen)39+}40+41+// Join seats p in the room with key, opening it on the workbook book42+// makes when there's none (book is called with no lock held but the43+// registry's, and may be nil when the room is known to be open). It44+// reports whether it opened the room. A room's history is shared: undo45+// takes back each participant's own steps.46+func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {47+ g.mu.Lock()48+ defer g.mu.Unlock()49+ r := g.rooms[key]50+ created := false51+ if r == nil {52+ w := book()53+ w.ShareHistory()54+ r = &Room{key: key, reg: g, book: w, mode: g.mode}55+ r.seen, r.version = w.LastOp(), w.Version()56+ g.rooms[key] = r57+ created = true58+ }59+ r.mu.Lock()60+ defer r.mu.Unlock()61+ r.nextID++62+ s := &Seat{room: r, id: r.nextID, name: p.Name(), color: r.freeColor(), p: p, joined: time.Now()}63+ if len(r.seats) == 0 {64+ r.writer = s.id65+ }66+ r.seats = append(r.seats, s)67+ r.notify(s)68+ return s, created69+}70+71+// freeColor is the first color no one in the room has.72+func (r *Room) freeColor() int {73+ used := map[int]bool{}74+ for _, s := range r.seats {75+ used[s.color] = true76+ }77+ for c := range Colors {78+ if !used[c] {79+ return c80+ }81+ }82+ return r.nextID % Colors83+}84+85+// alias makes the room also the one of key, a name it was saved under,86+// unless another room has it.87+func (g *Registry) alias(r *Room, key string) {88+ g.mu.Lock()89+ defer g.mu.Unlock()90+ if g.rooms[key] == nil {91+ g.rooms[key] = r92+ }93+}94+95+// close forgets the room once its last seat has left, reporting whether96+// it did: someone may have joined since.97+func (g *Registry) close(r *Room) bool {98+ g.mu.Lock()99+ defer g.mu.Unlock()100+ r.mu.Lock()101+ defer r.mu.Unlock()102+ if len(r.seats) > 0 || r.closed {103+ return false104+ }105+ r.closed = true106+ for k, x := range g.rooms {107+ if x == r {108+ delete(g.rooms, k)109+ }110+ }111+ return true112+}
A internal/room/room.go +376 -0
@@ -0,0 +1,376 @@
1+// Package room shares workbooks between the people 012 serve hosts: a2+// room is one workbook, opened by every session that opens the same3+// file (or joins the same named room), and each participant has a seat4+// in it. The server holds the workbook, so there is no merging: every5+// participant acts on it in turn, under the room's lock, and the steps6+// they make are ordered by the workbook's one mutation path (Batch and7+// its undo steps), each attributed to its author, as a stream of8+// operations (sheet.Op) the room hands every participant. A participant9+// is anything with a name that can be told the room changed10+// (Participant): a served session's model. The interface stays that11+// small so an MCP client attached to the server can take part the same12+// way (ROADMAP.md, Agents). See docs/contributing/architecture.md.13+package room14+15+import (16+ "slices"17+ "sync"18+ "time"19+20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// Participant is one who takes part in a room. The room asks only this24+// of it; everything else a participant does, it does through its Seat.25+type Participant interface {26+ // Name is who they are to the others: the SSH user of a session.27+ Name() string28+ // Notify tells them the room changed: someone else's operations,29+ // where the others are, who writes, a save, a message posted for30+ // them. It's called with the room locked, so it must neither block31+ // nor call back into the room: they look at what changed on their32+ // next turn (Seat.Do).33+ Notify()34+}35+36+// Mode is who may change a room's workbook.37+type Mode int38+39+const (40+ // Edit lets every participant edit.41+ Edit Mode = iota42+ // View lets one participant, the writer, edit; the others follow.43+ // The writer hands the role over explicitly (Seat.HandTo).44+ View45+)46+47+// Colors is how many colors participants are told apart by.48+const Colors = 649+50+// Room is one shared workbook and the seats of those in it.51+type Room struct {52+ key string53+ reg *Registry54+55+ mu sync.Mutex56+ book *sheet.Workbook57+ seats []*Seat // in the order they joined58+ nextID int59+ mode Mode60+ writer int // the seat that writes in View mode61+ saved Saved62+ values map[string]any63+ inbox []any64+ closed bool65+66+ // seen is the last operation the room has looked at, version the67+ // workbook's when a turn last ended, and marks the latest68+ // operations' cells, for who changed what.69+ seen uint6470+ version uint6471+ marks []Mark72+ // alias is a key the room was saved under this turn, for the73+ // registry once the room is unlocked.74+ alias string75+}76+77+// Saved is the room's file as last saved or opened: what every session78+// in it counts unsaved changes from.79+type Saved struct {80+ State int // the workbook's StateID then81+ Outputs int // its OutputsChanged then82+ Name string // the file's name, as typed, "" for none83+ Key string // the file's room key, its path: the room is its room too84+ Stamp any // the file on disk then, the UI's to compare85+}86+87+// Presence is where a participant is.88+type Presence struct {89+ Sheet *sheet.Sheet90+ Cursor sheet.Addr91+ Selection sheet.Rect92+ Editing bool // typing into the cell at Cursor93+}94+95+// Seat is a participant's place in a room.96+type Seat struct {97+ room *Room98+ id int99+ name string100+ color int101+ p Participant102+ joined time.Time103+ presence Presence104+ moved bool // presence changed this turn105+ left bool106+}107+108+// Peer is what a participant sees of another: who, in what color,109+// where, and whether they write.110+type Peer struct {111+ ID int112+ Name string113+ Color int114+ Presence Presence115+ Writing bool116+ Joined time.Time117+}118+119+// Mark is an operation's place in the workbook: who changed which120+// cells, for showing whose change a cell holds.121+type Mark struct {122+ Seq uint64123+ Author int124+ Name string125+ Color int126+ Kind sheet.OpKind127+ Label string128+ Sheet *sheet.Sheet129+ Rect sheet.Rect130+ At time.Time131+}132+133+// maxMarks is how many of the latest operations the room remembers the134+// cells of.135+const maxMarks = 256136+137+// ID identifies the seat in the room: the author of its steps.138+func (s *Seat) ID() int { return s.id }139+140+// Name is the participant's name.141+func (s *Seat) Name() string { return s.name }142+143+// Color is the participant's color, from 0 to Colors-1.144+func (s *Seat) Color() int { return s.color }145+146+// Key is the room's key: the file's path, or @name for a named room.147+func (s *Seat) Key() string { return s.room.key }148+149+// Do runs fn as the participant's turn: with the room locked, the150+// workbook's steps made in the participant's name. When the turn ends,151+// the others are told of what changed.152+func (s *Seat) Do(fn func(w *sheet.Workbook)) {153+ r := s.room154+ r.mu.Lock()155+ defer r.endTurn(s)156+ r.book.SetAuthor(s.id)157+ fn(r.book)158+}159+160+// endTurn takes note of the operations the turn made, and tells the161+// others when anything changed.162+func (r *Room) endTurn(by *Seat) {163+ changed := r.catchUp()164+ if v := r.book.Version(); v != r.version {165+ r.version, changed = v, true166+ }167+ if changed || by.moved {168+ by.moved = false169+ r.notify(by)170+ }171+ alias := r.alias172+ r.alias = ""173+ r.mu.Unlock()174+ if alias != "" {175+ r.reg.alias(r, alias)176+ }177+}178+179+// catchUp marks the cells of the operations since the room last looked.180+func (r *Room) catchUp() bool {181+ ops, last := r.book.OpsSince(r.seen)182+ if last == r.seen {183+ return false184+ }185+ r.seen = last186+ now := time.Now()187+ for _, o := range ops {188+ if o.Tabs || o.Macros || o.Sheet == nil {189+ continue190+ }191+ m := Mark{Seq: o.Seq, Author: o.Author, Kind: o.Kind, Label: o.Label, Sheet: o.Sheet, Rect: o.Focus, At: now}192+ if s := r.seat(o.Author); s != nil {193+ m.Name, m.Color = s.name, s.color194+ }195+ r.marks = append(r.marks, m)196+ }197+ if len(r.marks) > maxMarks {198+ r.marks = slices.Delete(r.marks, 0, len(r.marks)-maxMarks)199+ }200+ return true201+}202+203+// notify tells every seat but by that the room changed.204+func (r *Room) notify(by *Seat) {205+ for _, s := range r.seats {206+ if s != by {207+ s.p.Notify()208+ }209+ }210+}211+212+// seat is the seat with id, nil when it left.213+func (r *Room) seat(id int) *Seat {214+ for _, s := range r.seats {215+ if s.id == id {216+ return s217+ }218+ }219+ return nil220+}221+222+// Book is the room's workbook. Only use it in a turn (Do).223+func (s *Seat) Book() *sheet.Workbook { return s.room.book }224+225+// Move sets where the participant is, for the others to see once the226+// turn ends. Call it in a turn.227+func (s *Seat) Move(p Presence) {228+ if p != s.presence {229+ s.presence, s.moved = p, true230+ }231+}232+233+// Peers are the others in the room, in the order they joined. Call it234+// in a turn.235+func (s *Seat) Peers() []Peer {236+ var out []Peer237+ for _, o := range s.room.seats {238+ if o != s {239+ out = append(out, o.peer())240+ }241+ }242+ return out243+}244+245+func (s *Seat) peer() Peer {246+ return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined}247+}248+249+// Others is how many others are in the room. Call it in a turn.250+func (s *Seat) Others() int { return len(s.room.seats) - 1 }251+252+// Marks are the operations since seq, oldest first, whoever made them.253+// Call it in a turn.254+func (s *Seat) Marks(since uint64) []Mark {255+ m := s.room.marks256+ i, _ := slices.BinarySearchFunc(m, since+1, func(x Mark, seq uint64) int { return int(x.Seq) - int(seq) })257+ return m[i:]258+}259+260+// Last is the latest operation's number. Call it in a turn.261+func (s *Seat) Last() uint64 { return s.room.seen }262+263+// Mode is who may edit the room's workbook.264+func (s *Seat) Mode() Mode { return s.room.mode }265+266+// Writing reports whether the participant may edit: in an Edit room267+// everyone, in a View room the writer.268+func (s *Seat) Writing() bool { return s.room.mode == Edit || s.room.writer == s.id }269+270+// Writer is the writer's name in a View room, "" in an Edit room.271+func (s *Seat) Writer() string {272+ if s.room.mode == Edit {273+ return ""274+ }275+ if w := s.room.seat(s.room.writer); w != nil {276+ return w.name277+ }278+ return ""279+}280+281+// HandTo hands the writer's role to the participant with id, reporting282+// false when this one doesn't write or there's no such participant.283+// Call it in a turn.284+func (s *Seat) HandTo(id int) bool {285+ r := s.room286+ if r.mode != View || r.writer != s.id || r.seat(id) == nil {287+ return false288+ }289+ r.writer, s.moved = id, true290+ return true291+}292+293+// Saved is the room's file as last saved or opened. Call it in a turn.294+func (s *Seat) Saved() Saved { return s.room.saved }295+296+// SetSaved records a save, or the file the room was opened from, for297+// every session. Call it in a turn.298+func (s *Seat) SetSaved(v Saved) {299+ s.room.saved, s.moved = v, true300+ if v.Key != "" && v.Key != s.room.key {301+ s.room.alias = v.Key302+ }303+}304+305+// Value is the room's value for key, made by make the first time: state306+// every participant shares, such as the notebook cells running. Call it307+// in a turn.308+func (s *Seat) Value(key string, make func() any) any {309+ r := s.room310+ if r.values == nil {311+ r.values = map[string]any{}312+ }313+ v, ok := r.values[key]314+ if !ok {315+ v = make()316+ r.values[key] = v317+ }318+ return v319+}320+321+// Keeper reports whether the participant keeps what the room runs on322+// its own (linked files followed, notebook cells running): the one who323+// has been there longest. Call it in a turn.324+func (s *Seat) Keeper() bool { return len(s.room.seats) > 0 && s.room.seats[0] == s }325+326+// Post leaves msg for the keeper, whoever it is when it takes it: what327+// the room runs reports back this way rather than to the participant328+// that started it, who may have left. It may be called from any329+// goroutine, outside a turn.330+func (s *Seat) Post(msg any) {331+ r := s.room332+ r.mu.Lock()333+ defer r.mu.Unlock()334+ if r.closed || len(r.seats) == 0 {335+ return336+ }337+ r.inbox = append(r.inbox, msg)338+ r.seats[0].p.Notify()339+}340+341+// Take is what was posted for the keeper, if this is the keeper. Call342+// it in a turn.343+func (s *Seat) Take() []any {344+ if !s.Keeper() || len(s.room.inbox) == 0 {345+ return nil346+ }347+ in := s.room.inbox348+ s.room.inbox = nil349+ return in350+}351+352+// Leave gives up the seat, reporting whether it was the last: the room353+// closes then, and its workbook is the participant's alone.354+func (s *Seat) Leave() (last bool) {355+ r := s.room356+ r.mu.Lock()357+ if s.left {358+ r.mu.Unlock()359+ return false360+ }361+ s.left = true362+ i := slices.Index(r.seats, s)363+ r.seats = slices.Delete(r.seats, i, i+1)364+ empty := len(r.seats) == 0365+ if !empty {366+ if r.mode == View && r.writer == s.id {367+ r.writer = r.seats[0].id368+ }369+ r.notify(nil)370+ }371+ r.mu.Unlock()372+ if empty {373+ return r.reg.close(r)374+ }375+ return false376+}
A internal/room/room_test.go +169 -0
@@ -0,0 +1,169 @@
1+package room2+3+import (4+ "fmt"5+ "sync"6+ "sync/atomic"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// who is a participant counting the times it was told the room changed.13+type who struct {14+ name string15+ kicks atomic.Int6416+}17+18+func (w *who) Name() string { return w.name }19+func (w *who) Notify() { w.kicks.Add(1) }20+21+func addr(s string) sheet.Addr {22+ a, _ := sheet.ParseAddr(s)23+ return a24+}25+26+func newBook() *sheet.Workbook { return sheet.New().Book() }27+28+// Sessions opening the same key share one workbook: each turn's steps29+// are its author's, the others are told, and the room remembers who30+// changed which cells, in order.31+func TestRoomSharesOneWorkbook(t *testing.T) {32+ g := NewRegistry(Edit)33+ ann, bob := &who{name: "ann"}, &who{name: "bob"}34+ a, created := g.Join("/srv/budget.012", ann, newBook)35+ if !created {36+ t.Fatal("the first join didn't open the room")37+ }38+ b, created := g.Join("/srv/budget.012", bob, func() *sheet.Workbook { t.Fatal("opened twice"); return nil })39+ if created || a.Book() != b.Book() || a.Color() == b.Color() {40+ t.Fatalf("created %v, same book %v, colors %d %d", created, a.Book() == b.Book(), a.Color(), b.Color())41+ }42+ before := bob.kicks.Load()43+ a.Do(func(w *sheet.Workbook) { w.Sheets()[0].Set(addr("A1"), "ann's") })44+ if bob.kicks.Load() == before {45+ t.Error("bob wasn't told of ann's edit")46+ }47+ b.Do(func(w *sheet.Workbook) { w.Sheets()[0].Set(addr("B1"), "bob's") })48+ var marks []Mark49+ a.Do(func(*sheet.Workbook) { marks = a.Marks(0) })50+ if len(marks) != 2 || marks[0].Name != "ann" || marks[1].Name != "bob" || marks[1].Rect.From != addr("B1") || marks[0].Seq >= marks[1].Seq {51+ t.Fatalf("marks %+v", marks)52+ }53+ // Undo is each author's own.54+ a.Do(func(w *sheet.Workbook) { w.Undo() })55+ s := a.Book().Sheets()[0]56+ if s.Filled(addr("A1")) || s.Value(addr("B1")).Str != "bob's" {57+ t.Errorf("after ann's undo: A1 %v, B1 %v", s.Value(addr("A1")), s.Value(addr("B1")))58+ }59+}60+61+// Presence reaches the others; a turn that changes nothing tells no one.62+func TestRoomPresence(t *testing.T) {63+ g := NewRegistry(Edit)64+ ann, bob := &who{name: "ann"}, &who{name: "bob"}65+ a, _ := g.Join("@standup", ann, newBook)66+ b, _ := g.Join("@standup", bob, nil)67+ before := bob.kicks.Load()68+ a.Do(func(*sheet.Workbook) {})69+ if bob.kicks.Load() != before {70+ t.Error("told of nothing")71+ }72+ a.Do(func(w *sheet.Workbook) { a.Move(Presence{Sheet: w.Sheets()[0], Cursor: addr("C3")}) })73+ if bob.kicks.Load() == before {74+ t.Error("not told ann moved")75+ }76+ var peers []Peer77+ b.Do(func(*sheet.Workbook) { peers = b.Peers() })78+ if len(peers) != 1 || peers[0].Name != "ann" || peers[0].Presence.Cursor != addr("C3") {79+ t.Errorf("peers %+v", peers)80+ }81+}82+83+// In a View room one writes: the first in, until they hand it over or84+// leave.85+func TestRoomWriter(t *testing.T) {86+ g := NewRegistry(View)87+ a, _ := g.Join("k", &who{name: "ann"}, newBook)88+ b, _ := g.Join("k", &who{name: "bob"}, nil)89+ c, _ := g.Join("k", &who{name: "cy"}, nil)90+ a.Do(func(*sheet.Workbook) {91+ if !a.Writing() || b.Writing() || a.Writer() != "ann" {92+ t.Errorf("writing: ann %v, bob %v", a.Writing(), b.Writing())93+ }94+ if b.HandTo(c.ID()) {95+ t.Error("bob handed over what he doesn't hold")96+ }97+ if !a.HandTo(b.ID()) || !b.Writing() || a.Writing() {98+ t.Error("ann didn't hand over to bob")99+ }100+ })101+ b.Leave()102+ a.Do(func(*sheet.Workbook) {103+ if !a.Writing() {104+ t.Error("the writer left, and ann, there longest, doesn't write")105+ }106+ })107+}108+109+// What is posted goes to the keeper, the one there longest, and to the110+// next one once the keeper leaves; the last to leave closes the room.111+func TestRoomKeeperAndLeaving(t *testing.T) {112+ g := NewRegistry(Edit)113+ ann, bob := &who{name: "ann"}, &who{name: "bob"}114+ a, _ := g.Join("k", ann, newBook)115+ b, _ := g.Join("k", bob, nil)116+ b.Post("done")117+ var got []any118+ b.Do(func(*sheet.Workbook) { got = b.Take() })119+ if got != nil {120+ t.Errorf("bob took %v", got)121+ }122+ if a.Leave() {123+ t.Fatal("ann was last")124+ }125+ b.Do(func(*sheet.Workbook) { got = b.Take() })126+ if len(got) != 1 || got[0] != "done" {127+ t.Errorf("bob, keeper now, took %v", got)128+ }129+ if !g.Has("k") || !b.Leave() || g.Has("k") || g.Rooms() != 0 {130+ t.Error("the last to leave didn't close the room")131+ }132+ // A save under another name makes the room that file's too.133+ c, _ := g.Join("@r", &who{name: "cy"}, newBook)134+ c.Do(func(*sheet.Workbook) { c.SetSaved(Saved{Name: "r.012", Key: "/srv/r.012"}) })135+ if !g.Has("/srv/r.012") || g.Rooms() != 1 {136+ t.Error("saving didn't alias the room")137+ }138+}139+140+// Many participants taking turns at once: every step lands, in one141+// order, and the race detector finds nothing.142+func TestRoomTurnsAtOnce(t *testing.T) {143+ g := NewRegistry(Edit)144+ var wg sync.WaitGroup145+ const n, edits = 6, 50146+ for i := range n {147+ s, _ := g.Join("k", &who{name: fmt.Sprint(i)}, newBook)148+ wg.Add(1)149+ go func() {150+ defer wg.Done()151+ for j := range edits {152+ s.Do(func(w *sheet.Workbook) {153+ w.Sheets()[0].Set(sheet.Addr{Col: i, Row: j}, fmt.Sprint(j))154+ s.Move(Presence{Cursor: sheet.Addr{Col: i, Row: j}})155+ })156+ }157+ }()158+ }159+ wg.Wait()160+ s, _ := g.Join("k", &who{name: "last"}, nil)161+ s.Do(func(w *sheet.Workbook) {162+ if got := w.Sheets()[0].Len(); got != n*edits {163+ t.Errorf("%d cells", got)164+ }165+ if s.Last() != n*edits {166+ t.Errorf("%d operations", s.Last())167+ }168+ })169+}
M internal/serve/open.go +3 -0
M internal/serve/options.go +7 -1
M internal/serve/serve.go +28 -2
@@ -18,6 +18,7 @@ import (
1818 1919 "github.com/FelineStateMachine/012/internal/confine"2020 "github.com/FelineStateMachine/012/internal/jev"21+ "github.com/FelineStateMachine/012/internal/room"2122 "github.com/FelineStateMachine/012/internal/sheet"2223 "github.com/FelineStateMachine/012/internal/telemetry"2324 "github.com/FelineStateMachine/012/internal/ui"
@@ -36,6 +37,7 @@ type Server struct {
3637 hostPub gossh.PublicKey3738 slots chan struct{} // one per running session3839 active atomic.Int6440+ rooms *room.Registry // the shared workbooks; nil with sharing off3941 4042 // stop is closed when the server shuts down, ending every session,4143 // which keeps its unsaved work first; running counts the sessions
@@ -105,6 +107,12 @@ func New(o Options, d Deps) (*Server, error) {
105107 }106108 s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions),107109 stop: make(chan struct{})}110+ switch o.Share {111+ case "edit":112+ s.rooms = room.NewRegistry(room.Edit)113+ case "view":114+ s.rooms = room.NewRegistry(room.View)115+ }108116 s.ssh = &ssh.Server{109117 Addr: o.Listen,110118 Handler: s.session,
@@ -303,6 +311,9 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
303311 m.TraceUnder(parent)304312 env := append(sess.Environ(), "TERM="+pty.Term)305313 m.Serve(s.root, env)314+ if s.rooms != nil {315+ m.ShareRooms(s.rooms, sess.User())316+ }306317 m.OpenOnStart(name)307318 if s.jev != nil {308319 // Each session has its own cache: it queues that session's
@@ -311,12 +322,21 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
311322 }312323 in := newActivity(sess)313324 g := ui.Guard(m)314- p := tea.NewProgram(g, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)325+ var model tea.Model = g326+ var shared *ui.Shared327+ if s.rooms != nil {328+ shared = ui.InRooms(g)329+ model = shared330+ }331+ p := tea.NewProgram(model, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)315332 316333 // The connection's context ends when the client goes away; cancel317334 // stops the goroutines below when the program quits first.318335 ctx, cancel := context.WithCancel(sess.Context())319336 defer cancel()337+ if shared != nil {338+ shared.Attach(ctx, p)339+ }320340 go resize(ctx, p, winch)321341 go func() {322342 select {
@@ -344,7 +364,13 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
344364 // The report is the server's log: the operator's to read.345365 s.log.Error("session panic", "where", c.Where, "panic", fmt.Sprint(c.Value), "stack", string(c.Stack))346366 }347- end.keep(g)367+ // A shared workbook stays with those still in its room; the last368+ // one out keeps its unsaved work.369+ _, end.others = g.Model().Others()370+ if g.Model().LeaveRoom() {371+ end.others = ""372+ end.keep(g)373+ }348374 return end349375 }350376
M internal/sheet/authors.go +11 -0
M internal/ui/commands.go +1 -1
M internal/ui/crash.go +19 -1
M internal/ui/dialog.go +1 -1
M internal/ui/edit.go +6 -0
M internal/ui/entry.go +5 -0
M internal/ui/file.go +17 -3
@@ -116,6 +116,9 @@ func (m *Model) openFile(text string) tea.Cmd {
116116 if !ok {117117 return nil118118 }119+ if m.share.reg != nil {120+ return m.openShared(name, p)121+ }119122 return loadCmd(name, p, m.spans.Parent())120123 }121124
@@ -123,10 +126,12 @@ func (m *Model) openFile(text string) tea.Cmd {
123126 // session rather than the sheet carries over: the window, theme, terminal124127 // state, served directory and the JEV connection.125128 func (m *Model) reset(s *sheet.Sheet, filename string) {126- m.stopCells() // the old workbook's cells127- nb := nbState{runner: m.nb.runner, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}129+ if !m.shared() {130+ m.stopCells() // the old workbook's cells, unless others share them131+ }132+ nb := nbState{runner: m.nb.runner, runs: &nbRuns{}, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}128133 defer func() { m.nb = nb; m.bookOpened() }()129- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe,134+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe, share: m.share,130135 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}131136 s.Book().SetTrace(m.spans)132137 if m.jev != nil {
@@ -348,6 +353,11 @@ func (m *Model) handleSaved(msg savedMsg) tea.Cmd {
348353 m.filename, m.disk = msg.name, msg.stamp349354 m.changed = false350355 m.nb.saved = m.nb.saving356+ if seat := m.share.seat; seat != nil {357+ v := m.savedState()358+ v.Outputs = m.nb.saved359+ seat.SetSaved(v)360+ }351361 m.savedRecovered()352362 if left := m.book().UnsavedOutputs(); len(left) > 0 {353363 m.note = "Saved without the outputs of " + strings.Join(left, ", ") + " (over nu-save-cell-kb or nu-save-notebook-kb): run them to see them again"
@@ -363,6 +373,10 @@ func (m *Model) handleLoaded(msg loadedMsg) {
363373 m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))364374 return365375 }376+ if m.share.reg != nil {377+ m.loadedShared(msg)378+ return379+ }366380 m.reset(msg.sheet, msg.name)367381 m.disk = msg.stamp368382 m.offerRecovery(msg.name)
M internal/ui/follow.go +7 -1
M internal/ui/menubar.go +1 -0
M internal/ui/model.go +7 -2
@@ -128,6 +128,7 @@ type Model struct {
128128 term terminal // what the terminal supports: graphics.go129129 prefs prefs // the settings in effect and the theme chosen: prefs.go130130 session session // what outlasts the file open: the : history, the keyboard: keyboard.go131+ share shareState // the room the workbook is shared in, in 012 serve: share.go131132 132133 vim vimState // a vim key sequence in progress: vim.go133134 rec *recorder // a macro being recorded: macrorec.go
@@ -143,7 +144,7 @@ type Model struct {
143144 144145 // New returns a model editing s. filename may be empty.145146 func New(s *sheet.Sheet, filename string) *Model {146- m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1},147+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}, nb: nbState{runs: &nbRuns{}},147148 spans: telemetry.NewTrace(telemetry.Parent{})}148149 s.Book().SetTrace(m.spans)149150 if filename != "" {
@@ -231,6 +232,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231232 cmd = m.handleTransfer(msg)232233 case followTickMsg, followMsg:233234 cmd = m.handleFollow(msg)235+ case roomMsg:236+ cmd = m.roomChanged()237+ case shareTickMsg:238+ m.share.ticking = false234239 case macroCallMsg:235240 cmd = m.serveMacro(msg)236241 case macroDoneMsg:
@@ -263,7 +268,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
263268 // Any edit may have queued JEV questions.264269 // Chart images follow any change, see graphics.go.265270 // Linked regions may have come, gone or changed: follow.go.266- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync())271+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync(), m.shareTick())267272 }268273 269274 // beginUpdate prepares for an input event and returns the sheet's state
M internal/ui/model_test.go +15 -5
@@ -26,13 +26,19 @@ var named = map[string]tea.Key{
2626 // character at a time. Commands run and their messages are fed back,2727 // except tea.Quit, which is returned.2828 func press(t *testing.T, m *Model, keys ...string) tea.Msg {29+ t.Helper()30+ return pressTo(t, func(msg tea.Msg) tea.Msg { return send(m, msg) }, keys...)31+}32+33+// pressTo sends keys, as press does, through send.34+func pressTo(t *testing.T, send func(tea.Msg) tea.Msg, keys ...string) tea.Msg {2935 t.Helper()3036 var last tea.Msg3137 for _, k := range keys {3238 name, ok := strings.CutPrefix(k, "<")3339 if !ok {3440 for _, r := range k {35- if msg := send(m, tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {41+ if msg := send(tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {3642 last = msg3743 }3844 }
@@ -59,7 +65,7 @@ func press(t *testing.T, m *Model, keys ...string) tea.Msg {
5965 t.Fatalf("unknown key %q", k)6066 }6167 key.Mod = mod62- if msg := send(m, tea.KeyPressMsg(key)); msg != nil {68+ if msg := send(tea.KeyPressMsg(key)); msg != nil {6369 last = msg6470 }6571 }
@@ -75,6 +81,11 @@ func send(m *Model, msg tea.Msg) tea.Msg {
7581 }7682 7783 func run(m *Model, cmd tea.Cmd) tea.Msg {84+ return runVia(func(msg tea.Msg) tea.Cmd { _, next := m.Update(msg); return next }, cmd)85+}86+87+// runVia runs cmd as run does, handing its messages to update.88+func runVia(update func(tea.Msg) tea.Cmd, cmd tea.Cmd) tea.Msg {7889 if cmd == nil {7990 return nil8091 }
@@ -97,15 +108,14 @@ func run(m *Model, cmd tea.Cmd) tea.Msg {
97108 if v := reflect.ValueOf(out); v.Kind() == reflect.Slice && v.Type().Elem() == cmdType {98109 for i := range v.Len() {99110 if c, ok := v.Index(i).Interface().(tea.Cmd); ok {100- if q := run(m, c); q != nil {111+ if q := runVia(update, c); q != nil {101112 return q102113 }103114 }104115 }105116 return nil106117 }107- _, next := m.Update(out)108- return run(m, next)118+ return runVia(update, update(out))109119 }110120 111121 func newModel() *Model {
M internal/ui/nbrun.go +50 -39
@@ -25,13 +25,12 @@ import (
2525 2626 // nbState is the notebooks' part of the model.2727 type nbState struct {28- views map[*sheet.Sheet]*nbview.View29- runner nushell.Runner // nu, or a fake in tests30- queue []nbQueued31- running *nbRun32- gen int33- count int // runs this session, for [n]34- clip []notebook.Cell28+ views map[*sheet.Sheet]*nbview.View29+ runner nushell.Runner // nu, or a fake in tests30+ // runs are the cells running and waiting: the model's own, or in a31+ // shared room the room's (share.go).32+ runs *nbRuns33+ clip []notebook.Cell3534 // words are nu's command names, asked for once (asked).3635 words []string3736 asked bool
@@ -64,12 +63,24 @@ type nbState struct {
6463 // follow scrolls the view to each cell as it starts, while a run of6564 // several cells goes on and the user hasn't scrolled away.6665 follow bool67- // streams are the cells running as streams, by cell ID, and68- // streamTick is set while their heads are ticking: nbstream.go.69- streams map[int]*nbStream66+ // streamTick is set while streaming cells' heads are ticking:67+ // nbstream.go.7068 streamTick bool7169 }7270 71+// nbRuns are the cells running, waiting and streaming. In a room of72+// 012 serve they are the room's, which every participant sees and any73+// may stop, and whose results reach whoever keeps the room then74+// (room.Seat.Post), so they outlast the participant who started them.75+type nbRuns struct {76+ queue []nbQueued77+ running *nbRun78+ gen int79+ count int // runs of this workbook, for [n]80+ // streams are the cells running as streams, by cell ID: nbstream.go.81+ streams map[int]*nbStream82+}83+7384 // nbQueued is a cell waiting to run.7485 type nbQueued struct {7586 s *sheet.Sheet
@@ -119,13 +130,13 @@ func (m *Model) runner() nushell.Runner {
119130 // cellState is how cell id of notebook s stands.120131 func (m *Model) cellState(s *sheet.Sheet, id int) nbview.State {121132 var st nbview.State122- if r := m.nb.running; r != nil && r.s == s && r.id == id {133+ if r := m.nb.runs.running; r != nil && r.s == s && r.id == id {123134 st.Running, st.Started = true, r.start124135 }125- if r := m.nb.streams[id]; r != nil && r.s == s {136+ if r := m.nb.runs.streams[id]; r != nil && r.s == s {126137 st.Running, st.Started, st.Live, st.Rows = true, r.start, true, r.rows127138 }128- st.Waiting = slices.Contains(m.nb.queue, nbQueued{s, id})139+ st.Waiting = slices.Contains(m.nb.runs.queue, nbQueued{s, id})129140 st.Stale = m.staleCells(s)[id]130141 cells := s.NotebookCells()131142 if i := slices.IndexFunc(cells, func(c notebook.Cell) bool { return c.ID == id }); i >= 0 {
@@ -219,11 +230,11 @@ func (m *Model) queueCells(s *sheet.Sheet, cells []notebook.Cell, want []int) te
219230 return m.trustNotebook(func(m *Model) tea.Cmd {220231 for _, i := range order {221232 q := nbQueued{s, cells[i].ID}222- if !slices.Contains(m.nb.queue, q) && (m.nb.running == nil || m.nb.running.nbQueued != q) {223- m.nb.queue = append(m.nb.queue, q)233+ if !slices.Contains(m.nb.runs.queue, q) && (m.nb.runs.running == nil || m.nb.runs.running.nbQueued != q) {234+ m.nb.runs.queue = append(m.nb.runs.queue, q)224235 }225236 }226- if m.nb.running != nil {237+ if m.nb.runs.running != nil {227238 return nil228239 }229240 return m.nextCell()
@@ -264,9 +275,9 @@ func (m *Model) trustNotebook(run func(*Model) tea.Cmd) tea.Cmd {
264275 265276 // nextCell starts the first cell queued.266277 func (m *Model) nextCell() tea.Cmd {267- for len(m.nb.queue) > 0 {268- q := m.nb.queue[0]269- m.nb.queue = m.nb.queue[1:]278+ for len(m.nb.runs.queue) > 0 {279+ q := m.nb.runs.queue[0]280+ m.nb.runs.queue = m.nb.runs.queue[1:]270281 c, ok := cellOf(q)271282 if !ok || c.Kind != notebook.Code {272283 continue
@@ -303,17 +314,17 @@ func cellOf(q nbQueued) (notebook.Cell, bool) {
303314 // failCell gives a cell that can't run an output saying why, and stops304315 // the queue.305316 func (m *Model) failCell(q nbQueued, c notebook.Cell, why string) {306- m.nb.count++307- m.book().SetOutput(q.id, &notebook.Output{Err: why, Count: m.nb.count, Source: c.Source})308- m.nb.queue = nil317+ m.nb.runs.count++318+ m.book().SetOutput(q.id, &notebook.Output{Err: why, Count: m.nb.runs.count, Source: c.Source})319+ m.nb.runs.queue = nil309320 }310321 311322 // startCell runs a cell in the background.312323 func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {313- m.nb.gen++324+ m.nb.runs.gen++314325 ctx, cancel := context.WithCancel(context.Background())315- run.gen, run.cancel = m.nb.gen, cancel316- m.nb.running = run326+ run.gen, run.cancel = m.nb.runs.gen, cancel327+ m.nb.runs.running = run317328 if m.nb.follow {318329 m.viewOf(run.s).Reveal(run.id) // Run all: the view follows the cell running319330 }
@@ -329,7 +340,7 @@ func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {
329340 }330341 return nbDoneMsg{run: run, nuon: out, err: err}331342 }332- return tea.Batch(exec, m.tickCell(run.gen))343+ return tea.Batch(m.roomOwned(exec), m.tickCell(run.gen))333344 }334345 335346 // maxOutput is the most a run may print: past it the run is stopped.
@@ -345,7 +356,7 @@ func (m *Model) tickCell(gen int) tea.Cmd {
345356 346357 // ticked keeps the running cell's head turning.347358 func (m *Model) ticked(msg nbTickMsg) tea.Cmd {348- if r := m.nb.running; r != nil && r.gen == msg.gen {359+ if r := m.nb.runs.running; r != nil && r.gen == msg.gen {349360 return m.tickCell(msg.gen)350361 }351362 return nil
@@ -355,23 +366,23 @@ func (m *Model) ticked(msg nbTickMsg) tea.Cmd {
355366 // starts the next cell queued.356367 func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {357368 r := msg.run358- if m.nb.running != r {369+ if m.nb.runs.running != r {359370 return nil // stopped, and another started since360371 }361- m.nb.running = nil372+ m.nb.runs.running = nil362373 c, ok := cellOf(r.nbQueued)363374 if !ok {364375 return m.nextCell()365376 }366- m.nb.count++367- o := &notebook.Output{Count: m.nb.count, Took: time.Since(r.start), Source: r.source, Reads: r.reads, Selection: r.selection}377+ m.nb.runs.count++378+ o := &notebook.Output{Count: m.nb.runs.count, Took: time.Since(r.start), Source: r.source, Reads: r.reads, Selection: r.selection}368379 if msg.err != nil {369380 o.Err = runError(msg.err)370381 var nuErr *nushell.Error371382 if errors.As(msg.err, &nuErr) {372383 o.Detail = nuErr.Help()373384 }374- m.nb.queue = nil385+ m.nb.runs.queue = nil375386 } else {376387 o.NUON = msg.nuon377388 if o.NUON == nil {
@@ -384,13 +395,13 @@ func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {
384395 cells := r.s.NotebookCells()385396 if i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID }); i >= 0 {386397 for _, j := range notebook.Dependents(cells, i) {387- if q := (nbQueued{r.s, cells[j].ID}); !slices.Contains(m.nb.queue, q) {388- m.nb.queue = append(m.nb.queue, q)398+ if q := (nbQueued{r.s, cells[j].ID}); !slices.Contains(m.nb.runs.queue, q) {399+ m.nb.runs.queue = append(m.nb.runs.queue, q)389400 }390401 }391402 }392403 }393- if len(m.nb.queue) == 0 && time.Since(r.start) > 5*time.Second {404+ if len(m.nb.runs.queue) == 0 && time.Since(r.start) > 5*time.Second {394405 return tea.Batch(m.nextCell(), m.term.notify("Notebook cells finished in "+m.displayName()))395406 }396407 return m.nextCell()
@@ -411,10 +422,10 @@ func runError(err error) string {
411422 // stopCells stops the cell running, killing its process, forgets412423 // those waiting, and stops every stream.413424 func (m *Model) stopCells() {414- if r := m.nb.running; r != nil {425+ if r := m.nb.runs.running; r != nil {415426 r.cancel()416427 }417- m.nb.queue = nil428+ m.nb.runs.queue = nil418429 m.stopStreams()419430 }420431
@@ -423,8 +434,8 @@ func (m *Model) stopCells() {
423434 func (m *Model) clearOutputs(restart bool) {424435 if restart {425436 m.stopCells()426- m.nb.running = nil427- m.nb.count = 0437+ m.nb.runs.running = nil438+ m.nb.runs.count = 0428439 }429440 for _, c := range m.sheet.NotebookCells() {430441 if m.book().Output(c.ID) != nil {
M internal/ui/nbscreen.go +3 -3
M internal/ui/nbstream.go +15 -15
@@ -69,7 +69,7 @@ func (m *Model) startStream(q nbQueued) tea.Cmd {
6969 return nil7070 }7171 m.stopStream(q.id)72- m.nb.queue = slices.DeleteFunc(m.nb.queue, func(x nbQueued) bool { return x == q })72+ m.nb.runs.queue = slices.DeleteFunc(m.nb.runs.queue, func(x nbQueued) bool { return x == q })7373 cells := q.s.NotebookCells()7474 i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })7575 if why := notebook.Taken(cells, i); why != "" {
@@ -90,17 +90,17 @@ func (m *Model) startStream(q nbQueued) tea.Cmd {
9090 return err9191 })9292 src.Wait = streamOutputEvery // an idle poll brings the output up to date93- m.nb.count++94- st := &nbStream{nbRun: run, src: src, count: m.nb.count}95- if m.nb.streams == nil {96- m.nb.streams = map[int]*nbStream{}93+ m.nb.runs.count++94+ st := &nbStream{nbRun: run, src: src, count: m.nb.runs.count}95+ if m.nb.runs.streams == nil {96+ m.nb.runs.streams = map[int]*nbStream{}9797 }98- m.nb.streams[q.id] = st98+ m.nb.runs.streams[q.id] = st9999 m.showStream(st)100100 st.shown = time.Time{} // the first rows show at once101101 m.feedOutput(c.Name())102102 m.note = "Following cell " + strconv.Itoa(i+1) + " as a stream: Stop (i i) ends it"103- return tea.Batch(pollStream(st), m.tickStreams())103+ return tea.Batch(m.roomOwned(pollStream(st)), m.tickStreams())104104 }105105 106106 // pollStream polls st's stream on a goroutine of its own.
@@ -115,7 +115,7 @@ func pollStream(st *nbStream) tea.Cmd {
115115 // the output was sent to, and to the output, until the stream ends.116116 func (m *Model) streamPolled(msg nbStreamMsg) tea.Cmd {117117 st := msg.st118- if m.nb.streams[st.id] != st {118+ if m.nb.runs.streams[st.id] != st {119119 st.src.Close() // stopped, or started again since120120 return nil121121 }
@@ -134,14 +134,14 @@ func (m *Model) streamPolled(msg nbStreamMsg) tea.Cmd {
134134 }135135 }136136 if done, err := st.src.Ended(); done {137- delete(m.nb.streams, st.id)137+ delete(m.nb.runs.streams, st.id)138138 m.endStream(st, err)139139 return nil140140 }141141 if st.behind && time.Since(st.shown) >= streamOutputEvery {142142 m.showStream(st)143143 }144- return pollStream(st)144+ return m.roomOwned(pollStream(st))145145 }146146 147147 // showStream keeps what the stream has printed so far as the cell's
@@ -165,18 +165,18 @@ func (m *Model) endStream(st *nbStream, err error) {
165165 166166 // stopStream stops cell id's stream, keeping what it printed.167167 func (m *Model) stopStream(id int) {168- st := m.nb.streams[id]168+ st := m.nb.runs.streams[id]169169 if st == nil {170170 return171171 }172- delete(m.nb.streams, id)172+ delete(m.nb.runs.streams, id)173173 st.src.Close()174174 m.showStream(st)175175 }176176 177177 // stopStreams stops every stream.178178 func (m *Model) stopStreams() {179- for id := range m.nb.streams {179+ for id := range m.nb.runs.streams {180180 m.stopStream(id)181181 }182182 }
@@ -184,7 +184,7 @@ func (m *Model) stopStreams() {
184184 // closeStreams stops every stream's process as the program ends,185185 // touching nothing else: the model may be broken by a crash.186186 func (m *Model) closeStreams() {187- for _, st := range m.nb.streams {187+ for _, st := range m.nb.runs.streams {188188 st.src.Close()189189 }190190 }
@@ -205,7 +205,7 @@ type nbStreamTickMsg struct{}
205205 // streamTicked keeps the ticks going while a stream runs.206206 func (m *Model) streamTicked() tea.Cmd {207207 m.nb.streamTick = false208- if len(m.nb.streams) == 0 {208+ if len(m.nb.runs.streams) == 0 {209209 return nil210210 }211211 return m.tickStreams()
M internal/ui/nbstream_test.go +3 -3
M internal/ui/notebook.go +4 -2
M internal/ui/notebook_test.go +1 -1
M internal/ui/panel.go +6 -1
M internal/ui/pipe_test.go +1 -1
M internal/ui/pivot.go +3 -0
M internal/ui/recovery.go +7 -0
A internal/ui/rooms.go +189 -0
@@ -0,0 +1,189 @@
1+package ui2+3+import (4+ "context"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Shared is a served session's guarded model as Bubble Tea runs it when12+// workbooks are shared: every turn the model takes (Init, Update, View)13+// holds its room's lock (room.Seat.Do), with its steps made in its own14+// name, so sessions act on the workbook one at a time, in the order the15+// server takes them. Between turns it joins and leaves rooms, never16+// holding two locks. It also holds back input while someone else's step17+// is open (a macro they run spans several turns) and hands it on once18+// the step ends, and in a one-writer room it takes back what a19+// follower changed by a way no check stopped.20+type Shared struct {21+ g *Guarded22+}23+24+// InRooms runs g's model in the rooms it was given (Model.ShareRooms).25+func InRooms(g *Guarded) *Shared { return &Shared{g: g} }26+27+// Attach sends the program a roomMsg whenever the model's room changes,28+// until ctx ends.29+func (s *Shared) Attach(ctx context.Context, p *tea.Program) {30+ link := s.g.m.share.link31+ go func() {32+ for {33+ select {34+ case <-ctx.Done():35+ return36+ case <-link.kick:37+ p.Send(roomMsg{})38+ }39+ }40+ }()41+}42+43+// Init implements tea.Model.44+func (s *Shared) Init() tea.Cmd {45+ var cmd tea.Cmd46+ s.turn(func() { cmd = s.g.Init() })47+ return tea.Batch(cmd, s.between())48+}49+50+// Update implements tea.Model.51+func (s *Shared) Update(msg tea.Msg) (tea.Model, tea.Cmd) {52+ var cmd tea.Cmd53+ s.turn(func() { cmd = s.update(msg) })54+ return s, tea.Batch(cmd, s.between())55+}56+57+// View implements tea.Model.58+func (s *Shared) View() tea.View {59+ var v tea.View60+ s.turn(func() { v = s.g.View() })61+ return v62+}63+64+// update is a turn's Update: input held while another's step is open,65+// and handed on once it ends.66+func (s *Shared) update(msg tea.Msg) tea.Cmd {67+ m := s.g.m68+ seat := m.share.seat69+ if seat != nil && isInput(msg) {70+ if open, who := seat.Book().InStep(); open && who != seat.ID() {71+ m.share.held = append(m.share.held, msg)72+ return nil73+ }74+ }75+ _, cmd := s.g.Update(msg)76+ if _, ok := msg.(roomMsg); !ok || len(m.share.held) == 0 {77+ return cmd78+ }79+ if open, _ := seat.Book().InStep(); open {80+ return cmd81+ }82+ held := m.share.held83+ m.share.held = nil84+ cmds := []tea.Cmd{cmd}85+ for _, in := range held {86+ _, c := s.g.Update(in)87+ cmds = append(cmds, c)88+ }89+ return tea.Batch(cmds...)90+}91+92+// isInput reports whether msg is the user's input, which waits while93+// another's step is open.94+func isInput(msg tea.Msg) bool {95+ switch msg.(type) {96+ case tea.KeyPressMsg, tea.PasteMsg, tea.MouseClickMsg, tea.MouseReleaseMsg, tea.MouseMotionMsg, tea.MouseWheelMsg:97+ return true98+ }99+ return false100+}101+102+// turn runs fn holding the model's room, if it has one; after it, the103+// others learn where the user is.104+func (s *Shared) turn(fn func()) {105+ m := s.g.m106+ seat := m.share.seat107+ if seat == nil {108+ fn()109+ return110+ }111+ seat.Do(func(w *sheet.Workbook) {112+ w.SetTrace(m.spans)113+ before := w.LastOp()114+ fn()115+ if s.g.crash != nil {116+ if open, who := w.InStep(); open && who == seat.ID() {117+ w.EndStep() // the crashed session's macro ends here118+ }119+ return120+ }121+ if m.share.seat == seat && m.book() == w {122+ s.g.m.takeBack(w, before)123+ seat.Move(m.presence())124+ }125+ })126+}127+128+// takeBack undoes the steps a follower made in a one-writer room, which129+// only the writer edits.130+func (m *Model) takeBack(w *sheet.Workbook, before uint64) {131+ seat := m.share.seat132+ if seat.Writing() {133+ return134+ }135+ ops, _ := w.OpsSince(before)136+ for _, o := range ops {137+ if o.Author == seat.ID() && o.Kind == sheet.OpDo {138+ w.Undo()139+ m.mayEdit() // says why140+ }141+ }142+}143+144+// between joins and leaves rooms after a turn: leaving the room of a145+// workbook the model no longer shows, and joining the one it asked for.146+func (s *Shared) between() tea.Cmd {147+ m := s.g.m148+ if s.g.crash != nil {149+ return nil150+ }151+ if seat := m.share.seat; seat != nil && (m.share.want != nil || m.book() != seat.Book()) {152+ m.share.seat = nil153+ m.leftRoom(seat.Leave())154+ }155+ req := m.share.want156+ if req == nil {157+ return nil158+ }159+ m.share.want = nil160+ seat, created := m.share.reg.Join(req.key, m.share.link, func() *sheet.Workbook {161+ if req.book == nil {162+ return sheet.New().Book()163+ }164+ return req.book165+ })166+ var cmd tea.Cmd167+ seat.Do(func(*sheet.Workbook) {168+ cmd = s.g.do(func() tea.Cmd { return m.enterRoom(seat, created, req) })169+ seat.Move(m.presence())170+ })171+ return cmd172+}173+174+// leftRoom lets go of what the model shared in a room it left: the last175+// one out stops what the room ran; otherwise it goes on without this176+// model.177+func (m *Model) leftRoom(last bool) {178+ if last {179+ m.stopCells()180+ return181+ }182+ m.nb.runs = &nbRuns{}183+ for _, f := range m.follow.by {184+ m.dropFollower(f)185+ }186+}187+188+// Model is the model run.189+func (s *Shared) Model() *Model { return s.g.m }
A internal/ui/share.go +289 -0
@@ -0,0 +1,289 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/room"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Sharing a workbook between the sessions of 012 serve: every session15+// that opens the same file (or joins the same named room, @name) sits16+// in one room (package room) and works on its one workbook, each with17+// its own cursor, scroll, selection, theme and modes. The session's18+// model runs its turns (Update, View) under the room's lock (Shared,19+// rooms.go), so the workbook's one mutation path orders everyone's20+// steps; each step is its author's, so undo takes back only one's own21+// (sheet/authors.go). When the room changes, the others are told22+// (roomMsg), draw what changed and see where each other is23+// (sharedraw.go). Saving is the room's: every session counts unsaved24+// changes from the last save anyone made, and asks about them only25+// when it's the last one there. Linked files are followed, and26+// notebook cells run, by the room (nbRuns, follow.go), so they go on27+// while anyone is there. See docs/terminal/ssh.md.28+29+// shareState is a served session's part in the rooms.30+type shareState struct {31+ reg *room.Registry // nil: not shared (the local app, or serve without rooms)32+ link *peerLink // the session as a participant33+ seat *room.Seat // the room of the workbook shown; nil in a workbook of its own34+ want *joinReq // a room to join once the turn is over35+ seen uint64 // the room's last operation this model has looked at36+ held []tea.Msg // input held while another's step is open (a macro running)37+ // entryFrom is the room's last operation when the entry being typed38+ // began, to tell whether someone changed the cell meanwhile.39+ entryFrom uint6440+ // frame is what the frame draws of the others, and ticking is set41+ // while a redraw waits for marks to go stale: sharedraw.go.42+ frame shareFrame43+ ticking bool44+ // still stops the ticks, so tests that run commands in line don't45+ // wait on them.46+ still bool47+}48+49+// joinReq is a room to join: its key, the file's name as typed, and50+// for a room not yet open, its workbook (nil for a new one) and the51+// file's stamp.52+type joinReq struct {53+ key, name string54+ book *sheet.Workbook55+ stamp stamp56+}57+58+// roomMsg tells the model its room changed.59+type roomMsg struct{}60+61+// ShareRooms makes a served session open files in the rooms of reg, as62+// user: sessions opening the same file share its workbook.63+func (m *Model) ShareRooms(reg *room.Registry, user string) {64+ m.share.reg = reg65+ m.share.link = &peerLink{name: user, kick: make(chan struct{}, 1)}66+}67+68+// shared reports whether the workbook is shown in a room with others.69+func (m *Model) shared() bool { return m.share.seat != nil && m.share.seat.Others() > 0 }70+71+// roomKey is the room of the file named name: its path on disk, or the72+// name itself for a named room (@name).73+func (m *Model) roomKey(name string) (string, bool) {74+ if strings.HasPrefix(name, "@") {75+ return name, true76+ }77+ p, ok := m.path("open", name)78+ if !ok {79+ return "", false80+ }81+ if abs, err := filepath.Abs(p); err == nil {82+ p = abs83+ }84+ return p, true85+}86+87+// openShared opens the file named name, at path on disk, in its room:88+// joining the room when it's open, reading the file first otherwise.89+func (m *Model) openShared(name, path string) tea.Cmd {90+ key, ok := m.roomKey(name)91+ if !ok {92+ return nil93+ }94+ if m.share.reg.Has(key) {95+ m.share.want = &joinReq{key: key, name: name}96+ return nil97+ }98+ if _, err := os.Stat(path); os.IsNotExist(err) {99+ m.share.want = &joinReq{key: key, name: name, book: sheet.New().Book()}100+ return nil101+ }102+ return loadCmd(name, path, m.spans.Parent())103+}104+105+// joinNamed joins the named room @name, opening it on a new workbook.106+func (m *Model) joinNamed(name string) {107+ m.share.want = &joinReq{key: name}108+}109+110+// loadedShared takes a file read to be opened in its room.111+func (m *Model) loadedShared(msg loadedMsg) {112+ key, ok := m.roomKey(msg.name)113+ if !ok {114+ return115+ }116+ m.share.want = &joinReq{key: key, name: msg.name, book: msg.sheet.Book(), stamp: msg.stamp}117+}118+119+// enterRoom shows the room's workbook, as opening a file does. It runs120+// in a turn of the new seat.121+func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {122+ w := seat.Book()123+ s := w.Sheets()[w.Active()]124+ if !s.Live() || s.Hidden() {125+ s = w.Visible()[0]126+ }127+ m.reset(s, req.name)128+ m.share.seat, m.share.seen = seat, seat.Last()129+ m.nb.runs = seat.Value("nb", func() any { return &nbRuns{} }).(*nbRuns)130+ if created {131+ m.disk = req.stamp132+ seat.SetSaved(m.savedState())133+ m.offerRecovery(req.name)134+ } else {135+ m.syncSaved()136+ }137+ if peers := seat.Peers(); len(peers) > 0 {138+ m.note = "Sharing " + m.displayName() + " with " + peerNames(peers)139+ }140+ return tea.Batch(m.syncFollowers(), m.notebookSync())141+}142+143+// savedState is the room's file as this model last saved or opened it.144+func (m *Model) savedState() room.Saved {145+ key := ""146+ if m.filename != "" {147+ key, _ = m.roomKey(m.filename)148+ }149+ return room.Saved{State: m.book().StateID(), Outputs: m.book().OutputsChanged(), Name: m.filename, Key: key, Stamp: m.disk}150+}151+152+// syncSaved takes the room's file as anyone last saved it: its name,153+// and what unsaved changes count from.154+func (m *Model) syncSaved() {155+ v := m.share.seat.Saved()156+ m.saved, m.nb.saved = v.State, v.Outputs157+ if st, ok := v.Stamp.(stamp); ok {158+ m.disk = st159+ }160+ if v.Name != m.filename {161+ m.filename = v.Name162+ }163+ m.changed = m.book().StateID() != m.saved || m.book().OutputsChanged() != m.nb.saved164+}165+166+// roomChanged catches up with the room: what was posted for the keeper,167+// others' operations, a save, a sheet deleted from under the view.168+func (m *Model) roomChanged() tea.Cmd {169+ seat := m.share.seat170+ if seat == nil {171+ return nil172+ }173+ var cmds []tea.Cmd174+ for _, msg := range seat.Take() {175+ _, cmd := m.Update(msg)176+ cmds = append(cmds, cmd)177+ }178+ m.othersChanged(seat.Marks(m.share.seen))179+ m.share.seen = seat.Last()180+ m.syncSaved()181+ if !m.sheet.Live() || m.sheet.Hidden() {182+ m.afterSheetsChange(nil, m.book().Active())183+ }184+ m.cur = clampAddr(m.cur)185+ return tea.Batch(cmds...)186+}187+188+// othersChanged says on the context line when someone else changed the189+// cell being typed into, or the one the pointer is on.190+func (m *Model) othersChanged(marks []room.Mark) {191+ seat := m.share.seat192+ for _, mk := range marks {193+ if mk.Author == seat.ID() || mk.Sheet != m.sheet || !mk.Rect.Contains(m.cur) {194+ continue195+ }196+ switch {197+ case m.mode == modeEnter || m.mode == modeEdit:198+ m.warn = mk.Name + " changed " + m.cur.String() + " while you type: Enter puts yours over theirs, Esc keeps theirs"199+ case m.mode == modeReady:200+ m.note = mk.Name + " changed " + m.cur.String()201+ }202+ }203+}204+205+// presence is where the user is, for the others.206+func (m *Model) presence() room.Presence {207+ sel := sheet.Rect{From: m.cur, To: m.cur}208+ if r, ok := m.highlight(); ok {209+ sel = r210+ }211+ return room.Presence{Sheet: m.sheet, Cursor: m.active(), Selection: sel, Editing: m.mode == modeEnter || m.mode == modeEdit}212+}213+214+// mayEdit reports whether the user may change the workbook: always,215+// but in a one-writer room only the writer. When not, it says who216+// writes.217+func (m *Model) mayEdit() bool {218+ seat := m.share.seat219+ if seat == nil || seat.Writing() {220+ return true221+ }222+ m.warn = seat.Writer() + " writes here: you follow. Ask them to hand writing to you (File > Hand over writing)"223+ return false224+}225+226+// LeaveRoom gives up the session's seat as it ends, reporting whether227+// it was the last one there (or had a workbook of its own), whose228+// unsaved changes are then its to keep: others keep a room's workbook229+// open. The last one out stops what the room ran.230+func (m *Model) LeaveRoom() bool {231+ seat := m.share.seat232+ if seat == nil {233+ m.closeStreams()234+ return true235+ }236+ m.share.seat = nil237+ if !seat.Leave() {238+ return false239+ }240+ m.closeStreams()241+ return true242+}243+244+// Others is how many others share the session's workbook, and their245+// names.246+func (m *Model) Others() (int, string) {247+ if m.share.seat == nil {248+ return 0, ""249+ }250+ var n int251+ var names string252+ m.share.seat.Do(func(*sheet.Workbook) {253+ peers := m.share.seat.Peers()254+ n, names = len(peers), peerNames(peers)255+ })256+ return n, names257+}258+259+// peerNames lists peers' names: "ann", "ann and bob", "ann, bob and cy".260+func peerNames(peers []room.Peer) string {261+ names := make([]string, len(peers))262+ for i, p := range peers {263+ names[i] = p.Name264+ }265+ switch len(names) {266+ case 0:267+ return ""268+ case 1:269+ return names[0]270+ }271+ return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]272+}273+274+// peerLink is a served session as a room's participant: telling it the275+// room changed kicks a goroutine that sends its program a roomMsg276+// (Shared.Attach).277+type peerLink struct {278+ name string279+ kick chan struct{}280+}281+282+func (p *peerLink) Name() string { return p.name }283+284+func (p *peerLink) Notify() {285+ select {286+ case p.kick <- struct{}{}:287+ default:288+ }289+}
A internal/ui/share_test.go +255 -0
@@ -0,0 +1,255 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/confine"13+ "github.com/FelineStateMachine/012/internal/room"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// seshion is a served session in a room, driven as its program would:18+// every message through Shared.19+type seshion struct {20+ t *testing.T21+ sh *Shared22+ m *Model23+}24+25+// servedRooms is a served directory and the rooms of its sessions.26+type servedRooms struct {27+ t *testing.T28+ dir string29+ root confine.Root30+ reg *room.Registry31+ all []*seshion32+}33+34+func newRooms(t *testing.T, mode room.Mode) *servedRooms {35+ dir := t.TempDir()36+ root, err := confine.New(dir)37+ if err != nil {38+ t.Fatal(err)39+ }40+ return &servedRooms{t: t, dir: dir, root: root, reg: room.NewRegistry(mode)}41+}42+43+// open starts user's session on name, as ssh -t host name does, and44+// catches everyone up.45+func (r *servedRooms) open(user, name string) *seshion {46+ m := New(sheet.New(), "")47+ m.Serve(r.root, []string{"TERM=xterm-256color"})48+ m.ShareRooms(r.reg, user)49+ m.OpenOnStart(name)50+ m.SetMachine("here")51+ m.share.still, m.nb.still = true, true52+ s := &seshion{t: r.t, sh: InRooms(Guard(m)), m: m}53+ s.run(s.sh.Init())54+ s.send(tea.WindowSizeMsg{Width: 80, Height: 20})55+ r.all = append(r.all, s)56+ r.sync()57+ return s58+}59+60+// sync delivers roomMsg to every session its room told, until none is61+// waiting: what Shared.Attach does for a program.62+func (r *servedRooms) sync() {63+ for range 10 {64+ idle := true65+ for _, s := range r.all {66+ select {67+ case <-s.m.share.link.kick:68+ idle = false69+ s.send(roomMsg{})70+ default:71+ }72+ }73+ if idle {74+ return75+ }76+ }77+}78+79+func (s *seshion) update(msg tea.Msg) tea.Cmd {80+ _, cmd := s.sh.Update(msg)81+ return cmd82+}83+84+func (s *seshion) run(cmd tea.Cmd) tea.Msg { return runVia(s.update, cmd) }85+86+func (s *seshion) send(msg tea.Msg) tea.Msg { return s.run(s.update(msg)) }87+88+func (s *seshion) press(keys ...string) tea.Msg {89+ s.t.Helper()90+ return pressTo(s.t, s.send, keys...)91+}92+93+func (s *seshion) screen() string {94+ var out string95+ s.sh.turn(func() { out = ansi.Strip(s.m.View().Content) })96+ return out97+}98+99+// Two sessions opening one file share its workbook: what one types the100+// other sees, where each is shows on the other's screen, and each has101+// its own cursor.102+func TestSharedSessionsSeeEachOther(t *testing.T) {103+ r := newRooms(t, room.Edit)104+ writeSheet(t, filepath.Join(r.dir, "budget.012"), "rent")105+ ann := r.open("ann", "budget.012")106+ bob := r.open("bob", "budget.012")107+ if ann.m.book() != bob.m.book() {108+ t.Fatal("two workbooks")109+ }110+ if !strings.Contains(bob.m.note, "Sharing budget.012 with ann") {111+ t.Errorf("bob's note %q", bob.m.note)112+ }113+ ann.press("<down>", "1200", "<enter>")114+ r.sync()115+ if got := bob.m.sheet.Value(addr("A2")).Num; got != 1200 {116+ t.Fatalf("bob sees A2 = %v", got)117+ }118+ if !strings.Contains(bob.screen(), "1200") {119+ t.Errorf("bob's screen:\n%s", bob.screen())120+ }121+ bob.press("<right>", "<right>")122+ r.sync()123+ if bob.m.cur != addr("C1") || ann.m.cur != addr("A3") {124+ t.Errorf("cursors: ann %v, bob %v", ann.m.cur, bob.m.cur)125+ }126+ scr := ann.screen()127+ if !strings.Contains(scr, "b 1 rent") {128+ t.Errorf("bob's initial isn't on row 1's header:\n%s", scr)129+ }130+ if !strings.Contains(scr, " bob ") {131+ t.Errorf("the status line doesn't list bob:\n%s", scr)132+ }133+ // Ann goes to bob's cell from Who's here.134+ run(ann.m, nil)135+ ann.run(ann.m.runCommand("share.who"))136+ ann.press("<enter>")137+ ann.screen() // a frame, which works out where the others are138+ if ann.m.cur != addr("C1") || !strings.Contains(ansi.Strip(ann.m.shareLine()), "bob is here") {139+ t.Errorf("ann at %v:\n%s", ann.m.cur, ann.screen())140+ }141+}142+143+// Undo takes back one's own changes, and says whose later change is in144+// the way when it can't.145+func TestSharedUndoIsYourOwn(t *testing.T) {146+ r := newRooms(t, room.Edit)147+ ann := r.open("ann", "@plan")148+ bob := r.open("bob", "@plan")149+ ann.press("mine", "<enter>")150+ r.sync()151+ bob.press("<right>", "theirs", "<enter>")152+ r.sync()153+ ann.press("<ctrl+z>")154+ r.sync()155+ s := ann.m.sheet156+ if s.Filled(addr("A1")) || s.Value(addr("B1")).Str != "theirs" {157+ t.Fatalf("after ann's undo: A1 %v, B1 %v", s.Value(addr("A1")), s.Value(addr("B1")))158+ }159+ if !strings.Contains(bob.screen(), "theirs") || strings.Contains(bob.screen(), "mine") {160+ t.Errorf("bob's screen:\n%s", bob.screen())161+ }162+ ann.press("<ctrl+y>")163+ r.sync()164+ bob.press("<left>", "<up>", "bob's", "<enter>")165+ r.sync()166+ ann.press("<ctrl+z>")167+ if !strings.Contains(ann.m.warn, "Can't undo") || !strings.Contains(ann.m.warn, "bob") || s.Value(addr("A1")).Str != "bob's" {168+ t.Errorf("ann's undo: %q, A1 %v", ann.m.warn, s.Value(addr("A1")))169+ }170+ if ann.m.displayName() != "@plan" {171+ t.Errorf("named %q", ann.m.displayName())172+ }173+}174+175+// An entry typed over a cell someone else changed meanwhile goes in, in176+// the server's order, and both are told.177+func TestSharedConflictingEntries(t *testing.T) {178+ r := newRooms(t, room.Edit)179+ ann := r.open("ann", "@c")180+ bob := r.open("bob", "@c")181+ ann.press("a", "nn")182+ bob.press("bob", "<enter>")183+ r.sync()184+ if !strings.Contains(ann.m.warn, "bob changed A1 while you type") {185+ t.Errorf("ann wasn't told: %q", ann.m.warn)186+ }187+ ann.press("<enter>")188+ r.sync()189+ if ann.m.sheet.Value(addr("A1")).Str != "ann" || !strings.Contains(ann.m.warn, "replaced bob's change to A1") {190+ t.Errorf("A1 %v, ann's warning %q", ann.m.sheet.Value(addr("A1")), ann.m.warn)191+ }192+ bob.press("<up>")193+ if !strings.Contains(bob.screen(), "Changed by") {194+ t.Errorf("bob's context line:\n%s", bob.screen())195+ }196+}197+198+// Saving is the room's: a save by one clears the others' modified mark,199+// and quitting while others remain asks nothing.200+func TestSharedSaveAndQuit(t *testing.T) {201+ r := newRooms(t, room.Edit)202+ ann := r.open("ann", "notes.012")203+ bob := r.open("bob", "notes.012")204+ bob.press("x", "<enter>")205+ r.sync()206+ if !ann.m.changed || !bob.m.changed {207+ t.Fatalf("modified: ann %v, bob %v", ann.m.changed, bob.m.changed)208+ }209+ ann.press("<ctrl+s>")210+ r.sync()211+ if ann.m.changed || bob.m.changed {212+ t.Errorf("after ann saved, modified: ann %v, bob %v", ann.m.changed, bob.m.changed)213+ }214+ if _, err := os.Stat(filepath.Join(r.dir, "notes.012")); err != nil {215+ t.Fatal(err)216+ }217+ bob.press("y", "<enter>")218+ r.sync()219+ if msg := ann.press("<ctrl+q>"); msg == nil {220+ t.Errorf("ann asked before quitting, bob still there: %q", ann.screen())221+ }222+ if ann.m.LeaveRoom() {223+ t.Error("ann was the last")224+ }225+ r.sync()226+ if !bob.m.LeaveRoom() || !bob.m.Unsaved() {227+ t.Error("bob, last, doesn't keep his unsaved change")228+ }229+}230+231+// In a one-writer room the others follow: they can't edit until the232+// writer hands writing over.233+func TestSharedOneWriter(t *testing.T) {234+ r := newRooms(t, room.View)235+ ann := r.open("ann", "@talk")236+ bob := r.open("bob", "@talk")237+ bob.press("hello")238+ if bob.m.mode != modeReady || !strings.Contains(bob.m.warn, "ann writes here") {239+ t.Fatalf("bob typed: mode %v, %q", bob.m.mode, bob.m.warn)240+ }241+ if !strings.Contains(bob.screen(), "ann writes") {242+ t.Errorf("bob's status line:\n%s", bob.screen())243+ }244+ ann.run(ann.m.runCommand("share.hand"))245+ ann.press("<enter>")246+ r.sync()247+ bob.press("hello", "<enter>")248+ if bob.m.sheet.Value(addr("A1")).Str != "hello" {249+ t.Errorf("bob, writing, couldn't type: %q", bob.m.warn)250+ }251+ ann.press("<down>", "no", "<enter>")252+ if ann.m.sheet.Filled(addr("A2")) {253+ t.Error("ann typed after handing over")254+ }255+}
A internal/ui/sharecmds.go +178 -0
@@ -0,0 +1,178 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/room"9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+)12+13+// The commands of a shared workbook: who's here, and handing writing14+// over in a one-writer room; what undo says when another's step is in15+// the way; and whose change an entry replaced.16+17+func init() {18+ unshared := func(m *Model) bool { return m.share.reg == nil }19+ register(20+ &command{id: "share.who", macro: macroNever, title: "Who's here", desc: "List who shares this workbook and where each is; pick one to go to their cell",21+ hidden: unshared, enabled: func(m *Model) bool { return m.shared() }, run: (*Model).openWho},22+ &command{id: "share.hand", macro: macroNever, title: "Hand over writing", desc: "Let someone else edit the shared workbook, and follow them",23+ hidden: unshared, enabled: func(m *Model) bool {24+ seat := m.share.seat25+ return seat != nil && seat.Mode() == room.View && seat.Writing() && seat.Others() > 026+ }, run: (*Model).openHandOver},27+ )28+}29+30+// nbReading are the notebook's commands that change nothing a follower31+// in a one-writer room may not: moving about and looking.32+var nbReading = map[string]bool{"nb.open": true, "nb.goto": true, "nb.toc": true, "nb.toggle_output": true, "nb.toggle_whole": true,33+ "nb.open_output": true, "nb.command_mode": true, "nb.copy": true}34+35+// edits reports whether command c changes the workbook, which a36+// follower in a one-writer room may not do.37+func edits(c *command) bool {38+ if strings.HasPrefix(c.id, "nb.") {39+ return !nbReading[c.id]40+ }41+ return c.macro == macroRecord42+}43+44+// openWho lists who shares the workbook, to go to one of them.45+func (m *Model) openWho() tea.Cmd {46+ var items []picker.Item47+ for _, p := range m.share.seat.Peers() {48+ where := m.peerWhere(p)49+ desc := "Go to " + p.Name + "'s cell"50+ detail := where51+ switch {52+ case p.Writing && m.share.seat.Mode() == room.View:53+ detail += ", writing"54+ case p.Presence.Editing:55+ detail += ", typing"56+ }57+ items = append(items, picker.Item{Title: p.Name, Name: len(p.Name), Detail: detail, Desc: desc,58+ Pick: func() tea.Cmd { m.closeOverlay(); m.goToPeer(p); return nil }})59+ }60+ pk := m.newPicker("Who's here", "a name", 50, items)61+ pk.Action = "go"62+ m.openOverlay(pk)63+ return nil64+}65+66+// peerWhere is where p is, Sheet2!B3, or the notebook they're on.67+func (m *Model) peerWhere(p room.Peer) string {68+ s := p.Presence.Sheet69+ if s == nil || !s.Live() {70+ return "arriving"71+ }72+ if s.IsNotebook() {73+ return s.Name()74+ }75+ return sheet.Qualified(s.Name(), sheet.Rect{From: p.Presence.Cursor, To: p.Presence.Cursor})76+}77+78+// goToPeer shows p's sheet with the pointer on their cell.79+func (m *Model) goToPeer(p room.Peer) {80+ s := p.Presence.Sheet81+ if s == nil || !s.Live() || s.Hidden() {82+ return83+ }84+ m.showSheet(s)85+ if !s.IsNotebook() {86+ m.cur = p.Presence.Cursor87+ m.clearSelection()88+ }89+ m.note = "At " + p.Name + "'s cell"90+}91+92+// openHandOver asks whom to hand writing to.93+func (m *Model) openHandOver() tea.Cmd {94+ var items []picker.Item95+ for _, p := range m.share.seat.Peers() {96+ items = append(items, picker.Item{Title: p.Name, Name: len(p.Name), Detail: m.peerWhere(p), Desc: "Let " + p.Name + " write; you follow",97+ Pick: func() tea.Cmd {98+ m.closeOverlay()99+ if m.share.seat.HandTo(p.ID) {100+ m.note = p.Name + " writes now: you follow"101+ }102+ return nil103+ }})104+ }105+ pk := m.newPicker("Hand over writing", "a name", 50, items)106+ pk.Action = "hand over"107+ m.openOverlay(pk)108+ return nil109+}110+111+// undoBlocked says why the user's undo (or redo) can't go ahead: a later112+// change of someone else's changed the same cells.113+func (m *Model) undoBlocked(redo bool) bool {114+ if m.share.seat == nil {115+ return false116+ }117+ w := m.book()118+ check, verb := w.UndoBlocked, "undo "+w.UndoLabel()119+ if redo {120+ check, verb = w.RedoBlocked, "redo"121+ }122+ b, blocked := check()123+ if !blocked {124+ return false125+ }126+ m.warn = "Can't " + verb + ": " + m.peerName(b.Author) + " changed the same cells since (" + b.Label + "). Undo takes back only your own changes"127+ return true128+}129+130+// peerName is the name of the participant with id, or "someone" when131+// they've left.132+func (m *Model) peerName(id int) string {133+ for _, p := range m.share.seat.Peers() {134+ if p.ID == id {135+ return p.Name136+ }137+ }138+ return "someone who left"139+}140+141+// roomLast is the room's latest operation, 0 outside a room.142+func (m *Model) roomLast() uint64 {143+ if m.share.seat == nil {144+ return 0145+ }146+ return m.share.seat.Last()147+}148+149+// overwrote says whose change the entry just made replaced: someone150+// else changed the cell while it was being typed. The server's order151+// decides, and the last entry stays.152+func (m *Model) overwrote() {153+ seat := m.share.seat154+ if seat == nil {155+ return156+ }157+ for _, mk := range seat.Marks(m.share.entryFrom) {158+ if mk.Author != seat.ID() && mk.Sheet == m.entrySheet() && mk.Rect.Contains(m.cur) && mk.Kind != sheet.OpUndo {159+ m.warn = "Your entry replaced " + mk.Name + "'s change to " + m.cur.String() + ", made while you typed"160+ }161+ }162+}163+164+// roomOwned has what cmd reports go to the room's keeper when the165+// workbook is shared, rather than back to this model, which may have166+// left by then.167+func (m *Model) roomOwned(cmd tea.Cmd) tea.Cmd {168+ seat := m.share.seat169+ if seat == nil || cmd == nil {170+ return cmd171+ }172+ return func() tea.Msg {173+ if msg := cmd(); msg != nil {174+ seat.Post(msg)175+ }176+ return nil177+ }178+}
A internal/ui/sharedraw.go +186 -0
@@ -0,0 +1,186 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/room"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// Drawing who else is in a shared workbook, and what they changed: each18+// other participant's pointer is their cell in their color,19+// double-underlined, with their initial in the row header; a cell they20+// changed in the last markFresh has a ▘ in its corner in their color;21+// the status line lists who's here, and the context line says who is on22+// the active cell or last changed it. Colors and cues are theme.Peer.23+24+// markFresh is how long a cell another changed stays marked.25+const markFresh = 30 * time.Second26+27+// shareFrame is what a frame draws of the others, worked out once as it28+// starts: their pointers on the sheet shown, and the fresh marks.29+type shareFrame struct {30+ peers []room.Peer31+ here []room.Peer // on the sheet shown32+ fresh []room.Mark // others' changes on the sheet shown, newest last33+}34+35+// shareTickMsg redraws once marks have gone stale.36+type shareTickMsg struct{}37+38+// frameShare works out the others for the frame about to be drawn.39+func (m *Model) frameShare() {40+ f := &m.share.frame41+ f.peers, f.here, f.fresh = nil, f.here[:0], f.fresh[:0]42+ seat := m.share.seat43+ if seat == nil {44+ return45+ }46+ f.peers = seat.Peers()47+ for _, p := range f.peers {48+ if p.Presence.Sheet == m.sheet {49+ f.here = append(f.here, p)50+ }51+ }52+ now := time.Now()53+ for _, mk := range seat.Marks(0) {54+ if mk.Author != seat.ID() && mk.Sheet == m.sheet && now.Sub(mk.At) < markFresh && mk.Kind != sheet.OpUndo {55+ f.fresh = append(f.fresh, mk)56+ }57+ }58+}59+60+// peerAt is the other whose pointer is on a, if one is.61+func (m *Model) peerAt(a sheet.Addr) (room.Peer, bool) {62+ for _, p := range m.share.frame.here {63+ if p.Presence.Cursor == a {64+ return p, true65+ }66+ }67+ return room.Peer{}, false68+}69+70+// peerRole is the role of a cell under another's pointer.71+func (m *Model) peerRole(a sheet.Addr) (*lipgloss.Style, bool) {72+ p, ok := m.peerAt(a)73+ if !ok {74+ return nil, false75+ }76+ st := m.th.PeerCell(p.Color)77+ return &st, true78+}79+80+// freshMark is the newest of others' fresh changes covering a.81+func (m *Model) freshMark(a sheet.Addr) (room.Mark, bool) {82+ f := m.share.frame.fresh83+ for i := len(f) - 1; i >= 0; i-- {84+ if f[i].Rect.Contains(a) {85+ return f[i], true86+ }87+ }88+ return room.Mark{}, false89+}90+91+// changedMark draws the mark of a cell another changed lately over the92+// first column of its text.93+func (m *Model) changedMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {94+ mk, ok := m.freshMark(a)95+ if !ok || m.sheet.ColWidth(a.Col) < 2 {96+ return text97+ }98+ mark := m.th.PeerMark[mk.Color%theme.Peers]99+ if colored {100+ mark = base101+ }102+ return mark.Render("▘") + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))103+}104+105+// rowTags are the initials of those whose pointer is on row, as chips106+// in their colors, and how many columns they take.107+func (m *Model) rowTags(row int) (string, int) {108+ var b strings.Builder109+ n := 0110+ for _, p := range m.share.frame.here {111+ if p.Presence.Cursor.Row != row || n >= 2 {112+ continue113+ }114+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(initial(p.Name)))115+ n++116+ }117+ return b.String(), n118+}119+120+// initial is a name's first letter.121+func initial(name string) string {122+ for _, r := range name {123+ return string(r)124+ }125+ return "?"126+}127+128+// peersStatus is the status line's part about the others: their names129+// in their colors, and in a one-writer room who writes.130+func (m *Model) peersStatus() string {131+ peers := m.share.frame.peers132+ if len(peers) == 0 {133+ return ""134+ }135+ var b strings.Builder136+ b.WriteString(" ")137+ for i, p := range peers {138+ if i == 3 {139+ b.WriteString(m.th.Muted.Render(" +" + strconv.Itoa(len(peers)-3)))140+ break141+ }142+ if i > 0 {143+ b.WriteString(" ")144+ }145+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(" " + ansi.Truncate(p.Name, 10, "…") + " "))146+ }147+ if seat := m.share.seat; seat.Mode() == room.View {148+ if seat.Writing() {149+ b.WriteString(m.th.Muted.Render(" you write"))150+ } else {151+ b.WriteString(m.th.Muted.Render(" " + seat.Writer() + " writes"))152+ }153+ }154+ return b.String()155+}156+157+// shareLine is the context line's word on the active cell: who else is158+// on it, or who changed it lately.159+func (m *Model) shareLine() string {160+ if m.share.seat == nil {161+ return ""162+ }163+ if p, ok := m.peerAt(m.cur); ok {164+ what := " is here"165+ if p.Presence.Editing {166+ what = " is typing here"167+ }168+ return m.th.Peer[p.Color%theme.Peers].Render(" "+p.Name+" ") + m.th.Muted.Render(what)169+ }170+ if mk, ok := m.freshMark(m.cur); ok {171+ ago := time.Since(mk.At).Round(time.Second)172+ return m.th.Muted.Render("Changed by ") + m.th.Peer[mk.Color%theme.Peers].Render(" "+mk.Name+" ") +173+ m.th.Muted.Render(" "+ago.String()+" ago: "+mk.Label)174+ }175+ return ""176+}177+178+// shareTick redraws once the freshest mark has gone stale, while there179+// are marks.180+func (m *Model) shareTick() tea.Cmd {181+ if len(m.share.frame.fresh) == 0 || m.share.ticking || m.share.still {182+ return nil183+ }184+ m.share.ticking = true185+ return tea.Tick(markFresh, func(time.Time) tea.Msg { return shareTickMsg{} })186+}
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +41 -0
@@ -175,11 +175,51 @@ type Theme struct {
175175 SeriesBg [chart.Colors]lipgloss.Style176176 SeriesANSI [chart.Colors]int177177 178+ // Peers are the others sharing a workbook in 012 serve, each in a179+ // color of their own: their pointer's cell in it, with a double180+ // underline so it reads without color, and their initial as a chip181+ // in it on the row header and their name on the status line (Peer,182+ // text readable on the color); a cell they changed lately has a ▘ in183+ // its top-left corner in their color (PeerMark).184+ Peer [Peers]lipgloss.Style185+ PeerMark [Peers]lipgloss.Style186+178187 // levels are the contrast minimums the roles meet: WCAG AA, or AAA179188 // for a high-contrast scheme.180189 levels levels181190 }182191 192+// Peers is how many colors tell the others sharing a workbook apart.193+const Peers = 6194+195+// peerRoles are the others' colors: on a dark terminal with black196+// text, on a light one with text in black or white, whichever reads on197+// the color. Blue and cyan stay the user's own (the selection, the198+// pointer) where they can.199+func peerRoles(t *Theme, dark bool) {200+ type pair struct{ bg, fg ansi.BasicColor }201+ peers := [Peers]pair{{lipgloss.Magenta, lipgloss.Black}, {lipgloss.Green, lipgloss.Black}, {lipgloss.Yellow, lipgloss.Black},202+ {lipgloss.BrightBlue, lipgloss.Black}, {lipgloss.Red, lipgloss.Black}, {lipgloss.White, lipgloss.Black}}203+ if !dark {204+ peers = [Peers]pair{{lipgloss.Magenta, lipgloss.BrightWhite}, {lipgloss.Green, lipgloss.Black}, {lipgloss.Yellow, lipgloss.Black},205+ {lipgloss.Blue, lipgloss.BrightWhite}, {lipgloss.Red, lipgloss.BrightWhite}, {lipgloss.White, lipgloss.Black}}206+ }207+ for i, p := range peers {208+ t.Peer[i] = lipgloss.NewStyle().Background(p.bg).Foreground(p.fg)209+ mark := p.bg210+ if mark == lipgloss.White && !dark {211+ mark = lipgloss.BrightBlack // white on a light screen doesn't show212+ }213+ t.PeerMark[i] = lipgloss.NewStyle().Foreground(mark)214+ }215+}216+217+// PeerCell is the role of a cell under another's pointer: their color,218+// double-underlined.219+func (t *Theme) PeerCell(i int) lipgloss.Style {220+ return t.Peer[i%Peers].UnderlineStyle(lipgloss.UnderlineDouble)221+}222+183223 // ImageID is the style of an image's Unicode placeholders: the184224 // foreground color is not a color but the image's id, in the 256-color185225 // palette, which is how the terminal knows which image to draw there.
@@ -340,6 +380,7 @@ func roles(dark bool) Theme {
340380 }341381 ruleRoles(&t, dark)342382 codeRoles(&t, dark)383+ peerRoles(&t, dark)343384 for i, c := range series {344385 t.Series[i] = lipgloss.NewStyle().Foreground(c)345386 t.SeriesBg[i] = lipgloss.NewStyle().Background(c)
M internal/ui/view.go +12 -2