1package ui23import (4 "os"5 "path/filepath"6 "strings"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/cowork"11 "src.rest/cat/012/internal/room"12 "src.rest/cat/012/internal/sheet"13)1415// Sharing a workbook between the sessions of 012 serve: every session16// that opens the same file (or joins the same named room, @name) sits17// in one room (package room) and works on its one workbook, each with18// its own cursor, scroll, selection, theme and modes. The session's19// model runs its turns (Update, View) under the room's lock (Shared,20// rooms.go), so the workbook's one mutation path orders everyone's21// steps; each step is its author's, so undo takes back only one's own22// (sheet/authors.go). When the room changes, the others are told23// (roomMsg), draw what changed and see where each other is24// (sharedraw.go). Saving is the room's: every session counts unsaved25// changes from the last save anyone made, and asks about them only26// when it's the last one there. Linked files are followed, and27// notebook cells run, by the room (nbRuns, follow.go), so they go on28// while anyone is there. See docs/terminal/ssh.md.2930// shareState is a served session's part in the rooms.31type shareState struct {32 reg *room.Registry // nil: not shared (the local app, or serve without rooms)33 link *peerLink // the session as a participant34 seat *room.Seat // the room of the workbook shown; nil in a workbook of its own35 want *joinReq // a room to join once the turn is over36 seen uint64 // the room's last operation this model has looked at37 held []tea.Msg // input held while another's step is open (a macro running)38 // entryFrom is the room's last operation when the entry being typed39 // began, to tell whether someone changed the cell meanwhile.40 entryFrom uint6441 // frame is what the frame draws of the others, and ticking is set42 // while a redraw waits for marks to go stale: sharedraw.go.43 frame shareFrame44 ticking bool45 // still stops the ticks, so tests that run commands in line don't46 // wait on them.47 still bool48 // local is set for a local session sharing its workbook with agents49 // (live mode: agents.go), which waits for its room's changes itself50 // (waiting while it does), as no server sends them.51 local, waiting bool52}5354// joinReq is a room to join: its key, the file's name as typed, and55// for a room not yet open, its workbook (nil for a new one) and the56// file's stamp.57type joinReq struct {58 key, name string59 book *sheet.Workbook60 stamp stamp61 // restored is the recovery file book was read from: the room opens62 // again on it, as unsaved changes.63 restored string64 // local is a local session's own workbook, taken into a room of its65 // own as it is (enterLocal).66 local bool67}6869// roomMsg tells the model its room changed.70type roomMsg struct{}7172// ShareRooms makes a served session open files in the rooms of reg, as73// user: sessions opening the same file share its workbook.74func (m *Model) ShareRooms(reg *room.Registry, user string) {75 m.share.reg = reg76 m.share.link = &peerLink{name: user, kick: make(chan struct{}, 1)}77}7879// shared reports whether the workbook is shown in a room with other80// people, who keep it open when this session leaves; agents don't.81func (m *Model) shared() bool { return m.share.seat != nil && m.share.seat.People() > 0 }8283// withOthers reports whether anyone else, people or agents, is in the84// workbook's room.85func (m *Model) withOthers() bool { return m.share.seat != nil && m.share.seat.Others() > 0 }8687// roomKey is the room of the file named name: its path on disk, or the88// name itself for a named room (@name).89func (m *Model) roomKey(name string) (string, bool) {90 if strings.HasPrefix(name, "@") {91 return name, true92 }93 p, ok := m.path("open", name)94 if !ok {95 return "", false96 }97 if abs, err := filepath.Abs(p); err == nil {98 p = abs99 }100 return p, true101}102103// openShared opens the file named name, at path on disk, in its room:104// joining the room when it's open, reading the file first otherwise.105func (m *Model) openShared(name, path string) tea.Cmd {106 key, ok := m.roomKey(name)107 if !ok {108 return nil109 }110 if m.share.reg.Has(key) {111 m.share.want = &joinReq{key: key, name: name}112 return nil113 }114 if _, err := os.Stat(path); os.IsNotExist(err) {115 m.share.want = &joinReq{key: key, name: name, book: sheet.New().Book()}116 return nil117 }118 return loadCmd(name, path, m.spans.Parent())119}120121// joinNamed joins the named room @name, opening it on a new workbook.122func (m *Model) joinNamed(name string) {123 m.share.want = &joinReq{key: name}124}125126// loadedShared takes a file read to be opened in its room.127func (m *Model) loadedShared(msg loadedMsg) {128 key, ok := m.roomKey(msg.name)129 if !ok {130 return131 }132 m.share.want = &joinReq{key: key, name: msg.name, book: msg.sheet.Book(), stamp: msg.stamp}133}134135// enterRoom shows the room's workbook, as opening a file does. It runs136// in a turn of the new seat.137func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {138 if req.local {139 return m.enterLocal(seat, req)140 }141 w := seat.Book()142 s := w.Sheets()[w.Active()]143 if !s.Live() || s.Hidden() {144 s = w.Visible()[0]145 }146 m.reset(s, req.name)147 m.share.seat, m.share.seen = seat, seat.Last()148 m.nb.runs = seat.Value("nb", func() any { return &nbRuns{} }).(*nbRuns)149 m.src.run = seat.Value("sources", func() any { return &sourceHost{} }).(*sourceHost)150 switch {151 case created && req.restored != "":152 m.disk, m.saved, m.changed, m.recovered = req.stamp, -1, true, req.restored153 v := m.savedState()154 v.State = -1155 seat.SetSaved(v)156 m.note = "Restored the kept changes; save to keep them"157 case created:158 m.disk = req.stamp159 seat.SetSaved(m.savedState())160 m.offerRecovery(req.name)161 default:162 m.syncSaved()163 }164 if peers := seat.Peers(); len(peers) > 0 {165 m.note = "Sharing " + m.displayName() + " with " + peerNames(peers)166 }167 return tea.Batch(m.syncFollowers(), m.notebookSync())168}169170// restoreShared opens the room again on kept changes, when nobody else171// is in it: others are working on the workbook as it is.172func (m *Model) restoreShared(seat *room.Seat, msg restoredMsg) {173 if peers := seat.Peers(); len(peers) > 0 {174 m.warn = peerNames(peers) + " have " + m.displayName() + " open: restore the kept changes once you're the only one here"175 return176 }177 m.share.want = &joinReq{key: seat.Key(), name: msg.name, book: msg.sheet.Book(), stamp: m.disk, restored: msg.file}178}179180// savedState is the room's file as this model last saved or opened it.181func (m *Model) savedState() room.Saved {182 key := ""183 if m.filename != "" {184 key, _ = m.roomKey(m.filename)185 }186 return room.Saved{State: m.book().StateID(), Outputs: m.book().OutputsChanged(), Name: m.filename, Key: key, Stamp: m.disk}187}188189// syncSaved takes the room's file as anyone last saved it: its name,190// and what unsaved changes count from.191func (m *Model) syncSaved() {192 v := m.share.seat.Saved()193 m.saved, m.nb.saved = v.State, v.Outputs194 if st, ok := v.Stamp.(stamp); ok {195 m.disk = st196 }197 if v.Name != m.filename {198 m.filename = v.Name199 }200 m.changed = m.book().StateID() != m.saved || m.book().OutputsChanged() != m.nb.saved201}202203// roomChanged catches up with the room: what was posted for the keeper,204// others' operations, a save, a sheet deleted from under the view.205func (m *Model) roomChanged() tea.Cmd {206 seat := m.share.seat207 if seat == nil {208 return nil209 }210 var cmds []tea.Cmd211 for _, msg := range seat.Take() {212 if rc, ok := msg.(cowork.RunCell); ok {213 cmds = append(cmds, m.agentRunCell(rc))214 continue215 }216 _, cmd := m.Update(msg)217 cmds = append(cmds, cmd)218 }219 m.othersChanged(seat.Marks(m.share.seen))220 m.share.seen = seat.Last()221 m.syncSaved()222 if !m.sheet.Live() || m.sheet.Hidden() {223 m.afterSheetsChange(nil, m.book().Active())224 }225 m.cur = clampAddr(m.cur)226 return tea.Batch(cmds...)227}228229// othersChanged says on the context line when someone else changed the230// cell being typed into, or the one the pointer is on.231func (m *Model) othersChanged(marks []room.Mark) {232 seat := m.share.seat233 for _, mk := range marks {234 if mk.Author == seat.ID() || mk.Sheet != m.sheet || !mk.Rect.Contains(m.cur) {235 continue236 }237 switch {238 case m.mode == modeEnter || m.mode == modeEdit:239 m.warn = mk.Name + " changed " + m.cur.String() + " while you type: Enter puts yours over theirs, Esc keeps theirs"240 case m.mode == modeReady:241 m.note = mk.Name + " changed " + m.cur.String()242 }243 }244}245246// presence is where the user is, for the others.247func (m *Model) presence() room.Presence {248 sel := sheet.Rect{From: m.cur, To: m.cur}249 if r, ok := m.highlight(); ok {250 sel = r251 }252 return room.Presence{Sheet: m.sheet, Cursor: m.active(), Selection: sel, Editing: m.mode == modeEnter || m.mode == modeEdit}253}254255// mayEdit reports whether the user may change the workbook: always,256// but in a one-writer room only the writer. When not, it says who257// writes.258func (m *Model) mayEdit() bool {259 seat := m.share.seat260 if seat == nil || seat.Writing() {261 return true262 }263 m.warn = seat.Writer() + " writes here and you follow: they can hand writing to you"264 return false265}266267// LeaveRoom gives up the session's seat as it ends, reporting whether268// it was the last one there (or had a workbook of its own), whose269// unsaved changes are then its to keep: others keep a room's workbook270// open. The last one out stops what the room ran.271func (m *Model) LeaveRoom() bool {272 seat := m.share.seat273 if seat == nil {274 m.closeStreams()275 return true276 }277 m.share.seat = nil278 if !seat.Leave() {279 return false280 }281 m.closeStreams()282 return true283}284285// Others is how many others share the session's workbook, and their286// names.287func (m *Model) Others() (int, string) {288 if m.share.seat == nil {289 return 0, ""290 }291 var n int292 var names string293 m.share.seat.Do(func(*sheet.Workbook) {294 peers := m.share.seat.Peers()295 n, names = len(peers), peerNames(peers)296 })297 return n, names298}299300// peerNames lists peers' names: "ann", "ann and bob", "ann, bob and cy".301func peerNames(peers []room.Peer) string {302 names := make([]string, len(peers))303 for i, p := range peers {304 names[i] = p.Name305 }306 switch len(names) {307 case 0:308 return ""309 case 1:310 return names[0]311 }312 return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]313}314315// peerLink is a served session as a room's participant: telling it the316// room changed kicks a goroutine that sends its program a roomMsg317// (Shared.Attach).318type peerLink struct {319 name string320 kick chan struct{}321}322323func (p *peerLink) Name() string { return p.name }324325func (p *peerLink) Notify() {326 select {327 case p.kick <- struct{}{}:328 default:329 }330}