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 / linked.go
linked.go 10.2K
1package ui23import (4 "context"5 "fmt"6 "path/filepath"7 "strconv"8 "strings"9 "time"1011 tea "charm.land/bubbletea/v2"1213 "src.rest/cat/012/internal/fileio"14 "src.rest/cat/012/internal/sheet"15 "src.rest/cat/012/internal/ui/picker"16 "src.rest/cat/012/internal/ui/tabstrip"17 "src.rest/cat/012/internal/ui/transfer"18)1920// 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.2627func 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.Name, !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.Name)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.Name, 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.Name); 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}7677// linkedItems are Data > Linked file's items.78var linkedItems = []menuItem{79 {cmd: "data.link"}, {cmd: "data.link_source"}, sep, {cmd: "data.link_follow"}, {cmd: "data.link_reload"}, {cmd: "data.link_rows"}, sep, {cmd: "data.unlink"},80 sep, {cmd: "data.source_all"}, {cmd: "data.source_reload"},81}8283func hasLinked(m *Model) bool { _, ok := m.currentLinked(); return ok }8485// currentLinked is the region the pointer is in, or the sheet's only86// one.87func (m *Model) currentLinked() (sheet.LinkedRegion, bool) {88 if r, ok := m.sheet.LinkedAt(m.cur); ok {89 return r, true90 }91 if rs := m.sheet.LinkedRegions(); len(rs) == 1 {92 return rs[0], true93 }94 return sheet.LinkedRegion{}, false95}9697// openLinkPicker asks for the file to link at the active cell: the98// importable files here, or a path typed.99func (m *Model) openLinkPicker() tea.Cmd {100 at := m.cur101 var items []picker.Item102 names, _ := importable(m.root)103 for _, name := range names {104 k, _ := fileio.KindOf(name)105 items = append(items, picker.Item{Title: name, Name: len(name), Detail: k.Label(), Desc: "Link " + name + " at " + at.String(),106 Pick: func() tea.Cmd {107 m.closeOverlay()108 return m.linkFile(name, func(m *Model, src sheet.LinkSource) { m.linkAt(at, src) })109 }})110 }111 p := m.newPicker("Link a table", "Type to filter, or a path", 72, items)112 p.Action = "link"113 p.Enter = func(query string) (tea.Cmd, bool) {114 if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.Shown()) > 0 {115 return nil, false116 }117 m.closeOverlay()118 return m.linkFile(query, func(m *Model, src sheet.LinkSource) { m.linkAt(at, src) }), true119 }120 m.openOverlay(p)121 return nil122}123124// linkFile asks what to read of name (a table of a database) and how125// many rows to keep, then has place make the region.126func (m *Model) linkFile(name string, place func(*Model, sheet.LinkSource)) tea.Cmd {127 k, ok := fileio.KindOf(name)128 if !ok {129 m.fail(fmt.Sprintf("Can't link %s: 012 reads %s", name, importExts()))130 return nil131 }132 src := sheet.LinkSource{Path: linkPath(name, m.filename)}133 rows := func(src sheet.LinkSource) {134 m.askFollowRows(filepath.Base(name), func(m *Model, window int) tea.Cmd {135 src.Window = window136 place(m, src)137 return nil138 })139 }140 if !k.HasTables() {141 rows(src)142 return nil143 }144 m.chooseTable(name, src, nil, rows)145 return nil146}147148// chooseTable asks which table of the database name to link, then has149// done link it: the only one at once, else a picker of them, with more150// items (a query) after them.151func (m *Model) chooseTable(name string, src sheet.LinkSource, more []picker.Item, done func(sheet.LinkSource)) {152 path, ok := m.path("link", name)153 if !ok {154 return155 }156 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)157 defer cancel()158 tables, err := fileio.Tables(ctx, path)159 switch {160 case err != nil:161 m.fail(fmt.Sprintf("Couldn't read %s: %v", filepath.Base(name), m.root.Scrub(err.Error())))162 case len(tables) == 1 && len(more) == 0:163 src.Table = tables[0].Name164 done(src)165 default:166 var items []picker.Item167 for _, t := range tables {168 items = append(items, picker.Item{Title: t.Name, Name: len(t.Name), Detail: transfer.Rows(t.Rows), Desc: "Link " + t.Name,169 Pick: func() tea.Cmd {170 m.closeOverlay()171 src.Table = t.Name172 done(src)173 return nil174 }})175 }176 m.openOverlay(m.newPicker("Link from "+filepath.Base(name), "Type to filter tables", 60, append(items, more...)))177 }178}179180// askFollowRows asks how many rows a linked file keeps: all of them, up181// to max-cells, or the last so many, then calls done with the window (0182// for all).183func (m *Model) askFollowRows(what string, done func(*Model, int) tea.Cmd) {184 m.ask(question{185 msg: "Follow " + what + ":",186 desc: "Keep every row up to max-cells, or only the newest, older ones dropping as new ones arrive",187 choices: []choice{188 {key: "enter", label: "Keep every row", run: func(m *Model) tea.Cmd { return done(m, 0) }},189 {key: "l", label: "Keep the last rows", run: func(m *Model) tea.Cmd {190 m.openText("Keep the last how many rows:", "1000", func(m *Model, text string) tea.Cmd {191 n, err := strconv.Atoi(strings.ReplaceAll(strings.TrimSpace(text), ",", ""))192 if err != nil || n < 1 {193 m.fail("Type a number of rows, 1 or more")194 return nil195 }196 return done(m, n)197 })198 return nil199 }},200 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},201 },202 })203}204205// linkAt makes a region reading src at a on the sheet shown.206func (m *Model) linkAt(a sheet.Addr, src sheet.LinkSource) {207 before := len(m.book().LinkedRegions())208 if _, err := m.sheet.AddLinked(a, src); err != nil {209 m.fail(err.Error())210 return211 }212 m.linkedHere(before)213 m.note = "Following " + filepath.Base(src.Path) + " at " + a.String()214}215216// followInNewSheet makes a sheet after the one shown with a region at217// A1 reading src, as File > Import's Follow the file does.218func (m *Model) followInNewSheet(src sheet.LinkSource) {219 before := len(m.book().LinkedRegions())220 base := strings.TrimSuffix(filepath.Base(src.Path), filepath.Ext(src.Path))221 if src.Table != "" {222 base = src.Table223 }224 name := freeSheetName(m.book(), base)225 var s *sheet.Sheet226 err := m.book().Batch(sheet.Change{Label: "follow " + filepath.Base(src.Path), Sheet: m.sheet, Tabs: true}, func() error {227 var err error228 if s, err = m.book().AddSheet(name, m.book().Index(m.sheet)+1); err != nil {229 return err230 }231 _, err = s.AddLinked(sheet.Addr{}, src)232 return err233 })234 if err != nil {235 m.fail(err.Error())236 return237 }238 m.afterSheetsChange(s, m.book().Index(s))239 m.linkedHere(before)240 m.note = "Following " + filepath.Base(src.Path) + " in " + s.Name()241}242243// linkedHere trusts the workbook on this computer once the user links a244// file, unless regions or macros came from elsewhere untrusted.245func (m *Model) linkedHere(before int) {246 w := m.book()247 if m.macroTrusted() || before == 0 && len(w.Macros()) == 0 && !m.nb.fromFile {248 m.trustHere()249 }250}251252// freeSheetName is base made a valid sheet name no sheet has: "Log",253// "Log 2".254func freeSheetName(w *sheet.Workbook, base string) string {255 base = strings.Map(func(r rune) rune {256 if strings.ContainsRune(`:\/?*[]'`, r) {257 return '_'258 }259 return r260 }, strings.TrimSpace(base))261 if r := []rune(base); len(r) > 28 {262 base = string(r[:28])263 }264 if sheet.ValidSheetName(base) != nil {265 base = "Linked"266 }267 name := base268 for n := 2; w.Lookup(name) != nil; n++ {269 name = base + " " + strconv.Itoa(n)270 }271 return name272}273274// linkedLine says what the linked region under the pointer is doing:275// "● Following log.csv 1,204 rows, updated 12:03:04".276func (m *Model) linkedLine() string {277 r, ok := m.sheet.LinkedAt(m.cur)278 if !ok {279 return ""280 }281 name := filepath.Base(r.Source.Path)282 if r.Err != "" {283 line := m.th.Warning.Render("! " + name + ": " + r.Err)284 if r.Rows > 0 {285 line += m.th.Muted.Render(linkedCounts(r))286 }287 return line288 }289 state := liveMark(r) + " Following "290 if r.Paused {291 state = liveMark(r) + " Paused "292 }293 return m.th.Muted.Render(state) + m.th.Key.Render(name) + m.th.Muted.Render(linkedCounts(r))294}295296// linkedCounts is a region's rows and last update, for linkedLine.297func linkedCounts(r sheet.LinkedRegion) string {298 out := " " + transfer.Rows(r.Rows)299 if r.Source.Window > 0 {300 out += " (the last " + transfer.Thousands(r.Source.Window) + ")"301 }302 if r.Dropped > 0 {303 out += ", " + transfer.Thousands(r.Dropped) + " older dropped"304 }305 if !r.Updated.IsZero() {306 out += ", updated " + r.Updated.Local().Format(time.TimeOnly)307 }308 if r.Note != "" {309 out += "; " + r.Note310 }311 return out312}313314// liveMark is the glyph a linked region's state shows as, beside its315// tab's name and on the context line: tabstrip.Mark.316func liveMark(r sheet.LinkedRegion) string { return tabstrip.Mark(r.Paused, r.Err != "") }