cat / 012
clone

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

Nest the UI's, imports' and sessions' spans

Dami committed 8a7c5a4
Each Model keeps a Trace, given to its workbook: a command's span holds
the recalculation, pivot refreshes, sort or fill it causes. A macro run
is entered while its calls are served, so its commands and their
recalculations nest in it. Background work takes an explicit Parent:
questions to JEV, opening a file, imports and exports (through the
context they already take; an import's builder recalculates under the
import's span). 012 serve starts a span per session that holds its
program's commands, per Model, so sessions never share a trace.
commit
8a7c5a48163e0b1920242a70a008062497ec28d3
parent
503499f2cb9d14df6a39999f23a8d4e0af6b4554
author
Dami <dami.etoile@gmail.com>
30 files changed, +280 -57
Minternal/fileio/builder.go11++-
Minternal/fileio/csv.go2+-
Minternal/fileio/fileio.go7+-
Minternal/fileio/parquet.go2+-
Minternal/fileio/sqlite.go2+-
Minternal/fileio/wk1.go2+-
Minternal/fileio/xlsx_excelize_test.go2+-
Minternal/fileio/xlsximport.go2+-
Minternal/serve/serve.go8++-
Ainternal/serve/spans_test.go81+++++++++++++++++++++++
Minternal/ui/charts_test.go6+-
Minternal/ui/commands.go6+-
Minternal/ui/download.go4+-
Minternal/ui/file.go19+++--
Minternal/ui/fill.go3+-
Minternal/ui/filter.go3+-
Minternal/ui/findbar/find.go6+-
Minternal/ui/findbar/find_test.go2+
Minternal/ui/graphics.go4+-
Minternal/ui/hosts.go2+
Minternal/ui/jev.go5+-
Minternal/ui/macrorun.go5+-
Minternal/ui/model.go15+++-
Minternal/ui/pivot.go3+-
Minternal/ui/sort.go3+-
Ainternal/ui/spans_test.go106++++++++++++++++++++++++++++++
Minternal/ui/stress_test.go14+++-
Minternal/ui/transfer.go2+-
Minternal/ui/transfer/transfer.go7+-
Minternal/ui/transfer/transfer_test.go3+-
M internal/fileio/builder.go +8 -3
M internal/fileio/csv.go +1 -1
M internal/fileio/fileio.go +4 -3
M internal/fileio/parquet.go +1 -1
M internal/fileio/sqlite.go +1 -1
M internal/fileio/wk1.go +1 -1
M internal/fileio/xlsx_excelize_test.go +1 -1
M internal/fileio/xlsximport.go +1 -1
M internal/serve/serve.go +6 -2
A internal/serve/spans_test.go +81 -0
@@ -0,0 +1,81 @@
1+package serve2+3+import (4+ "encoding/json"5+ "io"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/telemetry"12+)13+14+// Each session's commands nest under its own span, in a trace of its15+// own, though the sessions run in one process at once.16+func TestSessionSpans(t *testing.T) {17+ path := filepath.Join(t.TempDir(), "log.jsonl")18+ stop, err := telemetry.Setup(telemetry.Config{LogPath: path})19+ if err != nil {20+ t.Fatal(err)21+ }22+ defer stop()23+ key := newKey(t)24+ srv, addr, _ := testServer(t, nil, key)25+ c, err := dial(addr, key, srv.HostKey())26+ if err != nil {27+ t.Fatal(err)28+ }29+ defer c.Close()30+ a, b := openTerm(t, c, 80, 24), openTerm(t, c, 80, 24)31+ a.waitFor(t, "READY")32+ b.waitFor(t, "READY")33+ for range 3 {34+ io.WriteString(a.stdin, "\x02") // Ctrl+B: format.bold35+ io.WriteString(b.stdin, "\x02")36+ }37+ for _, tm := range []*term{a, b} {38+ io.WriteString(tm.stdin, "\x11") // Ctrl+Q, then discard the bold39+ tm.waitFor(t, "unsaved changes")40+ io.WriteString(tm.stdin, "d")41+ tm.waitClosed(t)42+ }43+ srv.Close()44+ stop()45+46+ data, err := os.ReadFile(path)47+ if err != nil {48+ t.Fatal(err)49+ }50+ sessions := map[string]string{} // span id -> trace id51+ var commands []map[string]any52+ for _, l := range strings.Split(strings.TrimSpace(string(data)), "\n") {53+ var ev map[string]any54+ if err := json.Unmarshal([]byte(l), &ev); err != nil {55+ t.Fatal(err)56+ }57+ switch ev["event"] {58+ case "serve.session":59+ sessions[ev["span_id"].(string)] = ev["trace_id"].(string)60+ case "command":61+ commands = append(commands, ev)62+ }63+ }64+ if len(sessions) != 2 {65+ t.Fatalf("%d session spans: %s", len(sessions), data)66+ }67+ per := map[string]int{}68+ for _, cmd := range commands {69+ parent, _ := cmd["parent_id"].(string)70+ trace, ok := sessions[parent]71+ if !ok || cmd["trace_id"] != trace {72+ t.Errorf("command %v is not under a session", cmd)73+ }74+ per[parent]++75+ }76+ for id := range sessions {77+ if per[id] == 0 {78+ t.Errorf("session %s ran no commands: %v", id, per)79+ }80+ }81+}
M internal/ui/charts_test.go +3 -3
M internal/ui/commands.go +3 -3
M internal/ui/download.go +3 -1
M internal/ui/file.go +11 -8
@@ -88,7 +88,7 @@ func (m *Model) openFile(text string) tea.Cmd {
8888 if !ok {8989 return nil9090 }91- return loadCmd(name, p)91+ return loadCmd(name, p, m.spans.Parent())9292 }9393 9494 // reset starts over on s, as File > New and Open do. What belongs to the
@@ -96,7 +96,8 @@ func (m *Model) openFile(text string) tea.Cmd {
9696 // state, served directory and the JEV connection.9797 func (m *Model) reset(s *sheet.Sheet, filename string) {9898 *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs,99- macros: macroState{machine: m.macros.machine, editor: m.macros.editor}}99+ macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}100+ s.Book().SetTrace(m.spans)100101 if m.jev != nil {101102 s.Book().SetRemote(m.jev.cache)102103 }
@@ -159,14 +160,14 @@ func (m *Model) saveAs(name string, check bool) tea.Cmd {
159160 if check && name == m.filename {160161 expect = &m.disk161162 }162- return saveCmd(m.sheet, name, p, expect)163+ return saveCmd(m.sheet, name, p, expect, m.spans)163164 }164165 165166 // saveCmd writes the worksheet to path atomically: to a temporary file166167 // first, then renamed over the target. With expect set, it writes only167168 // if the file on disk is still that version.168-func saveCmd(s *sheet.Sheet, name, path string, expect *stamp) tea.Cmd {169- span := telemetry.Start("save")169+func saveCmd(s *sheet.Sheet, name, path string, expect *stamp, spans *telemetry.Trace) tea.Cmd {170+ span := spans.Start("save")170171 var buf strings.Builder171172 err := s.Write(&buf)172173 if telemetry.Enabled() {
@@ -211,7 +212,9 @@ func writeAtomic(path, data string) error {
211212 return err212213 }213214 214-func loadCmd(name, path string) tea.Cmd {215+// loadCmd reads the file in the background, the span of it and its216+// recalculation under parent.217+func loadCmd(name, path string, parent telemetry.Parent) tea.Cmd {215218 return func() tea.Msg {216219 f, err := os.Open(path)217220 if err != nil {
@@ -222,8 +225,8 @@ func loadCmd(name, path string) tea.Cmd {
222225 if fi, err := f.Stat(); err == nil {223226 st = stampOf(fi)224227 }225- span := telemetry.Start("open")226- s, err := sheet.Read(f)228+ span := parent.Start("open")229+ s, err := sheet.ReadTraced(f, telemetry.NewTrace(span.Parent()))227230 if err != nil {228231 span.Fail(err)229232 } else if telemetry.Enabled() {
M internal/ui/fill.go +1 -2
M internal/ui/filter.go +1 -2
M internal/ui/findbar/find.go +4 -2
M internal/ui/findbar/find_test.go +2 -0
M internal/ui/graphics.go +2 -2
M internal/ui/hosts.go +2 -0
M internal/ui/jev.go +3 -2
M internal/ui/macrorun.go +4 -1
M internal/ui/model.go +11 -4
@@ -108,25 +108,32 @@ type Model struct {
108108 rec *recorder // a macro being recorded: macrorec.go109109 macros macroState // a macro running, and trust in the file's macros: macrorun.go110110 111- keyAt time.Time // when the key the next frame answers was pressed, for telemetry111+ keyAt time.Time // when the key the next frame answers was pressed, for telemetry112+ spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go112113 113114 th theme.Theme114115 }115116 116117 // New returns a model editing s. filename may be empty.117118 func New(s *sheet.Sheet, filename string) *Model {118- m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}}119+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1},120+ spans: telemetry.NewTrace(telemetry.Parent{})}121+ s.Book().SetTrace(m.spans)119122 if filename != "" {120123 m.disk = diskStamp(filename)121124 }122125 return m123126 }124127 128+// TraceUnder nests every span the model starts under p for good: 012129+// serve's session span holds each session's commands.130+func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }131+125132 // Init implements tea.Model. It asks the terminal for its background color126133 // so the theme can adapt to light terminals.127134 func (m *Model) Init() tea.Cmd {128135 // jev.send starts any questions queued while loading the file.129- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(), m.startupCmd())136+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd())130137 }131138 132139 // Update implements tea.Model.
@@ -207,7 +214,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207214 m.clampView()208215 // Any edit may have queued JEV questions.209216 // Chart images follow any change, see graphics.go.210- return m, tea.Batch(cmd, m.jev.send(), m.term.syncImages(m.sheet, m.displayCharts, &m.th))217+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans))211218 }212219 213220 // beginUpdate prepares for an input event and returns the sheet's state
M internal/ui/pivot.go +1 -2
M internal/ui/sort.go +1 -2
A internal/ui/spans_test.go +106 -0
@@ -0,0 +1,106 @@
1+package ui2+3+import (4+ "encoding/json"5+ "log/slog"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+ "github.com/FelineStateMachine/012/internal/telemetry"14+)15+16+// logSpans turns telemetry on into a JSON log with the engine's hooks17+// set as cmd/012 sets them, and returns what reads the events logged.18+func logSpans(t *testing.T) func() []map[string]any {19+ t.Helper()20+ path := filepath.Join(t.TempDir(), "log.jsonl")21+ stop, err := telemetry.Setup(telemetry.Config{LogPath: path})22+ if err != nil {23+ t.Fatal(err)24+ }25+ end := func(trace any, name string, d time.Duration, attrs ...slog.Attr) {26+ if tr, ok := trace.(*telemetry.Trace); ok && tr.End(attrs...) {27+ return28+ }29+ telemetry.Event(name, d, attrs...)30+ }31+ sheet.OnBegin = func(trace any, op string) {32+ if tr, ok := trace.(*telemetry.Trace); ok {33+ tr.Begin(op)34+ }35+ }36+ sheet.OnRecalc = func(trace any, i sheet.RecalcInfo) { end(trace, "recalc", i.Duration) }37+ sheet.OnPivot = func(trace any, i sheet.PivotInfo) { end(trace, "pivot", i.Duration) }38+ t.Cleanup(func() {39+ sheet.OnBegin, sheet.OnRecalc, sheet.OnPivot = nil, nil, nil40+ stop()41+ })42+ return func() []map[string]any {43+ stop()44+ b, err := os.ReadFile(path)45+ if err != nil {46+ t.Fatal(err)47+ }48+ var evs []map[string]any49+ for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") {50+ var ev map[string]any51+ if err := json.Unmarshal([]byte(l), &ev); err != nil {52+ t.Fatal(err)53+ }54+ evs = append(evs, ev)55+ }56+ return evs57+ }58+}59+60+// under reports whether some event named child is logged under the one61+// that matches parent.62+func under(evs []map[string]any, child string, parent func(map[string]any) bool) bool {63+ for _, p := range evs {64+ if !parent(p) {65+ continue66+ }67+ for _, c := range evs {68+ if c["event"] == child && c["parent_id"] == p["span_id"] && c["trace_id"] == p["trace_id"] {69+ return true70+ }71+ }72+ }73+ return false74+}75+76+func spanNamed(event, id string) func(map[string]any) bool {77+ return func(ev map[string]any) bool { return ev["event"] == event && (id == "" || ev["id"] == id) }78+}79+80+// A trace shows what a command caused, and what a macro ran.81+func TestSpansNest(t *testing.T) {82+ read := logSpans(t)83+ m := newModel()84+ m.sheet.Set(addr("A1"), "1")85+ m.sheet.Set(addr("B1"), "=A1*2")86+ m.runCommand("clear")87+ script(t, m, `88+set("A2", 5)89+run("format.bold")90+`)91+ evs := read()92+ if !under(evs, "recalc", spanNamed("command", "clear")) {93+ t.Error("clear's recalculation is not under its command")94+ }95+ if !under(evs, "command", spanNamed("macro", "")) {96+ t.Error("the macro's command is not under the macro")97+ }98+ if !under(evs, "recalc", spanNamed("macro", "")) {99+ t.Error("the macro's recalculation is not under the macro")100+ }101+ for _, ev := range evs {102+ if ev["event"] == "command" && ev["id"] == "clear" && ev["parent_id"] != nil {103+ t.Errorf("a command from a key is a root: %v", ev)104+ }105+ }106+}
M internal/ui/stress_test.go +10 -4
M internal/ui/transfer.go +1 -1
M internal/ui/transfer/transfer.go +4 -3
M internal/ui/transfer/transfer_test.go +2 -1