cat / 012
clone

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

main
012 / internal / ui / follow.go
follow.go 9.0K
1package ui23import (4 "context"5 "errors"6 "fmt"7 "path/filepath"8 "strings"9 "time"1011 tea "charm.land/bubbletea/v2"1213 "src.rest/cat/012/internal/fileio"14 "src.rest/cat/012/internal/live"15 "src.rest/cat/012/internal/sheet"16)1718// Following linked regions (Data > Linked file, File > Import's Follow19// the file): each region of the workbook gets a live.Source, polled one20// poll at a time on a command's goroutine every live.Interval (at once21// while it has more to read), and each update comes back as a message22// and is applied to the workbook as a sheet.LiveOp, the change stream,23// outside the undo history. What follows which region is reconciled24// with the workbook after every update (syncFollowers), so undo, redo,25// opening a file, pausing and unlinking need nothing of their own.2627// followState is the linked regions' part of the model.28type followState struct {29 by map[string]*follower // by region name30 // asked is set once the user was asked whether to follow the files31 // a workbook from elsewhere links outside its folder.32 asked bool33}3435// follower is a region's source and where its polling is.36type follower struct {37 name string38 key string // the source it was made for, to notice a change39 src live.Source40 // busy is set while a poll runs, waiting while one is scheduled;41 // gone once the region is gone, closing the source when its poll42 // returns. reload is set from asking the source to read whole until43 // it has, and pending when that was asked while a poll ran.44 busy, waiting, gone, reload, pending bool45}4647// followInterval is how often a region's source is polled; tests lower48// it.49var followInterval = live.Interval5051// followTickMsg says it's time to poll f; followMsg is what a poll found.52type (53 followTickMsg struct{ f *follower }54 followMsg struct {55 f *follower56 u live.Update57 ok bool58 }59)6061// sourceKey identifies what a region reads, and where from.62func (m *Model) sourceKey(src sheet.LinkSource) string {63 return fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", src.Path, src.Format, src.Table, src.Query, src.Window, m.filename)64}6566// syncFollowers makes the followers match the workbook's regions:67// sources for new ones, none for those gone, a whole read for stale68// ones, and the next poll for each that follows.69func (m *Model) syncFollowers() tea.Cmd {70 if seat := m.share.seat; seat != nil && !seat.Keeper() {71 for _, f := range m.follow.by {72 m.dropFollower(f) // the room's keeper follows them73 }74 return nil75 }76 regions := m.book().LinkedRegions()77 if len(regions) == 0 && len(m.follow.by) == 0 {78 return nil79 }80 if m.follow.by == nil {81 m.follow.by = map[string]*follower{}82 }83 seen := map[string]bool{}84 var cmds []tea.Cmd85 for _, r := range regions {86 seen[r.Name] = true87 if cmd := m.syncFollower(r); cmd != nil {88 cmds = append(cmds, cmd)89 }90 }91 for name, f := range m.follow.by {92 if !seen[name] {93 m.dropFollower(f)94 }95 }96 return tea.Batch(cmds...)97}9899// syncFollower keeps r's follower in step with r.100func (m *Model) syncFollower(r sheet.LinkedRegion) tea.Cmd {101 f := m.follow.by[r.Name]102 if f != nil && f.key != m.sourceKey(r.Source) {103 m.dropFollower(f)104 f = nil105 }106 if f == nil {107 if !m.linkTrusted(r) {108 m.askLinkTrust()109 return nil110 }111 if f = m.newFollower(r); f == nil {112 return nil113 }114 }115 if r.Stale && !f.reload {116 f.reload = true117 if f.busy {118 f.pending = true119 } else {120 f.src.Reload()121 }122 }123 if f.busy || f.waiting || r.Paused {124 return nil125 }126 f.waiting = true127 if r.Stale {128 return func() tea.Msg { return followTickMsg{f} }129 }130 return tea.Tick(followInterval, func(_ time.Time) tea.Msg { return followTickMsg{f} })131}132133// newFollower makes r's source, or shows why it can't.134func (m *Model) newFollower(r sheet.LinkedRegion) *follower {135 name := m.linkName(r.Source.Path)136 k, ok := live.KindOf(name, r.Source.Format)137 if !ok {138 m.linkFailed(r.Name, "012 doesn't read "+filepath.Ext(name)+" files")139 return nil140 }141 path, err := m.root.Resolve(name)142 if err != nil {143 m.linkFailed(r.Name, m.root.Scrub(err.Error()))144 return nil145 }146 src := live.NewFile(filepath.Base(name), path, k, fileio.Options{Table: r.Source.Table, Query: r.Source.Query, Locale: m.locale()})147 f := &follower{name: r.Name, key: m.sourceKey(r.Source), src: src, reload: true}148 m.follow.by[r.Name] = f149 return f150}151152// linkFailed shows err in the region id, which can't be followed.153func (m *Model) linkFailed(name, err string) {154 if r, ok := m.book().LinkedRegion(name); ok && r.Err == err {155 return156 }157 m.book().ApplyLive(sheet.LiveOp{Region: name, Err: err})158}159160// dropFollower lets a follower go, now or when its poll returns.161func (m *Model) dropFollower(f *follower) {162 delete(m.follow.by, f.name)163 f.gone = true164 if !f.busy {165 f.src.Close()166 }167}168169// pollFollower polls f's source on a goroutine of its own.170func pollFollower(f *follower) tea.Cmd {171 f.busy = true // what it finds comes back to this model: see syncFollowers172 return func() tea.Msg {173 u, ok := f.src.Poll(context.Background())174 return followMsg{f, u, ok}175 }176}177178// handleFollow handles a follower's tick and what its poll found.179func (m *Model) handleFollow(msg tea.Msg) tea.Cmd {180 switch msg := msg.(type) {181 case followTickMsg:182 f := msg.f183 f.waiting = false184 if m.follow.by[f.name] != f {185 f.src.Close() // of a workbook no longer open186 return nil187 }188 if r, ok := m.book().LinkedRegion(f.name); !ok || r.Paused {189 return nil190 }191 return pollFollower(f)192 case followMsg:193 f := msg.f194 f.busy = false195 switch {196 case f.gone || m.follow.by[f.name] != f:197 f.src.Close()198 return nil199 case f.pending:200 f.pending = false201 f.src.Reload() // asked while it polled: read it whole now202 return pollFollower(f)203 }204 if msg.ok {205 m.applyUpdate(f, msg.u)206 }207 if msg.u.More && !f.gone {208 return pollFollower(f)209 }210 }211 return nil212}213214// applyUpdate applies what f's poll found to its region.215func (m *Model) applyUpdate(f *follower, u live.Update) {216 if u.Reset {217 f.reload = false218 }219 if err := m.book().ApplyLive(u.Op(f.name)); errors.Is(err, sheet.ErrNoRegion) {220 m.dropFollower(f)221 }222}223224// linkName is the name a region's path stands for, as the user would225// type it (relative to the working folder, or the served one).226func (m *Model) linkName(path string) string { return linkNameIn(path, m.filename) }227228// linkNameIn is the name path stands for in the workbook named workbook:229// a relative path is relative to its folder.230func linkNameIn(path, workbook string) string {231 if filepath.IsAbs(path) {232 return path233 }234 return filepath.Join(filepath.Dir(workbook), path)235}236237// linkPath is what a region keeps for the file typed as name, in the238// workbook named workbook: the path relative to the workbook's folder,239// or absolute when the name is and the file isn't in that folder or240// below.241func linkPath(name, workbook string) string {242 dir := filepath.Dir(workbook)243 if !filepath.IsAbs(name) && !filepath.IsAbs(dir) {244 if rel, err := filepath.Rel(dir, name); err == nil {245 return rel246 }247 return name248 }249 absName, err1 := filepath.Abs(name)250 absDir, err2 := filepath.Abs(dir)251 if err1 != nil || err2 != nil {252 return name253 }254 if rel, err := filepath.Rel(absDir, absName); err == nil && !outside(rel) {255 return rel256 }257 return absName258}259260// rebaseLinks keeps the regions' paths naming the same files when the261// workbook named from is to be named to, perhaps in another folder.262func (m *Model) rebaseLinks(from, to string) {263 if filepath.Dir(from) == filepath.Dir(to) {264 return265 }266 m.book().RebaseLinks(func(p string) string { return linkPath(linkNameIn(p, from), to) })267}268269// outside reports whether a region's path leaves its workbook's folder.270func outside(path string) bool {271 return filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator))272}273274// linkTrusted reports whether r may be followed without asking: its275// file is in the workbook's folder or below, or the workbook's macros276// and regions were made or trusted on this computer (macroTrusted: one277// trust for all a file can make 012 do), or the user said so.278func (m *Model) linkTrusted(r sheet.LinkedRegion) bool {279 return !outside(r.Source.Path) || m.macroTrusted()280}281282// askLinkTrust asks once whether to follow the files a workbook from283// elsewhere links outside its folder, when nothing else is being asked.284func (m *Model) askLinkTrust() {285 if m.follow.asked || m.mode != modeReady || m.overlay != nil {286 return287 }288 m.follow.asked = true289 var names []string290 for _, r := range m.book().LinkedRegions() {291 if !m.linkTrusted(r) {292 names = append(names, r.Source.Path)293 }294 }295 m.ask(question{296 msg: "This spreadsheet, made on another computer, follows files outside its folder: " + strings.Join(names, ", ") + ".",297 warn: true,298 desc: "Following reads those files as they change; not following leaves their regions empty",299 choices: []choice{300 {key: "enter", label: "Follow them", run: func(m *Model) tea.Cmd { m.trustHere(); return nil }},301 {key: "esc", label: "Don't follow", run: func(m *Model) tea.Cmd {302 for _, r := range m.book().LinkedRegions() {303 if !m.linkTrusted(r) {304 m.linkFailed(r.Name, "Not followed: the spreadsheet was made on another computer")305 }306 }307 return nil308 }},309 },310 })311}