cat / 012
clone

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

cat/module-path
012 / internal / ui / sources.go
sources.go 7.9K
1package ui23import (4 "context"5 "strings"6 "time"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/fileio"11 "src.rest/cat/012/internal/live"12 "src.rest/cat/012/internal/paged"13 "src.rest/cat/012/internal/sheet"14 "src.rest/cat/012/internal/ui/srcview"15)1617// Linked sources (Data > Linked file > Link a source): a Parquet file18// or a SQLite table or query read in place on a tab of its own. The19// workbook's paged.Host answers what formulas and pivot tables ask of20// the sources, its jobs run on goroutines of their own, a few at a21// time, and their answers come back as messages that recalculate what22// waited for them. The tab shown reads its rows a page at a time23// (paged.Pages) as it scrolls. Which sources the host reads is24// reconciled with the workbook after every update (syncSources), as25// linked files are (follow.go), and their files are looked at every26// live.Interval, a source read again once its file has changed.27//28// In a room of 012 serve the host is the room's, as its linked files29// and notebook runs are (share.go): whoever keeps the room links the30// sources, runs the jobs and looks at the files, and the jobs' answers31// reach whoever keeps it when they're done (roomOwned). Every32// participant reads the pages of the tab it shows itself.3334// sourceHost is a workbook's host and the jobs it has queued: the35// model's own, or the room's.36type sourceHost struct {37 host *paged.Host38 links string // the links given to the host, to notice a change39 failed map[string]string // why a source can't be linked, as told to the workbook40 queue []*paged.Job // the host's jobs waiting for a turn41 running int42}4344// sourceState is the linked sources' part of the model.45type sourceState struct {46 run *sourceHost47 pages map[string]*paged.Pages // the rows of each source's tab, by the source's name48 views map[*sheet.Sheet]*srcview.View // each source tab's view49 ticking bool50 dragBar bool // the scrollbar's thumb is being dragged51}5253// sourceInterval is how often sources' files are looked at; tests lower54// it.55var sourceInterval = live.Interval5657// sourceParallel is how many of the host's jobs run at once: each may58// read a whole source.59const sourceParallel = 36061type (62 sourceJobMsg struct {63 host *paged.Host64 j *paged.Job65 }66 sourcePageMsg struct {67 p *paged.Pages68 j *paged.PageJob69 }70 sourceTickMsg struct{ host *paged.Host }71)7273// sources is the workbook's host and jobs.74func (m *Model) sources() *sourceHost {75 if m.src.run == nil {76 m.src.run = &sourceHost{}77 }78 return m.src.run79}8081// keepsSources reports whether this model runs the host's jobs: its own82// workbook's, or a room's it keeps.83func (m *Model) keepsSources() bool {84 seat := m.share.seat85 return seat == nil || seat.Keeper()86}8788// closeSources lets the workbook's sources go, as another is opened:89// the host too, unless others share it.90func (m *Model) closeSources() {91 if r := m.src.run; r != nil && r.host != nil && !m.shared() {92 r.host.Close()93 }94 for _, p := range m.src.pages {95 p.Close()96 }97}9899// syncSources gives the host the workbook's sources, keeps the pages of100// the source shown, and runs what's queued.101func (m *Model) syncSources() tea.Cmd {102 w, r := m.book(), m.sources()103 infos := w.Sources()104 if len(infos) == 0 && r.host == nil {105 return nil106 }107 if r.host == nil {108 r.host = paged.NewHost(sheet.MaxCells())109 w.SetSources(r.host)110 }111 r.host.Budget = sheet.MaxCells() // the setting applies at once112 keeper := m.keepsSources()113 if keeper {114 m.linkSources(infos)115 }116 m.syncPages()117 if v := m.srcView(); v != nil {118 v.Prefetch()119 }120 cmds := []tea.Cmd{m.runPageJobs()}121 if keeper {122 cmds = append(cmds, m.runSourceJobs())123 if !m.src.ticking && len(infos) > 0 {124 m.src.ticking = true125 h := r.host126 cmds = append(cmds, tea.Tick(sourceInterval, func(time.Time) tea.Msg { return sourceTickMsg{h} }))127 }128 }129 return tea.Batch(cmds...)130}131132// linkSources gives the host the sources it may read, their files133// resolved, and tells the workbook why the others can't be.134func (m *Model) linkSources(infos []sheet.SourceInfo) {135 r := m.sources()136 var links []paged.Linked137 var key strings.Builder138 for _, info := range infos {139 src := info.Source140 path, why := m.sourcePath(src)141 if why != "" {142 if r.failed[info.Name] != why {143 if r.failed == nil {144 r.failed = map[string]string{}145 }146 r.failed[info.Name] = why147 m.book().SetSourceShape(info.Name, sheet.SourceShape{}, why)148 }149 continue150 }151 delete(r.failed, info.Name)152 spec := fileio.SourceSpec{Path: path, Format: src.Format, Table: src.Table, Query: src.Query}153 links = append(links, paged.Linked{Name: info.Name, Spec: spec})154 key.WriteString(info.Name + "\x00" + path + "\x00" + src.Format + "\x00" + src.Table + "\x00" + src.Query + "\x01")155 }156 if key.String() != r.links {157 r.links = key.String()158 r.host.Link(links)159 }160}161162// sourcePath is the file a source reads, or why it may not be read:163// outside the served folder, or outside the workbook's from a workbook164// made on another computer, until the user trusts it (follow.go).165func (m *Model) sourcePath(src sheet.LinkSource) (string, string) {166 if outside(src.Path) && !m.macroTrusted() {167 m.askLinkTrust()168 return "", "Not read: the spreadsheet was made on another computer"169 }170 path, err := m.root.Resolve(m.linkName(src.Path))171 if err != nil {172 return "", m.root.Scrub(err.Error())173 }174 return path, ""175}176177// runSourceJobs starts the host's jobs, a few at a time.178func (m *Model) runSourceJobs() tea.Cmd {179 r := m.sources()180 h := r.host181 r.queue = append(r.queue, h.Jobs()...)182 var cmds []tea.Cmd183 for r.running < sourceParallel && len(r.queue) > 0 {184 j := r.queue[0]185 r.queue = r.queue[1:]186 r.running++187 parent := m.spans.Parent()188 cmds = append(cmds, m.roomOwned(func() tea.Msg {189 span := parent.Start("source " + j.String())190 j.Run(context.Background())191 span.End()192 return sourceJobMsg{h, j}193 }))194 }195 return tea.Batch(cmds...)196}197198// runPageJobs reads the pages the tab shown asked for.199func (m *Model) runPageJobs() tea.Cmd {200 var cmds []tea.Cmd201 for _, p := range m.src.pages {202 for _, j := range p.Jobs() {203 cmds = append(cmds, func() tea.Msg {204 j.Run(context.Background())205 return sourcePageMsg{p, j}206 })207 }208 }209 return tea.Batch(cmds...)210}211212// handleSource takes what a job found, a page read, or a tick.213func (m *Model) handleSource(msg tea.Msg) tea.Cmd {214 r := m.sources()215 switch msg := msg.(type) {216 case sourceJobMsg:217 if msg.host != r.host {218 return nil // the workbook it was for is gone219 }220 r.running--221 paged.Apply(m.book(), r.host.Store(msg.j))222 case sourcePageMsg:223 if m.src.pages[pagesName(m.src.pages, msg.p)] == msg.p {224 msg.p.Store(msg.j)225 }226 case sourceTickMsg:227 m.src.ticking = false228 if msg.host == r.host && m.keepsSources() {229 r.host.Poll()230 }231 }232 return nil233}234235// pagesName is the name p is kept by, "" when it isn't kept.236func pagesName(pages map[string]*paged.Pages, p *paged.Pages) string {237 for name, q := range pages {238 if q == p {239 return name240 }241 }242 return ""243}244245// syncPages keeps the pages of the source tab shown in step with its246// source: made once it's open, made again when it's read again or its247// order changes; those of other tabs let go.248func (m *Model) syncPages() {249 info, ok := m.sheet.Source()250 for name, p := range m.src.pages {251 if !ok || !strings.EqualFold(name, info.Name) {252 p.Close()253 delete(m.src.pages, name)254 }255 }256 if !ok {257 return258 }259 h, gen, open := m.sources().host.Source(info.Name)260 p := m.src.pages[info.Name]261 switch {262 case !open:263 if p != nil {264 p.Close()265 delete(m.src.pages, info.Name)266 }267 case p == nil || !p.Same(h, gen, info.Source.Order):268 reordered := p != nil && p.Of(h, gen)269 if p != nil {270 p.Close()271 }272 if m.src.pages == nil {273 m.src.pages = map[string]*paged.Pages{}274 }275 m.src.pages[info.Name] = paged.NewPages(h, gen, info.Source.Order)276 if v := m.src.views[m.sheet]; v != nil {277 if reordered {278 v.Reset() // other rows in other places: back to the first279 }280 v.Refit()281 }282 }283}