cat / 012
clone

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

Following files: live sources, Data > Linked file, Import's Follow the file

Dami committed 9c5103d
internal/live polls a linked region's source: a File follows a text
table as it grows (partial records wait; truncation, rotation and
rewrites read it again) and any other format when it's rewritten,
debounced, with os.Stat alone. The UI keeps a follower per region,
reconciled with the workbook after every update, polls one poll at a
time on a command and applies each update as a LiveOp.

Data > Linked file links a table at the active cell, follows or
pauses, reads again, keeps the last rows, and unlinks to values;
File > Import's locations add Follow the file, in a new sheet. The
context line and the tab show the region's state; linked paths are
saved relative to the workbook, and a workbook from elsewhere asks
once before following files outside its folder.
commit
9c5103ddf0848b6ef75f9d86ddf5d5e2451c90c1
parent
ce682befb2d3a2f43ae732bb6073f40a89738b09
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
16 files changed, +1431 -11
Minternal/fileio/formats.go2+-
Ainternal/live/file.go242+++++++++++++++++++++++
Ainternal/live/file_test.go212++++++++++++++++++++
Ainternal/live/live.go54+++++
Minternal/ui/file.go8+-
Ainternal/ui/follow.go311++++++++++++++++++++++++++++++
Ainternal/ui/follow_test.go242+++++++++++++++++++++++
Minternal/ui/importplace.go4+
Minternal/ui/importplace_test.go4+-
Ainternal/ui/linked.go303+++++++++++++++++++++++++++++
Minternal/ui/menubar.go1+
Minternal/ui/model.go14+-
Minternal/ui/model_test.go3+
Minternal/ui/panel.go3+
Minternal/ui/pivot.go4+
Minternal/ui/tabstrip/tabstrip.go35+++-
M internal/fileio/formats.go +1 -1
A internal/live/file.go +242 -0
@@ -0,0 +1,242 @@
1+package live2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "io"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "strings"12+ "time"13+14+ "github.com/FelineStateMachine/012/internal/fileio"15+)16+17+// File follows a file with the standard library's stat alone (no18+// inotify or kqueue, which differ by OS and need cgo or a module), which19+// polling every Interval makes cheap enough: one stat a poll while20+// nothing changes.21+//22+// A text table (CSV, TSV, JSON lines, NUON; fileio.Kind.Grows) is read23+// as it grows: each poll reads what was appended, and a record cut off24+// at the end waits for the rest (fileio.Tail). The file is read again25+// whole when it shrinks (truncated), is another file (rotated: the path26+// names a new inode), or its bytes before where reading stopped differ27+// (rewritten in place). Any other format is read again whole when its28+// size or modification time changes, once they have held still for29+// Debounce, so a file being written isn't read half done.30+type File struct {31+ // Name is how the file is named to the user, in errors.32+ Name string33+ path string34+ kind fileio.Kind35+ opt fileio.Options36+ loaded bool37+ err string38+39+ // A growing file: what identifies it, how far it's read, and the40+ // bytes before that, which a rewrite changes.41+ info os.FileInfo42+ offset int6443+ mark []byte44+ tail *fileio.Tail45+46+ // A file read whole: its stamp when last read, and the one last47+ // seen with when it was first seen, for the debounce.48+ read, seen stamp49+ seenAt time.Time50+51+ // Debounce is how long a file read whole must hold still.52+ Debounce time.Duration53+ // MaxRead bounds what one poll reads of a growing file, so a large54+ // file comes in over several polls, each a short step for the UI.55+ MaxRead int6456+ // Now is the clock; tests replace it.57+ Now func() time.Time58+}59+60+// stamp is what tells a file changed: its size and modification time.61+type stamp struct {62+ size int6463+ mod time.Time64+}65+66+func stampOf(st os.FileInfo) stamp { return stamp{st.Size(), st.ModTime()} }67+68+// markLen is how many bytes before the read offset tell an append from69+// a rewrite.70+const markLen = 6471+72+// NewFile follows the file at path (on disk) in format k, with opt's73+// table, query and locale (fileio.Options); name is how the user named it.74+func NewFile(name, path string, k fileio.Kind, opt fileio.Options) *File {75+ return &File{Name: name, path: path, kind: k, opt: opt, Debounce: 300 * time.Millisecond, MaxRead: 1 << 20, Now: time.Now}76+}77+78+// Poll reads what changed (see File).79+func (f *File) Poll(ctx context.Context) (Update, bool) {80+ st, err := os.Stat(f.path)81+ if err != nil {82+ return f.fail(err)83+ }84+ var u Update85+ var ok bool86+ if f.kind.Grows() {87+ u, ok = f.pollGrowing(st)88+ } else {89+ u, ok = f.pollWhole(ctx, st)90+ }91+ u.At = f.Now()92+ if ok && u.Err == "" {93+ f.err = ""94+ }95+ return u, ok96+}97+98+// fail reports err once, until the file can be read again.99+func (f *File) fail(err error) (Update, bool) {100+ msg := err.Error()101+ switch {102+ case errors.Is(err, fs.ErrNotExist):103+ msg = "the file isn't there"104+ case errors.Is(err, fs.ErrPermission):105+ msg = "permission denied"106+ default:107+ // Errors name the path on disk; the user named the file.108+ msg = strings.ReplaceAll(msg, f.path, f.Name)109+ }110+ if msg == f.err {111+ return Update{}, false112+ }113+ f.err = msg114+ f.Reload() // it's read whole once it's back115+ return Update{Err: msg, At: f.Now()}, true116+}117+118+// Reload has the next poll read the file again, whole.119+func (f *File) Reload() {120+ f.loaded = false121+ f.read, f.seen = stamp{}, stamp{}122+ if f.tail != nil {123+ f.tail.Close()124+ f.tail = nil125+ }126+}127+128+// Close lets the file go.129+func (f *File) Close() { f.Reload() }130+131+// pollWhole reads a file that is rewritten, not appended to, once it132+// has held still.133+func (f *File) pollWhole(ctx context.Context, st os.FileInfo) (Update, bool) {134+ s := stampOf(st)135+ if f.loaded && s == f.read {136+ return Update{}, false137+ }138+ now := f.Now()139+ if s != f.seen {140+ f.seen, f.seenAt = s, now141+ if f.loaded {142+ return Update{}, false // changing: wait for it to settle143+ }144+ }145+ if f.loaded && now.Sub(f.seenAt) < f.Debounce {146+ return Update{}, false147+ }148+ res, err := fileio.Import(ctx, f.path, f.opt)149+ if err != nil {150+ return f.fail(err)151+ }152+ f.loaded, f.read = true, s153+ rows := fileio.Rows(res.Sheet)154+ return Update{Reset: true, Header: rows.Header, Rows: rows.Rows, Note: strings.Join(res.Notes, "; ")}, true155+}156+157+// pollGrowing reads what a growing file gained, or the whole file again158+// when it was truncated, rotated or rewritten.159+func (f *File) pollGrowing(st os.FileInfo) (Update, bool) {160+ reset := !f.loaded || !os.SameFile(f.info, st) || st.Size() < f.offset161+ if !reset && st.Size() == f.offset && st.ModTime().Equal(f.info.ModTime()) {162+ return Update{}, false163+ }164+ file, err := os.Open(f.path)165+ if err != nil {166+ return f.fail(err)167+ }168+ defer file.Close()169+ if !reset && !f.sameMark(file) {170+ reset = true171+ }172+ if reset {173+ if err := f.restart(file); err != nil {174+ return f.fail(err)175+ }176+ }177+ f.info = st178+ u := Update{Reset: reset}179+ end := min(st.Size(), f.offset+f.MaxRead)180+ if end > f.offset {181+ piece := make([]byte, end-f.offset)182+ n, err := file.ReadAt(piece, f.offset)183+ if err != nil && !errors.Is(err, io.EOF) {184+ return f.fail(err)185+ }186+ piece = piece[:n]187+ rows, err := f.tail.Feed(piece)188+ if err != nil {189+ f.Reload()190+ return Update{Err: err.Error(), At: f.Now()}, true191+ }192+ f.offset += int64(n)193+ f.remember(piece)194+ u.Header, u.Rows, u.More = rows.Header, rows.Rows, f.offset < st.Size()195+ }196+ return u, reset || u.Header != nil || len(u.Rows) > 0197+}198+199+// restart reads the file from its start: a new tail, sniffing its head.200+func (f *File) restart(file *os.File) error {201+ if f.tail != nil {202+ f.tail.Close()203+ }204+ head := make([]byte, 64<<10)205+ n, err := file.ReadAt(head, 0)206+ if err != nil && !errors.Is(err, io.EOF) {207+ return err208+ }209+ tail, err := fileio.NewTail(f.kind, head[:n], f.opt.Locale)210+ if err != nil {211+ return err212+ }213+ f.tail, f.offset, f.mark, f.loaded = tail, 0, nil, true214+ return nil215+}216+217+// remember keeps the last bytes read, which a rewrite changes.218+func (f *File) remember(piece []byte) {219+ f.mark = append(f.mark, piece[max(len(piece)-markLen, 0):]...)220+ f.mark = f.mark[max(len(f.mark)-markLen, 0):]221+}222+223+// sameMark reports whether the bytes before the offset are as read.224+func (f *File) sameMark(file *os.File) bool {225+ if len(f.mark) == 0 {226+ return true227+ }228+ b := make([]byte, len(f.mark))229+ n, _ := file.ReadAt(b, f.offset-int64(len(b)))230+ return bytes.Equal(b[:n], f.mark)231+}232+233+// KindOf is the format a linked file is read in: format, named as234+// fileio.Kind.String names it, or else the one its name tells.235+func KindOf(name, format string) (fileio.Kind, bool) {236+ for _, k := range fileio.Kinds() {237+ if strings.EqualFold(k.String(), format) {238+ return k, true239+ }240+ }241+ return fileio.KindOf(filepath.Base(name))242+}
A internal/live/file_test.go +212 -0
@@ -0,0 +1,212 @@
1+package live2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// rowsText writes an update's rows as a test reads them.16+func rowsText(rows []sheet.LiveRow) string {17+ var out []string18+ for _, r := range rows {19+ var cells []string20+ for _, c := range r {21+ cells = append(cells, c.V.String())22+ }23+ out = append(out, strings.Join(cells, ","))24+ }25+ return strings.Join(out, "|")26+}27+28+// follow starts following a file in a temporary directory, returning it29+// and a function writing it.30+func follow(t *testing.T, name string) (*File, string) {31+ t.Helper()32+ path := filepath.Join(t.TempDir(), name)33+ k, ok := KindOf(name, "")34+ if !ok {35+ t.Fatalf("no kind for %s", name)36+ }37+ f := NewFile(name, path, k, fileio.Options{})38+ t.Cleanup(f.Close)39+ return f, path40+}41+42+func write(t *testing.T, path, text string) {43+ t.Helper()44+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {45+ t.Fatal(err)46+ }47+}48+49+func appendTo(t *testing.T, path, text string) {50+ t.Helper()51+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)52+ if err != nil {53+ t.Fatal(err)54+ }55+ defer f.Close()56+ if _, err := f.WriteString(text); err != nil {57+ t.Fatal(err)58+ }59+}60+61+func poll(t *testing.T, f *File) (Update, bool) {62+ t.Helper()63+ return f.Poll(context.Background())64+}65+66+func TestFileAppendAndPartialLine(t *testing.T) {67+ f, path := follow(t, "log.csv")68+ u, ok := poll(t, f)69+ if !ok || u.Err != "the file isn't there" {70+ t.Fatalf("missing file: %+v", u)71+ }72+ if _, ok := poll(t, f); ok {73+ t.Fatal("the same error twice")74+ }75+ write(t, path, "when,n\n1,10\n2,2")76+ u, ok = poll(t, f)77+ if !ok || !u.Reset || u.Err != "" || rowsText([]sheet.LiveRow{u.Header}) != "when,n" || rowsText(u.Rows) != "1,10" {78+ t.Fatalf("first read: %+v", u)79+ }80+ if _, ok := poll(t, f); ok {81+ t.Fatal("nothing changed, yet an update")82+ }83+ appendTo(t, path, "0\n3,30\n")84+ u, ok = poll(t, f)85+ if !ok || u.Reset || rowsText(u.Rows) != "2,20|3,30" {86+ t.Fatalf("append: %+v", u)87+ }88+}89+90+func TestFileTruncateRotateRewrite(t *testing.T) {91+ f, path := follow(t, "log.tsv")92+ write(t, path, "a\n1\n2\n")93+ poll(t, f)94+ // Truncated: read again from the start.95+ write(t, path, "a\n9\n")96+ if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "9" {97+ t.Fatalf("truncated: %+v", u)98+ }99+ // Rewritten in place, longer: the bytes read before differ.100+ write(t, path, "b\n7\n8\n")101+ if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "7|8" || u.Header[0].V.Str != "b" {102+ t.Fatalf("rewritten: %+v", u)103+ }104+ // Rotated: renamed away, and a new file in its place.105+ os.Rename(path, path+".1")106+ write(t, path, "c\n5\n6\n7\n")107+ if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "5|6|7" {108+ t.Fatalf("rotated: %+v", u)109+ }110+ appendTo(t, path+".1", "gone\n") // the old file no longer counts111+ if u, ok := poll(t, f); ok {112+ t.Fatalf("the rotated file was read: %+v", u)113+ }114+}115+116+func TestFileMaxRead(t *testing.T) {117+ f, path := follow(t, "big.csv")118+ f.MaxRead = 8119+ write(t, path, "a\n1\n2\n3\n4\n5\n6\n")120+ var all []sheet.LiveRow121+ for i := 0; ; i++ {122+ u, _ := poll(t, f)123+ all = append(all, u.Rows...)124+ if !u.More {125+ break126+ }127+ if i > 10 {128+ t.Fatal("never done")129+ }130+ }131+ if rowsText(all) != "1|2|3|4|5|6" {132+ t.Fatalf("%q", rowsText(all))133+ }134+}135+136+func TestFileNDJSON(t *testing.T) {137+ f, path := follow(t, "events.ndjson")138+ if f.kind != fileio.JSON {139+ t.Skip("ndjson isn't JSON's extension here")140+ }141+ write(t, path, `{"a": 1}`+"\n"+`{"a": 2, "b"`)142+ u, _ := poll(t, f)143+ if rowsText(u.Rows) != "1" {144+ t.Fatalf("%+v", u)145+ }146+ appendTo(t, path, `: "x"}`+"\n")147+ u, _ = poll(t, f)148+ if rowsText(u.Rows) != "2,x" || rowsText([]sheet.LiveRow{u.Header}) != "a,b" {149+ t.Fatalf("%+v", u)150+ }151+}152+153+func TestFileRewrittenWholeDebounced(t *testing.T) {154+ f, path := follow(t, "book.xlsx")155+ now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)156+ f.Now = func() time.Time { return now }157+ save := func(v string) {158+ s := sheet.New()159+ s.Set(sheet.Addr{}, "n")160+ s.Set(sheet.Addr{Row: 1}, v)161+ if _, err := fileio.Export(context.Background(), path, fileio.XLSX, fileio.SnapBook(s), fileio.ExportOptions{}); err != nil {162+ t.Fatal(err)163+ }164+ }165+ save("1")166+ u, ok := poll(t, f)167+ if !ok || !u.Reset || rowsText(u.Rows) != "1" {168+ t.Fatalf("first read: %+v", u)169+ }170+ // Rewritten twice in quick succession: read once, after it settles.171+ save("22")172+ if _, ok := poll(t, f); ok {173+ t.Fatal("read while changing")174+ }175+ now = now.Add(100 * time.Millisecond)176+ save("333")177+ os.Chtimes(path, now, now)178+ if _, ok := poll(t, f); ok {179+ t.Fatal("read while still changing")180+ }181+ now = now.Add(100 * time.Millisecond)182+ if _, ok := poll(t, f); ok {183+ t.Fatal("read before the debounce")184+ }185+ now = now.Add(time.Second)186+ if u, ok := poll(t, f); !ok || rowsText(u.Rows) != "333" {187+ t.Fatalf("after settling: %+v %v", u, ok)188+ }189+ if _, ok := poll(t, f); ok {190+ t.Fatal("read again, unchanged")191+ }192+}193+194+func TestKindOf(t *testing.T) {195+ if k, ok := KindOf("x.txt", "tsv"); !ok || k != fileio.TSV {196+ t.Fatal("format by name")197+ }198+ if k, ok := KindOf("x.nuon", ""); !ok || k != fileio.NUON {199+ t.Fatal("format by extension")200+ }201+ if _, ok := KindOf("x.txt", ""); ok {202+ t.Fatal("an unknown format")203+ }204+}205+206+func TestUpdateOp(t *testing.T) {207+ at := time.Now()208+ op := Update{Reset: true, Err: "e", Note: "n", At: at}.Op(7)209+ if op.Link != 7 || !op.Reset || op.Err != "e" || op.Note != "n" || !op.At.Equal(at) {210+ t.Fatalf("%+v", op)211+ }212+}
A internal/live/live.go +54 -0
@@ -0,0 +1,54 @@
1+// Package live reads the sources of linked regions as they change: a2+// file followed as it grows or is rewritten (File), and anything else3+// that yields a table's rows over time behind the same Source.4+//5+// A Source is polled on its owner's schedule, one poll at a time, from6+// a goroutine of the owner's (a Bubble Tea command), and answers with an7+// Update: rows to add, or all of them again, which the owner turns into8+// a sheet.LiveOp for the region (Update.Op) and applies on the goroutine9+// that owns the workbook. Sources never touch the workbook.10+package live11+12+import (13+ "context"14+ "time"15+16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// A Source yields a table's rows as they change.20+type Source interface {21+ // Poll looks once for what changed since the last poll. It reports22+ // false when nothing did. Polls never overlap.23+ Poll(ctx context.Context) (Update, bool)24+ // Reload has the next poll read the table again, whole.25+ Reload()26+ // Close lets the source go; it is polled no more.27+ Close()28+}29+30+// Update is what a poll found.31+type Update struct {32+ // Reset has Rows replace every row; otherwise they follow the last.33+ Reset bool34+ // Header is the table's first row when it's new or changed.35+ Header sheet.LiveRow36+ Rows []sheet.LiveRow37+ // Err says why the source can't be read, "" once it can.38+ Err string39+ // Note says what was left out or changed in reading.40+ Note string41+ // More is set when more is waiting to be read at once, rather than42+ // after the next interval.43+ More bool44+ // At is when the poll found it.45+ At time.Time46+}47+48+// Op is u as the operation that applies it to the region id.49+func (u Update) Op(id int) sheet.LiveOp {50+ return sheet.LiveOp{Link: id, At: u.At, Reset: u.Reset, Header: u.Header, Rows: u.Rows, Err: u.Err, Note: u.Note}51+}52+53+// Interval is how often a followed source is polled.54+const Interval = 250 * time.Millisecond
M internal/ui/file.go +7 -1
A internal/ui/follow.go +311 -0
@@ -0,0 +1,311 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "path/filepath"8+ "strings"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/fileio"14+ "github.com/FelineStateMachine/012/internal/live"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// 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.26+27+// followState is the linked regions' part of the model.28+type followState struct {29+ by map[int]*follower // by region ID30+ // trusted is set once the user agreed to follow the files a31+ // workbook from elsewhere links outside its folder; asked once the32+ // question was put.33+ trusted, asked bool34+}35+36+// follower is a region's source and where its polling is.37+type follower struct {38+ id int39+ key string // the source it was made for, to notice a change40+ src live.Source41+ // busy is set while a poll runs, waiting while one is scheduled;42+ // gone once the region is gone, closing the source when its poll43+ // returns; reload asks the next poll to read the source whole.44+ busy, waiting, gone, reload bool45+}46+47+// followInterval is how often a region's source is polled; tests lower48+// it.49+var followInterval = live.Interval50+51+// followTickMsg says it's time to poll f; followMsg is what a poll found.52+type (53+ followTickMsg struct{ f *follower }54+ followMsg struct {55+ f *follower56+ u live.Update57+ ok bool58+ }59+)60+61+// sourceKey identifies what a region reads, and where from.62+func (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+}65+66+// 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.69+func (m *Model) syncFollowers() tea.Cmd {70+ regions := m.book().LinkedRegions()71+ if len(regions) == 0 && len(m.follow.by) == 0 {72+ return nil73+ }74+ if m.follow.by == nil {75+ m.follow.by = map[int]*follower{}76+ }77+ seen := map[int]bool{}78+ var cmds []tea.Cmd79+ for _, r := range regions {80+ seen[r.ID] = true81+ if cmd := m.syncFollower(r); cmd != nil {82+ cmds = append(cmds, cmd)83+ }84+ }85+ for id, f := range m.follow.by {86+ if !seen[id] {87+ m.dropFollower(f)88+ }89+ }90+ return tea.Batch(cmds...)91+}92+93+// syncFollower keeps r's follower in step with r.94+func (m *Model) syncFollower(r sheet.LinkedRegion) tea.Cmd {95+ f := m.follow.by[r.ID]96+ if f != nil && f.key != m.sourceKey(r.Source) {97+ m.dropFollower(f)98+ f = nil99+ }100+ if f == nil {101+ if !m.linkTrusted(r) {102+ m.askLinkTrust()103+ return nil104+ }105+ if f = m.newFollower(r); f == nil {106+ return nil107+ }108+ }109+ if r.Stale && !f.reload {110+ f.reload = true111+ if !f.busy {112+ f.src.Reload()113+ }114+ }115+ if f.busy || f.waiting || r.Paused {116+ return nil117+ }118+ f.waiting = true119+ if r.Stale {120+ return func() tea.Msg { return followTickMsg{f} }121+ }122+ return tea.Tick(followInterval, func(_ time.Time) tea.Msg { return followTickMsg{f} })123+}124+125+// newFollower makes r's source, or shows why it can't.126+func (m *Model) newFollower(r sheet.LinkedRegion) *follower {127+ name := m.linkName(r.Source.Path)128+ k, ok := live.KindOf(name, r.Source.Format)129+ if !ok {130+ m.linkFailed(r.ID, "012 doesn't read "+filepath.Ext(name)+" files")131+ return nil132+ }133+ path, err := m.root.Resolve(name)134+ if err != nil {135+ m.linkFailed(r.ID, m.root.Scrub(err.Error()))136+ return nil137+ }138+ src := live.NewFile(filepath.Base(name), path, k, fileio.Options{Table: r.Source.Table, Query: r.Source.Query, Locale: m.locale()})139+ f := &follower{id: r.ID, key: m.sourceKey(r.Source), src: src, reload: true}140+ m.follow.by[r.ID] = f141+ return f142+}143+144+// linkFailed shows err in the region id, which can't be followed.145+func (m *Model) linkFailed(id int, err string) {146+ if r, ok := m.book().LinkedRegion(id); ok && r.Err == err {147+ return148+ }149+ m.book().ApplyLive(sheet.LiveOp{Link: id, Err: err})150+}151+152+// dropFollower lets a follower go, now or when its poll returns.153+func (m *Model) dropFollower(f *follower) {154+ delete(m.follow.by, f.id)155+ f.gone = true156+ if !f.busy {157+ f.src.Close()158+ }159+}160+161+// pollFollower polls f's source on a goroutine of its own.162+func pollFollower(f *follower) tea.Cmd {163+ f.busy = true164+ return func() tea.Msg {165+ u, ok := f.src.Poll(context.Background())166+ return followMsg{f, u, ok}167+ }168+}169+170+// handleFollow handles a follower's tick and what its poll found.171+func (m *Model) handleFollow(msg tea.Msg) tea.Cmd {172+ switch msg := msg.(type) {173+ case followTickMsg:174+ f := msg.f175+ f.waiting = false176+ if m.follow.by[f.id] != f {177+ f.src.Close() // of a workbook no longer open178+ return nil179+ }180+ if r, ok := m.book().LinkedRegion(f.id); !ok || r.Paused {181+ return nil182+ }183+ return pollFollower(f)184+ case followMsg:185+ f := msg.f186+ f.busy = false187+ switch {188+ case f.gone || m.follow.by[f.id] != f:189+ f.src.Close()190+ return nil191+ case f.reload && !msg.u.Reset:192+ f.src.Reload() // asked while it polled: read it whole now193+ return pollFollower(f)194+ }195+ if msg.ok {196+ m.applyUpdate(f, msg.u)197+ }198+ if msg.u.More && !f.gone {199+ return pollFollower(f)200+ }201+ }202+ return nil203+}204+205+// applyUpdate applies what f's poll found to its region.206+func (m *Model) applyUpdate(f *follower, u live.Update) {207+ if u.Reset {208+ f.reload = false209+ }210+ _, err := m.book().ApplyLive(u.Op(f.id))211+ if errors.Is(err, sheet.ErrNoLinked) {212+ m.dropFollower(f)213+ }214+}215+216+// linkName is the name a region's path stands for, as the user would217+// type it (relative to the working folder, or the served one).218+func (m *Model) linkName(path string) string { return linkNameIn(path, m.filename) }219+220+// linkNameIn is the name path stands for in the workbook named workbook:221+// a relative path is relative to its folder.222+func linkNameIn(path, workbook string) string {223+ if filepath.IsAbs(path) {224+ return path225+ }226+ return filepath.Join(filepath.Dir(workbook), path)227+}228+229+// linkPath is what a region keeps for the file typed as name, in the230+// workbook named workbook: the path relative to the workbook's folder,231+// or absolute when the name is and the file isn't in that folder or232+// below.233+func linkPath(name, workbook string) string {234+ dir := filepath.Dir(workbook)235+ if !filepath.IsAbs(name) && !filepath.IsAbs(dir) {236+ if rel, err := filepath.Rel(dir, name); err == nil {237+ return rel238+ }239+ return name240+ }241+ absName, err1 := filepath.Abs(name)242+ absDir, err2 := filepath.Abs(dir)243+ if err1 != nil || err2 != nil {244+ return name245+ }246+ if rel, err := filepath.Rel(absDir, absName); err == nil && !outside(rel) {247+ return rel248+ }249+ return absName250+}251+252+// rebaseLinks keeps the regions' paths naming the same files when the253+// workbook named from is to be named to, perhaps in another folder.254+func (m *Model) rebaseLinks(from, to string) {255+ if filepath.Dir(from) == filepath.Dir(to) {256+ return257+ }258+ m.book().RebaseLinks(func(p string) string { return linkPath(linkNameIn(p, from), to) })259+}260+261+// outside reports whether a region's path leaves its workbook's folder.262+func outside(path string) bool {263+ return filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator))264+}265+266+// linkTrusted reports whether r may be followed without asking: its267+// file is in the workbook's folder or below, or the workbook's links268+// were made or trusted on this computer, or the user said so.269+func (m *Model) linkTrusted(r sheet.LinkedRegion) bool {270+ o := m.book().LinkOrigin()271+ return !outside(r.Source.Path) || m.follow.trusted || o != "" && o == m.macros.machine272+}273+274+// askLinkTrust asks once whether to follow the files a workbook from275+// elsewhere links outside its folder, when nothing else is being asked.276+func (m *Model) askLinkTrust() {277+ if m.follow.asked || m.mode != modeReady || m.overlay != nil {278+ return279+ }280+ m.follow.asked = true281+ var names []string282+ for _, r := range m.book().LinkedRegions() {283+ if !m.linkTrusted(r) {284+ names = append(names, r.Source.Path)285+ }286+ }287+ m.ask(question{288+ msg: "This spreadsheet, made on another computer, follows files outside its folder: " + strings.Join(names, ", ") + ".",289+ warn: true,290+ desc: "Following reads those files as they change; not following leaves their regions empty",291+ choices: []choice{292+ {key: "enter", label: "Follow them", run: func(m *Model) tea.Cmd { m.trustLinks(); return nil }},293+ {key: "esc", label: "Don't follow", run: func(m *Model) tea.Cmd {294+ for _, r := range m.book().LinkedRegions() {295+ if !m.linkTrusted(r) {296+ m.linkFailed(r.ID, "Not followed: the spreadsheet was made on another computer")297+ }298+ }299+ return nil300+ }},301+ },302+ })303+}304+305+// trustLinks trusts the workbook's links from now on, on this computer.306+func (m *Model) trustLinks() {307+ m.follow.trusted = true308+ if m.macros.machine != "" {309+ m.book().SetLinkOrigin(m.macros.machine)310+ }311+}
A internal/ui/follow_test.go +242 -0
@@ -0,0 +1,242 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// pump polls every region's source once, as the ticks would, and15+// applies what the polls found.16+func pump(t *testing.T, m *Model) {17+ t.Helper()18+ followInterval = 019+ run(m, m.syncFollowers())20+ for range 3 { // a stale region reads, then the tick after21+ for _, f := range m.follow.by {22+ if f.waiting {23+ send(m, followTickMsg{f})24+ }25+ }26+ }27+}28+29+func appendFile(t *testing.T, name, text string) {30+ t.Helper()31+ f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)32+ if err != nil {33+ t.Fatal(err)34+ }35+ defer f.Close()36+ f.WriteString(text)37+}38+39+// linkLog links log.csv at the active cell, keeping every row.40+func linkLog(t *testing.T, m *Model) {41+ t.Helper()42+ m.runCommand("data.link")43+ press(t, m, "log.csv", "<enter>")44+ if !strings.Contains(line(m, contextLine), "Follow log.csv") {45+ t.Fatalf("no question: %q", line(m, contextLine))46+ }47+ press(t, m, "<enter>")48+ pump(t, m)49+}50+51+func TestLinkFollowsAppends(t *testing.T) {52+ t.Chdir(t.TempDir())53+ writeFile(t, "log.csv", "level,n\ninfo,1\nwarn,2\n")54+ m := newModel()55+ press(t, m, "<right>", "<down>") // B256+ linkLog(t, m)57+ if got := m.sheet.Value(addr("C4")).Num; got != 2 || m.sheet.Value(addr("B2")).Str != "level" {58+ t.Fatalf("C4 %v, B2 %v; %q", got, m.sheet.Value(addr("B2")), m.errMsg)59+ }60+ press(t, m, "<left>", "<up>", "=SUM(C:C)", "<enter>")61+ m.changed = false62+ appendFile(t, "log.csv", "error,4\npartial,")63+ pump(t, m)64+ if m.sheet.Value(addr("A1")).Num != 7 || m.sheet.Filled(addr("B6")) {65+ t.Fatalf("after appending: SUM %v, B6 %v", m.sheet.Value(addr("A1")), m.sheet.Value(addr("B6")))66+ }67+ if m.changed {68+ t.Error("rows arriving marked the file changed")69+ }70+ // The tab and the context line say it's following.71+ m.cur = addr("B3")72+ if l := line(m, contextLine); !strings.Contains(l, "● Following log.csv 3 rows") {73+ t.Errorf("context line %q", l)74+ }75+ if st := status(m); !strings.Contains(st, "Sheet1 ●") {76+ t.Errorf("status %q", st)77+ }78+ // Typing into the region is refused.79+ press(t, m, "x", "<enter>")80+ if m.sheet.Value(addr("B3")).Str != "info" {81+ t.Errorf("typed over a linked cell: %v", m.sheet.Value(addr("B3")))82+ }83+ m.mode, m.errMsg = modeReady, ""84+ m.cur = addr("B3")85+ m.runCommand("clear")86+ if !strings.Contains(m.note, "linked file") || m.sheet.Value(addr("B3")).Str != "info" {87+ t.Errorf("clear: note %q, B3 %v", m.note, m.sheet.Value(addr("B3")))88+ }89+90+ // Paused, it reads nothing; resumed, it catches up.91+ m.runCommand("data.link_follow")92+ appendFile(t, "log.csv", "5\n")93+ pump(t, m)94+ if m.sheet.Filled(addr("B6")) || !strings.Contains(ansi.Strip(m.linkedLine()), "‖ Paused log.csv") {95+ t.Fatalf("paused, yet read: %q", m.linkedLine())96+ }97+ m.runCommand("data.link_follow")98+ pump(t, m)99+ if m.sheet.Value(addr("B6")).Str != "partial" || m.sheet.Value(addr("A1")).Num != 12 {100+ t.Fatalf("resumed: B6 %v, SUM %v", m.sheet.Value(addr("B6")), m.sheet.Value(addr("A1")))101+ }102+103+ // Keeping the last two rows.104+ m.runCommand("data.link_rows")105+ press(t, m, "l", "2", "<enter>")106+ pump(t, m)107+ if m.sheet.Value(addr("B3")).Str != "error" || m.sheet.Filled(addr("B5")) || m.sheet.Value(addr("B2")).Str != "level" {108+ t.Fatalf("window: B3 %v B5 %v", m.sheet.Value(addr("B3")), m.sheet.Value(addr("B5")))109+ }110+ if l := line(m, contextLine); !strings.Contains(l, "2 rows (the last 2)") {111+ t.Errorf("context line %q", l)112+ }113+114+ // Unlinking keeps the values as ordinary cells.115+ m.runCommand("data.unlink")116+ run(m, m.syncFollowers())117+ if m.sheet.HasLinked() || len(m.follow.by) != 0 || m.sheet.Cell(addr("B3")).Spilled() || m.sheet.Value(addr("B3")).Str != "error" {118+ t.Fatalf("unlinked: %d followers, B3 %+v", len(m.follow.by), m.sheet.Cell(addr("B3")))119+ }120+ // Undo brings the region back, reading the file again.121+ press(t, m, "<ctrl+z>")122+ pump(t, m)123+ if !m.sheet.HasLinked() || m.sheet.Value(addr("B3")).Str != "error" || !m.sheet.Cell(addr("B3")).Spilled() {124+ t.Fatalf("undo: B3 %+v", m.sheet.Cell(addr("B3")))125+ }126+}127+128+func TestFollowInNewSheetAndReopen(t *testing.T) {129+ t.Chdir(t.TempDir())130+ writeFile(t, "log.csv", "a\n1\n")131+ m := newModel()132+ m.SetMachine("here")133+ press(t, m, "x", "<enter>")134+ m.runCommand("file.import")135+ press(t, m, "log.csv", "<enter>", "follow", "<enter>", "<enter>")136+ pump(t, m)137+ if m.sheet.Name() != "log" || m.sheet.Value(addr("A2")).Num != 1 {138+ t.Fatalf("sheet %q, A2 %v (%q)", m.sheet.Name(), m.sheet.Value(addr("A2")), m.errMsg)139+ }140+ // Saved in a folder of its own, the path is kept relative to it.141+ os.Mkdir("books", 0o755)142+ press(t, m, "<ctrl+s>", filepath.Join("books", "b"), "<enter>")143+ data, _ := os.ReadFile(filepath.Join("books", "b.012"))144+ if !strings.Contains(string(data), `"path":"../log.csv"`) {145+ t.Fatalf("saved:\n%s", data)146+ }147+ if rs := m.book().LinkedRegions(); rs[0].Source.Path != filepath.Join("..", "log.csv") {148+ t.Fatalf("path after saving: %q", rs[0].Source.Path)149+ }150+ appendFile(t, "log.csv", "2\n")151+ pump(t, m)152+ if m.sheet.Value(addr("A3")).Num != 2 {153+ t.Fatalf("after saving elsewhere, A3 %v", m.sheet.Value(addr("A3")))154+ }155+156+ // Reopened, it follows again; a missing file shows in the region.157+ m2 := newModel()158+ m2.SetMachine("here")159+ press(t, m2, "<ctrl+o>", filepath.Join("books", "b"), "<enter>")160+ m2.showSheet(m2.book().Lookup("log"))161+ pump(t, m2)162+ if m2.sheet.Value(addr("A3")).Num != 2 {163+ t.Fatalf("reopened: A3 %v", m2.sheet.Value(addr("A3")))164+ }165+ os.Remove("log.csv")166+ pump(t, m2)167+ m2.cur = addr("A1")168+ if l := line(m2, contextLine); !strings.Contains(l, "! log.csv: the file isn't there") {169+ t.Errorf("context line %q", l)170+ }171+ if st := status(m2); !strings.Contains(st, "log !") {172+ t.Errorf("status %q", st)173+ }174+}175+176+func TestLinksFromElsewhereAskOnce(t *testing.T) {177+ dir := t.TempDir()178+ t.Chdir(dir)179+ outside := filepath.Join(t.TempDir(), "far.csv")180+ os.WriteFile(outside, []byte("a\n1\n"), 0o644)181+ book := `{"version": 2, "linkOrigin": "another", "cells": {}, "links": [{"at": "A1", "path": ` + jsonQuote(outside) + `}]}`182+ writeFile(t, "b.012", book)183+ for _, answer := range []string{"<esc>", "<enter>"} {184+ m := newModel()185+ m.SetMachine("this-one")186+ press(t, m, "<ctrl+o>", "b", "<enter>")187+ if !strings.Contains(line(m, contextLine), "made on another computer") {188+ t.Fatalf("no question: %q", line(m, contextLine))189+ }190+ press(t, m, answer)191+ pump(t, m)192+ followed := m.sheet.Value(addr("A2")).Num == 1193+ if followed != (answer == "<enter>") {194+ t.Errorf("%s: followed %v", answer, followed)195+ }196+ if answer == "<enter>" && m.book().LinkOrigin() != "this-one" {197+ t.Errorf("trusting didn't record this computer: %q", m.book().LinkOrigin())198+ }199+ }200+ // A file in the workbook's own folder needs no question.201+ writeFile(t, "near.csv", "a\n1\n")202+ writeFile(t, "c.012", `{"version": 2, "linkOrigin": "another", "cells": {}, "links": [{"at": "A1", "path": "near.csv"}]}`)203+ m := newModel()204+ m.SetMachine("this-one")205+ press(t, m, "<ctrl+o>", "c", "<enter>")206+ pump(t, m)207+ if m.sheet.Value(addr("A2")).Num != 1 || m.overlay != nil {208+ t.Errorf("a file beside the workbook: A2 %v, overlay %T", m.sheet.Value(addr("A2")), m.overlay)209+ }210+}211+212+func TestLinkPath(t *testing.T) {213+ for _, c := range []struct{ name, book, want string }{214+ {"log.csv", "", "log.csv"},215+ {"logs/a.csv", "books/b.012", "../logs/a.csv"},216+ {"books/a.csv", "books/b.012", "a.csv"},217+ } {218+ if got := linkPath(c.name, c.book); got != filepath.FromSlash(c.want) {219+ t.Errorf("linkPath(%q, %q) = %q, want %q", c.name, c.book, got, c.want)220+ }221+ }222+ abs := filepath.Join(t.TempDir(), "x.csv")223+ if got := linkPath(abs, "b.012"); got != abs {224+ t.Errorf("an absolute path outside: %q", got)225+ }226+ if got := linkNameIn("../a.csv", "books/b.012"); got != "a.csv" {227+ t.Errorf("linkNameIn: %q", got)228+ }229+}230+231+func TestFreeSheetName(t *testing.T) {232+ w := sheet.NewBook()233+ w.RenameSheet(w.Sheet(0), "log")234+ if got := freeSheetName(w, "log"); got != "log 2" {235+ t.Errorf("%q", got)236+ }237+ if got := freeSheetName(w, "a:b"); got != "a_b" {238+ t.Errorf("%q", got)239+ }240+}241+242+func jsonQuote(s string) string { return `"` + strings.ReplaceAll(s, `\`, `\\`) + `"` }
M internal/ui/importplace.go +4 -0
M internal/ui/importplace_test.go +2 -2
A internal/ui/linked.go +303 -0
@@ -0,0 +1,303 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "path/filepath"7+ "strconv"8+ "strings"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/fileio"14+ "github.com/FelineStateMachine/012/internal/sheet"15+ "github.com/FelineStateMachine/012/internal/ui/picker"16+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"17+ "github.com/FelineStateMachine/012/internal/ui/transfer"18+)19+20+// Linked files: Data > Linked file links a file at the active cell, and21+// File > Import can follow the file in a new sheet; either way the22+// file's table becomes a linked region, read-only, following the file23+// (follow.go). The same menu pauses and resumes following, reads the24+// file again, sets how many rows to keep, and unlinks, keeping the rows25+// as values.26+27+func init() {28+ register(&command{id: "data.link", macro: macroNever, title: "Link a table",29+ desc: "Show a file's table at the active cell, following the file as it grows or is rewritten",30+ run: (*Model).openLinkPicker})31+ register(&command{id: "data.link_follow", macro: macroNever, title: "Follow",32+ desc: "Follow the linked file under the pointer as it changes, or pause following it",33+ enabled: hasLinked, checked: func(m *Model) bool { r, ok := m.currentLinked(); return ok && !r.Paused },34+ run: func(m *Model) tea.Cmd {35+ r, _ := m.currentLinked()36+ m.book().PauseLinked(r.ID, !r.Paused)37+ m.note = "Following " + filepath.Base(r.Source.Path)38+ if !r.Paused {39+ m.note = "Paused following " + filepath.Base(r.Source.Path)40+ }41+ return nil42+ }})43+ register(&command{id: "data.link_reload", macro: macroNever, title: "Read again",44+ desc: "Read the linked file under the pointer again, whole", enabled: hasLinked,45+ run: func(m *Model) tea.Cmd {46+ r, _ := m.currentLinked()47+ m.book().ReloadLinked(r.ID)48+ return nil49+ }})50+ register(&command{id: "data.link_rows", macro: macroNever, title: "Rows to keep",51+ desc: "Keep every row of the linked file under the pointer, or only the last ones, as tail -f does", enabled: hasLinked,52+ run: func(m *Model) tea.Cmd {53+ r, _ := m.currentLinked()54+ m.askFollowRows(filepath.Base(r.Source.Path), func(m *Model, window int) tea.Cmd {55+ src := r.Source56+ src.Window = window57+ if err := m.book().SetLinkSource(r.ID, src); err != nil {58+ m.fail(err.Error())59+ }60+ return nil61+ })62+ return nil63+ }})64+ register(&command{id: "data.unlink", title: "Unlink",65+ desc: "Keep the linked file's rows as values and stop following it", enabled: hasLinked,66+ run: func(m *Model) tea.Cmd {67+ r, _ := m.currentLinked()68+ if err := m.book().Unlink(r.ID); err != nil {69+ m.fail(err.Error())70+ return nil71+ }72+ m.note = "Unlinked " + filepath.Base(r.Source.Path) + "; its rows are values now"73+ return nil74+ }})75+}76+77+// linkedItems are Data > Linked file's items.78+var linkedItems = []menuItem{79+ {cmd: "data.link"}, sep, {cmd: "data.link_follow"}, {cmd: "data.link_reload"}, {cmd: "data.link_rows"}, sep, {cmd: "data.unlink"},80+}81+82+func hasLinked(m *Model) bool { _, ok := m.currentLinked(); return ok }83+84+// currentLinked is the region the pointer is in, or the sheet's only85+// one.86+func (m *Model) currentLinked() (sheet.LinkedRegion, bool) {87+ if r, ok := m.sheet.LinkedAt(m.cur); ok {88+ return r, true89+ }90+ if rs := m.sheet.LinkedRegions(); len(rs) == 1 {91+ return rs[0], true92+ }93+ return sheet.LinkedRegion{}, false94+}95+96+// openLinkPicker asks for the file to link at the active cell: the97+// importable files here, or a path typed.98+func (m *Model) openLinkPicker() tea.Cmd {99+ at := m.cur100+ var items []picker.Item101+ names, _ := importable(m.root)102+ for _, name := range names {103+ k, _ := fileio.KindOf(name)104+ items = append(items, picker.Item{Title: name, Name: len(name), Detail: k.Label(), Desc: "Link " + name + " at " + at.String(),105+ Pick: func() tea.Cmd {106+ m.closeOverlay()107+ return m.linkFile(name, func(m *Model, src sheet.LinkSource) { m.linkAt(at, src) })108+ }})109+ }110+ p := m.newPicker("Link a table", "Type to filter, or a path", 72, items)111+ p.Action = "link"112+ p.Enter = func(query string) (tea.Cmd, bool) {113+ if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.Shown()) > 0 {114+ return nil, false115+ }116+ m.closeOverlay()117+ return m.linkFile(query, func(m *Model, src sheet.LinkSource) { m.linkAt(at, src) }), true118+ }119+ m.openOverlay(p)120+ return nil121+}122+123+// linkFile asks what to read of name (a table of a database) and how124+// many rows to keep, then has place make the region.125+func (m *Model) linkFile(name string, place func(*Model, sheet.LinkSource)) tea.Cmd {126+ k, ok := fileio.KindOf(name)127+ if !ok {128+ m.fail(fmt.Sprintf("Can't link %s: 012 reads %s", name, importExts()))129+ return nil130+ }131+ src := sheet.LinkSource{Path: linkPath(name, m.filename)}132+ rows := func(src sheet.LinkSource) {133+ m.askFollowRows(filepath.Base(name), func(m *Model, window int) tea.Cmd {134+ src.Window = window135+ place(m, src)136+ return nil137+ })138+ }139+ if !k.HasTables() {140+ rows(src)141+ return nil142+ }143+ path, ok := m.path("link", name)144+ if !ok {145+ return nil146+ }147+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)148+ defer cancel()149+ tables, err := fileio.Tables(ctx, path)150+ switch {151+ case err != nil:152+ m.fail(fmt.Sprintf("Couldn't read %s: %v", filepath.Base(name), m.root.Scrub(err.Error())))153+ case len(tables) == 1:154+ src.Table = tables[0].Name155+ rows(src)156+ default:157+ var items []picker.Item158+ for _, t := range tables {159+ items = append(items, picker.Item{Title: t.Name, Name: len(t.Name), Detail: transfer.Rows(t.Rows), Desc: "Link " + t.Name,160+ Pick: func() tea.Cmd {161+ m.closeOverlay()162+ src.Table = t.Name163+ rows(src)164+ return nil165+ }})166+ }167+ m.openOverlay(m.newPicker("Link from "+filepath.Base(name), "Type to filter tables", 60, items))168+ }169+ return nil170+}171+172+// askFollowRows asks how many rows a linked file keeps: all of them, up173+// to max-cells, or the last so many, then calls done with the window (0174+// for all).175+func (m *Model) askFollowRows(what string, done func(*Model, int) tea.Cmd) {176+ m.ask(question{177+ msg: "Follow " + what + ":",178+ desc: "Keep every row up to max-cells, or only the newest, older ones dropping as new ones arrive",179+ choices: []choice{180+ {key: "enter", label: "Keep every row", run: func(m *Model) tea.Cmd { return done(m, 0) }},181+ {key: "l", label: "Keep the last rows", run: func(m *Model) tea.Cmd {182+ m.openText("Keep the last how many rows:", "1000", func(m *Model, text string) tea.Cmd {183+ n, err := strconv.Atoi(strings.ReplaceAll(strings.TrimSpace(text), ",", ""))184+ if err != nil || n < 1 {185+ m.fail("Type a number of rows, 1 or more")186+ return nil187+ }188+ return done(m, n)189+ })190+ return nil191+ }},192+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},193+ },194+ })195+}196+197+// linkAt makes a region reading src at a on the sheet shown.198+func (m *Model) linkAt(a sheet.Addr, src sheet.LinkSource) {199+ before := len(m.book().LinkedRegions())200+ if _, err := m.sheet.AddLinked(a, src); err != nil {201+ m.fail(err.Error())202+ return203+ }204+ m.linkedHere(before)205+ m.note = "Following " + filepath.Base(src.Path) + " at " + a.String()206+}207+208+// followInNewSheet makes a sheet after the one shown with a region at209+// A1 reading src, as File > Import's Follow the file does.210+func (m *Model) followInNewSheet(src sheet.LinkSource) {211+ before := len(m.book().LinkedRegions())212+ base := strings.TrimSuffix(filepath.Base(src.Path), filepath.Ext(src.Path))213+ if src.Table != "" {214+ base = src.Table215+ }216+ name := freeSheetName(m.book(), base)217+ var s *sheet.Sheet218+ err := m.book().Batch(sheet.Change{Label: "follow " + filepath.Base(src.Path), Sheet: m.sheet, Tabs: true}, func() error {219+ var err error220+ if s, err = m.book().AddSheet(name, m.book().Index(m.sheet)+1); err != nil {221+ return err222+ }223+ _, err = s.AddLinked(sheet.Addr{}, src)224+ return err225+ })226+ if err != nil {227+ m.fail(err.Error())228+ return229+ }230+ m.afterSheetsChange(s, m.book().Index(s))231+ m.linkedHere(before)232+ m.note = "Following " + filepath.Base(src.Path) + " in " + s.Name()233+}234+235+// linkedHere trusts the workbook's links on this computer once the user236+// links a file, unless others came from elsewhere untrusted.237+func (m *Model) linkedHere(before int) {238+ if before == 0 || m.follow.trusted || m.book().LinkOrigin() == m.macros.machine {239+ m.trustLinks()240+ }241+}242+243+// freeSheetName is base made a valid sheet name no sheet has: "Log",244+// "Log 2".245+func freeSheetName(w *sheet.Workbook, base string) string {246+ base = strings.Map(func(r rune) rune {247+ if strings.ContainsRune(`:\/?*[]'`, r) {248+ return '_'249+ }250+ return r251+ }, strings.TrimSpace(base))252+ if r := []rune(base); len(r) > 28 {253+ base = string(r[:28])254+ }255+ if sheet.ValidSheetName(base) != nil {256+ base = "Linked"257+ }258+ name := base259+ for n := 2; w.Lookup(name) != nil; n++ {260+ name = base + " " + strconv.Itoa(n)261+ }262+ return name263+}264+265+// linkedLine says what the linked region under the pointer is doing:266+// "● Following log.csv 1,204 rows, updated 12:03:04".267+func (m *Model) linkedLine() string {268+ r, ok := m.sheet.LinkedAt(m.cur)269+ if !ok {270+ return ""271+ }272+ name := filepath.Base(r.Source.Path)273+ if r.Err != "" {274+ return m.th.Warning.Render("! "+name+": "+r.Err) + m.th.Muted.Render(linkedCounts(r))275+ }276+ state := liveMark(r) + " Following "277+ if r.Paused {278+ state = liveMark(r) + " Paused "279+ }280+ return m.th.Muted.Render(state) + m.th.Key.Render(name) + m.th.Muted.Render(linkedCounts(r))281+}282+283+// linkedCounts is a region's rows and last update, for linkedLine.284+func linkedCounts(r sheet.LinkedRegion) string {285+ out := " " + transfer.Rows(r.Rows)286+ if r.Source.Window > 0 {287+ out += " (the last " + transfer.Thousands(r.Source.Window) + ")"288+ }289+ if r.Dropped > 0 {290+ out += ", " + transfer.Thousands(r.Dropped) + " older dropped"291+ }292+ if !r.Updated.IsZero() {293+ out += ", updated " + r.Updated.Local().Format(time.TimeOnly)294+ }295+ if r.Note != "" {296+ out += "; " + r.Note297+ }298+ return out299+}300+301+// liveMark is the glyph a linked region's state shows as, beside its302+// tab's name and on the context line: tabstrip.Mark.303+func liveMark(r sheet.LinkedRegion) string { return tabstrip.Mark(r.Paused, r.Err != "") }
M internal/ui/menubar.go +1 -0
M internal/ui/model.go +9 -5
@@ -127,9 +127,10 @@ type Model struct {
127127 prefs prefs // the settings in effect and the theme chosen: prefs.go128128 session session // what outlasts the file open: the : history, the keyboard: keyboard.go129129 130- vim vimState // a vim key sequence in progress: vim.go131- rec *recorder // a macro being recorded: macrorec.go132- macros macroState // a macro running, and trust in the file's macros: macrorun.go130+ vim vimState // a vim key sequence in progress: vim.go131+ rec *recorder // a macro being recorded: macrorec.go132+ macros macroState // a macro running, and trust in the file's macros: macrorun.go133+ follow followState // the linked regions followed, and trust in them: follow.go133134 134135 keyAt time.Time // when the key the next frame answers was pressed, for telemetry135136 spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go
@@ -156,7 +157,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
156157 // so the theme can adapt to light terminals.157158 func (m *Model) Init() tea.Cmd {158159 // jev.send starts any questions queued while loading the file.159- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd())160+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.syncFollowers())160161 }161162 162163 // Update implements tea.Model.
@@ -223,6 +224,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223224 cmd = m.recalcAnswered()224225 case transfer.ImportedMsg, transfer.TickMsg, exportedMsg:225226 cmd = m.handleTransfer(msg)227+ case followTickMsg, followMsg:228+ cmd = m.handleFollow(msg)226229 case macroCallMsg:227230 cmd = m.serveMacro(msg)228231 case macroDoneMsg:
@@ -252,7 +255,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
252255 m.clampView()253256 // Any edit may have queued JEV questions.254257 // Chart images follow any change, see graphics.go.255- 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))258+ // Linked regions may have come, gone or changed: follow.go.259+ 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())256260 }257261 258262 // beginUpdate prepares for an input event and returns the sheet's state
M internal/ui/model_test.go +3 -0
M internal/ui/panel.go +3 -0
M internal/ui/pivot.go +4 -0
M internal/ui/tabstrip/tabstrip.go +33 -2