cat / 012
clone

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

Following files: e2e test, golden screens, stress benchmark

Dami committed 766b432
An e2e test appends to a CSV file while 012 follows it: new rows,
a partial line waiting, a formula recalculating, truncation, pausing
and unlinking. Golden screens show a followed log with its tab mark
and a formula over it, the question of how many rows to keep and a
linked file that isn't there, dark and light. BenchmarkFollow appends
10,000 rows a second for three seconds and times each update applied
and each frame.

An error op no longer counts as an update of the region, so its time
shows when rows last arrived; the state is shown without monochrome
color too.
commit
766b432fe975b2bc5e417013efe3e7190e9518c2
parent
e29c1ef868d2cc17b091edd1a8efcd4edbb9dcd0
author
Dami <dami.etoile@gmail.com>
kind
test risk score 1.7 of 3, Jev's estimate
13 files changed, +542 -39
Ae2e/follow_test.go83++++++++++++++++++
Ae2e/screenfollow_test.go84++++++++++++++++++
Ae2e/testdata/screens/follow-ask.html30+++++++
Ae2e/testdata/screens/follow-log-light.html30+++++++
Ae2e/testdata/screens/follow-log.html30+++++++
Ae2e/testdata/screens/follow-missing-light.html30+++++++
Ae2e/testdata/screens/follow-missing.html30+++++++
Minternal/sheet/linked.go8+-
Minternal/sheet/live.go73+++++++++-------
Minternal/ui/follow.go12++-
Ainternal/ui/followstress_test.go138++++++++++++++++++++++++++++++
Minternal/ui/linked.go6+-
Minternal/ui/monochrome_test.go27++++++
A e2e/follow_test.go +83 -0
@@ -0,0 +1,83 @@
1+package e2e2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+// appendText adds text to the end of a file, as a program logging to it12+// does.13+func appendText(t *testing.T, name, text string) {14+ t.Helper()15+ f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)16+ if err != nil {17+ t.Fatal(err)18+ }19+ defer f.Close()20+ if _, err := f.WriteString(text); err != nil {21+ t.Fatal(err)22+ }23+}24+25+// linkTable links name at the active cell through the palette, keeping26+// every row.27+func (s *session) linkTable(name string) {28+ s.t.Helper()29+ s.linkTableAsk(name)30+ s.keys("<enter>")31+}32+33+func TestFollowFileAsItGrows(t *testing.T) {34+ dir := t.TempDir()35+ log := filepath.Join(dir, "app.csv")36+ writeText(t, log, "level,ms\ninfo,12\n")37+ s := start(t, dir)38+ s.keys("<f5>", "D1", "<enter>", "=SUM(B:B)", "<enter>")39+ s.keys("<f5>", "A1", "<enter>")40+ s.linkTable("app.csv")41+ s.waitFor("info")42+ s.waitFor("Sheet1 ●")43+44+ // Rows appended while 012 follows the file come in, a partial line45+ // once it's whole, and the formula over them recalculates.46+ for i := range 5 {47+ appendText(t, log, fmt.Sprintf("warn,%d\n", 100+i))48+ }49+ appendText(t, log, "error,")50+ s.waitFor("104")51+ s.eventually("the sum of the rows", func() bool { return strings.Contains(s.line(4), "522") })52+ if strings.Contains(s.screen(), "error") {53+ t.Fatalf("a partial line was read:\n%s", s.screen())54+ }55+ appendText(t, log, "7\n")56+ s.waitFor("error")57+ s.eventually("the sum with the last row", func() bool { return strings.Contains(s.line(4), "529") })58+59+ // The context line says what the region is doing.60+ s.keys("<f5>", "A3", "<enter>")61+ s.waitFor("● Following app.csv 7 rows")62+63+ // Truncated and written again, it's read from the start.64+ writeText(t, log, "level,ms\nreset,1\n")65+ s.waitFor("reset")66+ s.eventually("the old rows gone", func() bool { return !strings.Contains(s.screen(), "104") })67+68+ // Paused, it waits; unlinked, the values stay as they are.69+ s.keys("<ctrl+k>", "Follow", "<enter>")70+ s.waitFor("Paused following app.csv")71+ appendText(t, log, "late,2\n")72+ s.keys("<up>") // A2, in the region73+ s.waitFor("‖ Paused app.csv")74+ if strings.Contains(s.screen(), "late") {75+ t.Fatalf("a paused region read on:\n%s", s.screen())76+ }77+ s.keys("<ctrl+k>", "Unlink", "<enter>")78+ s.waitFor("Unlinked app.csv")79+ s.waitFor("Sheet1 ")80+ if strings.Contains(s.line(29), "●") {81+ t.Errorf("tab still marked: %q", s.line(29))82+ }83+}
A e2e/screenfollow_test.go +84 -0
@@ -0,0 +1,84 @@
1+package e2e2+3+import (4+ "path/filepath"5+ "testing"6+)7+8+// Golden screens for following files: a log followed in its own sheet,9+// the tab's live mark and a formula over the rows, the question of how10+// many rows to keep, and a linked file that isn't there; the log and the11+// missing file on a light terminal too.12+13+// appLog is a web server's log as a CSV file.14+const appLog = `time,level,path,ms15+09:14:02,info,/,1216+09:14:03,info,/login,4817+09:14:05,warn,/search,31018+09:14:07,info,/cart,2219+09:14:08,error,/pay,120420+09:14:10,info,/,921+09:14:11,info,/orders,5722+09:14:13,warn,/search,28823+09:14:14,info,/login,4124+09:14:16,info,/cart,1925+09:14:17,info,/,1126+`27+28+func writeAppLog(t *testing.T, dir string) { writeText(t, filepath.Join(dir, "app.csv"), appLog) }29+30+// followLog follows app.csv in a new sheet, keeping its last 8 rows, and31+// adds up their times beside it.32+func followLog(s *session) {33+ s.keys("Requests", "<enter>")34+ s.keys("<ctrl+k>", "Import", "<enter>")35+ s.waitFor("Type to filter, or a path")36+ s.keys("app.csv", "<enter>")37+ s.waitFor("Follow the file")38+ s.keys("follow", "<enter>")39+ s.waitFor("Follow app.csv:")40+ s.keys("l")41+ s.waitFor("Keep the last how many rows:")42+ s.keys("8", "<enter>")43+ s.waitFor("/orders")44+ s.keys("<f5>", "F1", "<enter>", "Slowest", "<tab>", "=MAX(D:D)", "<enter>")45+ s.keys("<f5>", "F2", "<enter>", "Errors", "<tab>", `=COUNTIF(B:B,"error")`, "<enter>")46+ s.waitFor("1204")47+}48+49+var followScreens = []screen{50+ {name: "follow-log", files: writeAppLog, setup: followLog},51+ {name: "follow-ask", files: writeAppLog, setup: func(s *session) { s.linkTableAsk("app.csv") }},52+ {name: "follow-missing", files: func(t *testing.T, dir string) {53+ writeText(t, filepath.Join(dir, "gone.012"),54+ `{"version": 2, "cells": {"D1": "Nightly run"}, "links": [{"at": "A1", "path": "nightly.csv"}]}`)55+ }, setup: func(s *session) {56+ s.keys("<ctrl+o>", "gone", "<enter>")57+ s.waitFor("#REF!")58+ s.waitFor("! nightly.csv: the file isn't there")59+ }},60+}61+62+// linkTableAsk links name at the active cell through the palette, up to63+// the question of how many rows to keep.64+func (s *session) linkTableAsk(name string) {65+ s.t.Helper()66+ s.keys("<ctrl+k>", "Link a table")67+ s.waitFor("Link a table")68+ s.keys("<enter>")69+ s.waitFor("Type to filter, or a path")70+ s.keys(name, "<enter>")71+ s.waitFor("Follow " + name + ":")72+}73+74+func init() {75+ for _, sc := range followScreens {76+ screens = append(screens, sc)77+ if sc.name != "follow-ask" {78+ light := sc79+ light.name += "-light"80+ light.opts.light = true81+ screens = append(screens, light)82+ }83+ }84+}
A e2e/testdata/screens/follow-ask.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span>3+Follow app.csv: <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Keep every row <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">L</span><span style="background:var(--vt-palette-8)"> </span> Keep the last rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Keep every row up to max-cells, or only the newest, older ones dropping as new ones arrive</pre>\ No newline at end of file
A e2e/testdata/screens/follow-log-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F3</span><span style="background:var(--vt-palette-7)"> </span>3+4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">F</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">time</span> <span style="color:var(--vt-palette-4);font-style:italic">level</span> <span style="color:var(--vt-palette-4);font-style:italic">path</span> <span style="color:var(--vt-palette-4);font-style:italic">ms</span> Slowest 12046+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:07</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/cart</span> <span style="color:var(--vt-palette-4);font-style:italic">22</span> Errors 17+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:08</span> <span style="color:var(--vt-palette-4);font-style:italic">error</span> <span style="color:var(--vt-palette-4);font-style:italic">/pay</span> <span style="color:var(--vt-palette-4);font-style:italic">1204</span> <span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:10</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/</span> <span style="color:var(--vt-palette-4);font-style:italic">9</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:11</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/orders</span> <span style="color:var(--vt-palette-4);font-style:italic">57</span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:13</span> <span style="color:var(--vt-palette-4);font-style:italic">warn</span> <span style="color:var(--vt-palette-4);font-style:italic">/search</span> <span style="color:var(--vt-palette-4);font-style:italic">288</span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:14</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/login</span> <span style="color:var(--vt-palette-4);font-style:italic">41</span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:16</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/cart</span> <span style="color:var(--vt-palette-4);font-style:italic">19</span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">9:14:17</span> <span style="color:var(--vt-palette-4);font-style:italic">info</span> <span style="color:var(--vt-palette-4);font-style:italic">/</span> <span style="color:var(--vt-palette-4);font-style:italic">11</span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">app</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">●</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/follow-log.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F3</span><span style="background:var(--vt-palette-8)"> </span>3+4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">F</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">time</span> <span style="color:var(--vt-palette-6);font-style:italic">level</span> <span style="color:var(--vt-palette-6);font-style:italic">path</span> <span style="color:var(--vt-palette-6);font-style:italic">ms</span> Slowest 12046+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:07</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/cart</span> <span style="color:var(--vt-palette-6);font-style:italic">22</span> Errors 17+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:08</span> <span style="color:var(--vt-palette-6);font-style:italic">error</span> <span style="color:var(--vt-palette-6);font-style:italic">/pay</span> <span style="color:var(--vt-palette-6);font-style:italic">1204</span> <span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:10</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/</span> <span style="color:var(--vt-palette-6);font-style:italic">9</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:11</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/orders</span> <span style="color:var(--vt-palette-6);font-style:italic">57</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:13</span> <span style="color:var(--vt-palette-6);font-style:italic">warn</span> <span style="color:var(--vt-palette-6);font-style:italic">/search</span> <span style="color:var(--vt-palette-6);font-style:italic">288</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:14</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/login</span> <span style="color:var(--vt-palette-6);font-style:italic">41</span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:16</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/cart</span> <span style="color:var(--vt-palette-6);font-style:italic">19</span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">9:14:17</span> <span style="color:var(--vt-palette-6);font-style:italic">info</span> <span style="color:var(--vt-palette-6);font-style:italic">/</span> <span style="color:var(--vt-palette-6);font-style:italic">11</span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">app</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">●</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/follow-missing-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> #REF!3+<span style="color:var(--vt-palette-3)">!</span> <span style="color:var(--vt-palette-3)">nightly.csv:</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">file</span> <span style="color:var(--vt-palette-3)">isn&#39;t</span> <span style="color:var(--vt-palette-3)">there</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline wavy var(--vt-palette-1)">#REF!</span><span style="background:var(--vt-palette-6)"> </span> Nightly run6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">!</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> gone.012 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/follow-missing.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> #REF!3+<span style="color:var(--vt-palette-3)">!</span> <span style="color:var(--vt-palette-3)">nightly.csv:</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">file</span> <span style="color:var(--vt-palette-3)">isn&#39;t</span> <span style="color:var(--vt-palette-3)">there</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline wavy var(--vt-palette-1)">#REF!</span><span style="background:var(--vt-palette-6)"> </span> Nightly run6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">!</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> gone.012 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
M internal/sheet/linked.go +4 -4
M internal/sheet/live.go +43 -30
@@ -63,7 +63,8 @@ var OnLive func(trace any, i LiveInfo)
6363 6464 // ApplyLive applies op to its region: its rows are written, the window6565 // drops the oldest, and what reads the cells that changed recalculates.66-// It returns the region as it now is; ErrNoLinked when it's gone.66+// It returns the region as the op leaves it, or ErrNoLinked for a67+// region the workbook doesn't hold.6768 func (w *Workbook) ApplyLive(op LiveOp) (LinkedRegion, error) {6869 s, l := w.findLinked(op.Link)6970 if l == nil {
@@ -92,7 +93,7 @@ func (w *Workbook) ApplyLive(op LiveOp) (LinkedRegion, error) {
9293 9394 // applyLive writes op's rows into l and returns the cells that changed.9495 func (s *Sheet) applyLive(l *linked, op LiveOp) []loc {95- if !op.At.IsZero() {96+ if !op.At.IsZero() && op.Err == "" {9697 l.updated = op.At9798 }9899 if op.Reset {
@@ -183,17 +184,7 @@ func (s *Sheet) readLinkedRow(l *linked, r int) LiveRow {
183184 // that changed. When a cell it needs holds something of its own, it184185 // writes nothing and says so.185186 func (s *Sheet) writeLinkedRows(l *linked, header LiveRow, rows []LiveRow, reset bool) []loc {186- cols := len(header)187- for _, r := range rows {188- cols = max(cols, len(r))189- }190- if !reset {191- cols = max(cols, l.cols)192- }193- nrows := 0194- if cols > 0 {195- nrows = 1 + len(rows)196- }187+ cols, nrows := linkedSize(l, header, rows, reset)197188 area := Rect{From: l.anchor, To: Addr{Col: l.anchor.Col + max(cols, 1) - 1, Row: l.anchor.Row + max(nrows, 1) - 1}}198189 if at, ok := s.linkedBlocked(l, area); ok {199190 l.err = "The linked rows would overwrite data in " + at.String()
@@ -207,29 +198,14 @@ func (s *Sheet) writeLinkedRows(l *linked, header LiveRow, rows []LiveRow, reset
207198 keep = noRect208199 }209200 changed := s.clearSpilled(l.area(), keep)210- write := func(r int, row LiveRow, all bool) {211- for c := range cols {212- var lc LiveCell213- if c < len(row) {214- lc = row[c]215- }216- if lc.V.Kind == Empty && !all {217- continue218- }219- a := Addr{Col: l.anchor.Col + c, Row: l.anchor.Row + r}220- if s.writeSpilled(a, lc.V, lc.F) {221- changed = append(changed, loc{s, a})222- }223- }224- }225201 if cols > 0 {226- write(0, header, true)202+ changed = s.writeLinkedRow(l, 0, header, cols, true, changed)227203 }228204 for i, row := range rows {229205 // A row written over one that showed clears what it doesn't230206 // fill; one below the old region has nothing to clear.231207 if row != nil {232- write(i+1, row, i+1 < l.rows || reset)208+ changed = s.writeLinkedRow(l, i+1, row, cols, i+1 < l.rows || reset, changed)233209 }234210 }235211 l.cols, l.rows = cols, nrows
@@ -243,6 +219,43 @@ func (s *Sheet) writeLinkedRows(l *linked, header LiveRow, rows []LiveRow, reset
243219 return changed244220 }245221 222+// linkedSize is how many columns and rows the region takes to show223+// header and rows: none when there's nothing to show.224+func linkedSize(l *linked, header LiveRow, rows []LiveRow, reset bool) (cols, nrows int) {225+ cols = len(header)226+ for _, r := range rows {227+ cols = max(cols, len(r))228+ }229+ if !reset {230+ cols = max(cols, l.cols)231+ }232+ if cols > 0 {233+ nrows = 1 + len(rows)234+ }235+ return cols, nrows236+}237+238+// writeLinkedRow writes row r of the region (0 for the header), cols239+// cells wide, adding the cells that changed to changed. With all, the240+// cells the row leaves blank are cleared; otherwise they're blank241+// already.242+func (s *Sheet) writeLinkedRow(l *linked, r int, row LiveRow, cols int, all bool, changed []loc) []loc {243+ for c := range cols {244+ var lc LiveCell245+ if c < len(row) {246+ lc = row[c]247+ }248+ if lc.V.Kind == Empty && !all {249+ continue250+ }251+ a := Addr{Col: l.anchor.Col + c, Row: l.anchor.Row + r}252+ if s.writeSpilled(a, lc.V, lc.F) {253+ changed = append(changed, loc{s, a})254+ }255+ }256+ return changed257+}258+246259 // linkedBlocked returns a cell of area the region can't write: one247260 // holding something that isn't the region's, or past the sheet's edge.248261 func (s *Sheet) linkedBlocked(l *linked, area Rect) (Addr, bool) {
M internal/ui/follow.go +8 -4
A internal/ui/followstress_test.go +138 -0
@@ -0,0 +1,138 @@
1+//go:build stress2+3+package ui4+5+import (6+ "context"7+ "fmt"8+ "os"9+ "path/filepath"10+ "slices"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/012/internal/fileio"15+ "github.com/FelineStateMachine/012/internal/live"16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// BenchmarkFollow follows a CSV log while another goroutine appends20+// 10,000 rows a second to it for three seconds, polling as the UI does21+// (every live.Interval, at once while more is waiting), applying each22+// update to the workbook, with formulas over the region, and drawing a23+// frame after each. It reports the slowest and the 99th percentile of24+// an update applied (writing the cells and recalculating what reads25+// them) and of a frame, and the rows a second that came in: an update26+// must stay a small part of a frame's budget, however fast the file27+// grows. The window cases keep the last rows, rewriting the region on28+// every update; "all" keeps every row.29+func BenchmarkFollow(b *testing.B) {30+ for _, window := range []int{0, 1000, 10000} {31+ name := "all"32+ if window > 0 {33+ name = fmt.Sprintf("window-%d", window)34+ }35+ b.Run(name, func(b *testing.B) {36+ for b.Loop() {37+ r := followRun(b, window)38+ b.ReportMetric(r.applyMax, "apply-max-ms")39+ b.ReportMetric(r.applyP99, "apply-p99-ms")40+ b.ReportMetric(r.frameMax, "frame-max-ms")41+ b.ReportMetric(r.frameP99, "frame-p99-ms")42+ b.ReportMetric(r.rate, "rows/s")43+ }44+ })45+ }46+}47+48+type followResult struct{ applyMax, applyP99, frameMax, frameP99, rate float64 }49+50+const (51+ followRate = 10000 // rows appended a second52+ followSeconds = 353+ followBatchGap = 10 * time.Millisecond54+)55+56+func followRun(b *testing.B, window int) followResult {57+ b.StopTimer()58+ path := filepath.Join(b.TempDir(), "access.csv")59+ os.WriteFile(path, []byte("time,level,path,ms\n"), 0o644)60+ m := sized(sheet.New(), 200, 60)61+ id, err := m.sheet.AddLinked(sheet.Addr{}, sheet.LinkSource{Path: "access.csv", Window: window})62+ if err != nil {63+ b.Fatal(err)64+ }65+ m.sheet.Set(sheet.Addr{Col: 6}, "=SUM(D:D)")66+ m.sheet.Set(sheet.Addr{Col: 6, Row: 1}, `=COUNTIF(B:B,"error")`)67+ m.sheet.Set(sheet.Addr{Col: 6, Row: 2}, "=AVERAGE(D2:D100000)")68+ src := live.NewFile("access.csv", path, fileio.CSV, fileio.Options{})69+ defer src.Close()70+ t := newFakeTerm(200, 60)71+ done := make(chan int)72+ go appendLog(path, done)73+ b.StartTimer()74+75+ var applies, frames []float6476+ start, total := time.Now(), 077+ writing := true78+ for {79+ select {80+ case total = <-done:81+ writing = false82+ default:83+ }84+ u, ok := src.Poll(context.Background())85+ if ok {86+ at := time.Now()87+ if _, err := m.book().ApplyLive(u.Op(id)); err != nil {88+ b.Fatal(err)89+ }90+ applies = append(applies, ms(time.Since(at)))91+ at = time.Now()92+ t.frame(m)93+ frames = append(frames, ms(time.Since(at)))94+ }95+ switch {96+ case u.More:97+ case writing:98+ time.Sleep(live.Interval)99+ default:100+ if r, _ := m.book().LinkedRegion(id); r.Rows+r.Dropped < total {101+ continue102+ }103+ return followResult{applyMax: slices.Max(applies), applyP99: p99(applies), frameMax: slices.Max(frames),104+ frameP99: p99(frames), rate: float64(total) / time.Since(start).Seconds()}105+ }106+ }107+}108+109+// appendLog appends followRate rows a second to path, in batches, for110+// followSeconds, then sends how many it wrote.111+func appendLog(path string, done chan<- int) {112+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)113+ if err != nil {114+ panic(err)115+ }116+ defer f.Close()117+ levels := []string{"info", "info", "info", "warn", "error"}118+ per := followRate * int(followBatchGap) / int(time.Second)119+ n := 0120+ for range followSeconds * int(time.Second/followBatchGap) {121+ var batch []byte122+ for range per {123+ batch = fmt.Appendf(batch, "%d,%s,/api/%d,%d\n", n, levels[n%len(levels)], n%97, n%1300)124+ n++125+ }126+ f.Write(batch)127+ time.Sleep(followBatchGap)128+ }129+ done <- n130+}131+132+func ms(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }133+134+func p99(xs []float64) float64 {135+ s := slices.Clone(xs)136+ slices.Sort(s)137+ return s[min(len(s)-1, len(s)*99/100)]138+}
M internal/ui/linked.go +5 -1
M internal/ui/monochrome_test.go +27 -0