A notebook is a sheet of the workbook holding cells (internal/notebook: code cells of nushell, note cells of Markdown, names, what each reads, run orders, stale outputs, save caps). Its cells are part of the undo state; outputs are kept by cell and saved as NUON up to the caps. A cell's output sent to a sheet is a region fed through ApplyLive, as a linked file is. Command regions of earlier files convert on load into code cells, their tables staying on the sheet as sent outputs. nushell.Exec returns what nu printed as NUON; fileio.NUONRows reads it as a region's rows.
- commit
- 9d1ef682cdfb1ea09d4e945331f00ac31ac6c280
- parent
- d9a93a36b15ece3b6874d93bef43f86fb9c3d35d
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.8 of 3, Jev's estimate
@@ -173,18 +173,20 @@ func TestEncodeFiltered(t *testing.T) {
173173 }174174 }175175 176-// A notebook's regions download as their values, labels included.176+// A notebook cell's output sent to a sheet downloads as its values.177177 func TestExportRegion(t *testing.T) {178178 s := sheet.New()179- s.MakeNotebook()180- if err := s.AddRegion(sheet.Region{Name: "r1", Command: "ls"}); err != nil {179+ if err := s.AddRegion(sheet.Region{Name: "r1", Output: true}); err != nil {181180 t.Fatal(err)182181 }183- d := &sheet.RegionData{Rows: 2, Cols: 2, Values: []sheet.Value{{Kind: sheet.Text, Str: "name"}, {Kind: sheet.Text, Str: "n"}, {Kind: sheet.Text, Str: "a"}, {Kind: sheet.Number, Num: 1}}}184- if err := s.ShowRegion("r1", d); err != nil {182+ rows, _, err := NUONRows(context.Background(), []byte("[[name, n]; [a, 1]]"), 0)183+ if err != nil {184+ t.Fatal(err)185+ }186+ if err := s.Book().ApplyLive(sheet.LiveOp{Region: "r1", Reset: true, Header: rows.Header, Rows: rows.Rows}); err != nil {185187 t.Fatal(err)186188 }187- if got := encode(t, s, CSV); got != "r1 ls,\nname,n\na,1\n" {189+ if got := encode(t, s, CSV); got != "name,n\na,1\n" {188190 t.Errorf("CSV %q", got)189191 }190192 path := filepath.Join(t.TempDir(), "nb.xlsx")@@ -195,8 +197,48 @@ func TestExportRegion(t *testing.T) {
195197 if err != nil {196198 t.Fatal(err)197199 }198- if c := res.Sheet.Cell(addr(t, "B3")); c == nil || c.IsFormula() || c.Value.Num != 1 {199- t.Errorf("B3 read back as %+v", c)200+ if c := res.Sheet.Cell(addr(t, "B2")); c == nil || c.IsFormula() || c.Value.Num != 1 {201+ t.Errorf("B2 read back as %+v", c)202+ }203+}204+205+// NUONRows reads any value nu prints as a region's rows.206+func TestNUONRows(t *testing.T) {207+ for _, c := range []struct {208+ out string209+ want string210+ }{211+ {"[[name, size]; [a, 1kb], [b, 2b]]", "name size|a 1.0 kB|b 2 B"},212+ {"{a: 1, b: true}", "a b|1 TRUE"},213+ {"3", "value|3"},214+ {`"hello"`, "value|hello"},215+ {"null", "value"},216+ {"[1, 2]", "value|1|2"},217+ {"", ""},218+ } {219+ rows, _, err := NUONRows(context.Background(), []byte(c.out), 0)220+ if err != nil {221+ t.Errorf("%q: %v", c.out, err)222+ continue223+ }224+ var lines []string225+ for _, r := range append([]sheet.LiveRow{rows.Header}, rows.Rows...) {226+ var texts []string227+ for _, lc := range r {228+ texts = append(texts, sheet.FormatText(lc.V, lc.F))229+ }230+ if len(texts) > 0 {231+ lines = append(lines, strings.TrimSpace(strings.Join(texts, " ")))232+ }233+ }234+ if got := strings.Join(lines, "|"); got != c.want {235+ t.Errorf("%q read as %q, want %q", c.out, got, c.want)236+ }237+ }238+ // Past max-cells, whole rows are kept and the note says so.239+ rows, note, err := NUONRows(context.Background(), []byte("[[a, b]; [1, 2], [3, 4], [5, 6]]"), 4)240+ if err != nil || len(rows.Rows) != 1 || note == "" {241+ t.Errorf("capped: %+v %q %v", rows, note, err)200242 }201243 }202244 @@ -0,0 +1,35 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// NUONRows reads NUON, a notebook cell's output, as a region's rows: a13+// table's header and rows, a record as one row, a list of values that14+// aren't records as one column named value, and any other value as that15+// column with one row. It keeps at most maxCells cells, whole rows (016+// for the max-cells setting), and says what it left out.17+func NUONRows(ctx context.Context, data []byte, maxCells int) (TailRows, string, error) {18+ data = bytes.TrimSpace(data)19+ if len(data) == 0 {20+ return TailRows{}, "", nil21+ }22+ if data[0] != '[' && data[0] != '{' {23+ data = append(append([]byte("["), data...), ']')24+ }25+ res, err := ImportReader(ctx, "nu", bytes.NewReader(data), Options{MaxCells: maxCells})26+ if err != nil {27+ return TailRows{}, "", err28+ }29+ return Rows(res.Sheet), strings.Join(res.Notes, "; "), nil30+}31+32+// NUONCell is the cell a NUON value makes: its value, text as the33+// value's own, and its format (a file size's Size, a date's Date time,34+// in the local time zone), as importing it would store it.35+func NUONCell(v nuon.Value) sheet.LiveCell { return nuonCell(v, zone()) }@@ -0,0 +1,224 @@
1+package notebook2+3+import (4+ "fmt"5+ "slices"6+ "strings"7+)8+9+// The cells' dependency graph: a cell reading $sales depends on the cell10+// named sales. A name belongs to the first code cell giving it; a later11+// one giving the same name can't run until it's renamed. Cells run in12+// the order asked, each after the cells it reads among those running,13+// and a cell reading itself, directly or through others, doesn't run.14+15+// Names maps each name to the index of the first code cell giving it.16+func Names(cells []Cell) map[string]int {17+ out := map[string]int{}18+ for i, c := range cells {19+ if n := c.Name(); n != "" {20+ if _, dup := out[n]; !dup {21+ out[n] = i22+ }23+ }24+ }25+ return out26+}27+28+// Taken says why cell i can't use its name: an earlier cell gives it29+// (names ignore case there, as formulas' nu.name do), or "".30+func Taken(cells []Cell, i int) string {31+ name := cells[i].Name()32+ if name == "" {33+ return ""34+ }35+ for j, c := range cells[:i] {36+ if strings.EqualFold(c.Name(), name) {37+ return fmt.Sprintf("cell %d is already named %s: rename one", j+1, c.Name())38+ }39+ }40+ return ""41+}42+43+// Reads returns the indices of the cells cell i reads, in the order it44+// names them.45+func Reads(cells []Cell, names map[string]int, i int) []int {46+ var out []int47+ for _, n := range Refs(cells[i].Pipeline()) {48+ if j, ok := names[n]; ok && j != i && !slices.Contains(out, j) {49+ out = append(out, j)50+ }51+ }52+ return out53+}54+55+// Order returns the code cells of want, each after the cells it reads56+// among them, in want's order otherwise, and the cells left out for57+// reading themselves, directly or through others.58+func Order(cells []Cell, want []int) (order, cycle []int) {59+ names := Names(cells)60+ in := map[int]bool{}61+ for _, i := range want {62+ if cells[i].Kind == Code {63+ in[i] = true64+ }65+ }66+ done := map[int]bool{}67+ for len(order) < len(in) {68+ progress := false69+ for _, i := range want {70+ if !in[i] || done[i] {71+ continue72+ }73+ ready := !slices.ContainsFunc(Reads(cells, names, i), func(j int) bool { return in[j] && !done[j] })74+ if ready {75+ order, done[i], progress = append(order, i), true, true76+ }77+ }78+ if !progress {79+ break80+ }81+ }82+ for _, i := range want {83+ if in[i] && !done[i] && !slices.Contains(cycle, i) {84+ cycle = append(cycle, i)85+ }86+ }87+ return order, cycle88+}89+90+// Dependents returns the cells that read cell i, directly or through91+// others, in the notebook's order.92+func Dependents(cells []Cell, i int) []int {93+ names := Names(cells)94+ want := map[int]bool{i: true}95+ for changed := true; changed; {96+ changed = false97+ for j := range cells {98+ if !want[j] && cells[j].Kind == Code && slices.ContainsFunc(Reads(cells, names, j), func(k int) bool { return want[k] }) {99+ want[j], changed = true, true100+ }101+ }102+ }103+ var out []int104+ for j := range cells {105+ if want[j] && j != i {106+ out = append(out, j)107+ }108+ }109+ return out110+}111+112+// Inputs returns the cells cell i reads, directly or through others,113+// that have no output, in the notebook's order: what has to run before114+// it can.115+func Inputs(cells []Cell, i int, output func(id int) *Output) []int {116+ names := Names(cells)117+ need := map[int]bool{}118+ var visit func(j int)119+ visit = func(j int) {120+ for _, k := range Reads(cells, names, j) {121+ if need[k] || k == i {122+ continue123+ }124+ if o := output(cells[k].ID); o == nil || o.NUON == nil {125+ need[k] = true126+ visit(k)127+ }128+ }129+ }130+ visit(i)131+ var out []int132+ for j := range cells {133+ if need[j] {134+ out = append(out, j)135+ }136+ }137+ return out138+}139+140+// Stale returns the cells, by ID, whose output may not be what running141+// them now would give: their source changed since, or an output they142+// read changed or is stale itself.143+func Stale(cells []Cell, output func(id int) *Output) map[int]bool {144+ names := Names(cells)145+ all := make([]int, len(cells))146+ for i := range all {147+ all[i] = i148+ }149+ order, _ := Order(cells, all)150+ stale := map[int]bool{}151+ for _, i := range order {152+ c := cells[i]153+ o := output(c.ID)154+ if o == nil {155+ continue156+ }157+ s := o.Source != c.Source158+ for _, name := range Refs(c.Pipeline()) {159+ j, ok := names[name]160+ seq := 0161+ if ok {162+ if d := output(cells[j].ID); d != nil {163+ seq = d.Seq164+ }165+ s = s || stale[cells[j].ID]166+ }167+ if read, had := o.Reads[name]; had || ok {168+ s = s || read != seq169+ }170+ }171+ if s {172+ stale[c.ID] = true173+ }174+ }175+ return stale176+}177+178+// Settle makes outputs read from a file what their cells' runs would179+// have left: made from the source as it is, having read the outputs as180+// they are, so none is stale until something changes.181+func Settle(cells []Cell, outputs map[int]*Output) {182+ names := Names(cells)183+ for _, c := range cells {184+ o := outputs[c.ID]185+ if o == nil {186+ continue187+ }188+ o.Source, o.Reads = c.Source, map[string]int{}189+ for _, name := range Refs(c.Pipeline()) {190+ if j, ok := names[name]; ok {191+ if d := outputs[cells[j].ID]; d != nil {192+ o.Reads[name] = d.Seq193+ } else {194+ o.Reads[name] = 0195+ }196+ }197+ }198+ }199+}200+201+// Caps are how much of the outputs a file keeps, in bytes of NUON: at202+// most Cell of one cell's, Total of all of them.203+type Caps struct{ Cell, Total int }204+205+// DefaultCaps keep 1 MB of a cell's output and 8 MB of a notebook's.206+var DefaultCaps = Caps{Cell: 1 << 20, Total: 8 << 20}207+208+// Kept returns the cells, by ID, whose outputs a file keeps within caps:209+// in the notebook's order, each that fits in what's left.210+func Kept(cells []Cell, output func(id int) *Output, caps Caps) map[int]bool {211+ out := map[int]bool{}212+ left := caps.Total213+ for _, c := range cells {214+ o := output(c.ID)215+ if o == nil || o.NUON == nil {216+ continue217+ }218+ if n := len(o.NUON); n <= caps.Cell && n <= left {219+ out[c.ID] = true220+ left -= n221+ }222+ }223+ return out224+}@@ -0,0 +1,158 @@
1+// Package notebook is a notebook's document: its cells (code cells2+// holding a nushell pipeline, note cells holding Markdown), the names3+// cells give their outputs, what each cell reads, the order cells run4+// in, and what a cell's last run left (its output). It knows nothing of5+// sheets, terminals or nu itself: the engine (internal/sheet) keeps a6+// notebook tab's cells in its undo history and its file, and the UI runs7+// them and draws them.8+package notebook9+10+import (11+ "errors"12+ "fmt"13+ "slices"14+ "strings"15+ "time"16+)17+18+// Kind is what a cell holds.19+type Kind uint820+21+const (22+ // Code cells hold a nushell pipeline, several lines if need be.23+ Code Kind = iota24+ // Note cells hold Markdown.25+ Note26+)27+28+// String is how the file names the kind.29+func (k Kind) String() string {30+ if k == Note {31+ return "note"32+ }33+ return "code"34+}35+36+// ParseKind reads a kind as the file names it.37+func ParseKind(s string) (Kind, bool) {38+ switch s {39+ case "code", "":40+ return Code, true41+ case "note":42+ return Note, true43+ }44+ return Code, false45+}46+47+// Cell is one cell of a notebook. Cells are values: a change makes a new48+// one, so the undo history can keep the list it replaced.49+type Cell struct {50+ // ID identifies the cell for as long as the workbook is open: its51+ // output is kept by it. It isn't saved.52+ ID int53+ Kind Kind54+ Source string55+}56+57+// Name is the name the cell gives its output, from a source of the form58+// `name = pipeline`, or "".59+func (c Cell) Name() string {60+ if c.Kind != Code {61+ return ""62+ }63+ name, _ := SplitName(c.Source)64+ return name65+}66+67+// Pipeline is what runs: the source without its name.68+func (c Cell) Pipeline() string {69+ _, p := SplitName(c.Source)70+ return p71+}72+73+// SplitName splits `name = pipeline` into the name and the pipeline; a74+// source that doesn't start so has no name. `==` isn't a name's `=`.75+func SplitName(src string) (name, pipeline string) {76+ head, rest, ok := strings.Cut(src, "=")77+ head = strings.TrimSpace(head)78+ if !ok || strings.HasPrefix(rest, "=") || ValidName(head) != nil {79+ return "", src80+ }81+ return head, strings.TrimLeft(rest, " \t")82+}83+84+// WithName is src given the name name, replacing any name it has; ""85+// takes its name away.86+func WithName(src, name string) string {87+ _, p := SplitName(src)88+ if name == "" {89+ return p90+ }91+ return name + " = " + p92+}93+94+// Reserved are nushell's own variables and the notebook's: $selection95+// is the selection on a sheet, $sheet.A1:C9 a range of one.96+var Reserved = []string{"in", "env", "nu", "it", "selection", "sheet"}97+98+// ValidName checks that name can name a cell's output: a nushell99+// variable's name of letters, digits and _, not starting with a digit,100+// not one of Reserved, and not starting with __, which the notebook's101+// own variables do.102+func ValidName(name string) error {103+ switch {104+ case name == "":105+ return errors.New("Enter a name")106+ case len(name) > 64:107+ return errors.New("A name can be at most 64 characters")108+ case isDigit(name[0]):109+ return errors.New("A name can't start with a digit")110+ case strings.HasPrefix(name, "__"):111+ return errors.New("Names starting with __ are the notebook's own")112+ case slices.Contains(Reserved, strings.ToLower(name)):113+ return fmt.Errorf("$%s is nushell's own", name)114+ }115+ for i := range len(name) {116+ if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' {117+ return errors.New("A name can only have letters, digits and _")118+ }119+ }120+ return nil121+}122+123+func isLetter(c byte) bool { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' }124+func isDigit(c byte) bool { return c >= '0' && c <= '9' }125+func isWord(c byte) bool { return isLetter(c) || isDigit(c) || c == '_' }126+127+// Output is what a code cell's last run left: what it printed, as NUON,128+// or why it failed. Outputs are values too, replaced whole.129+type Output struct {130+ // NUON is what the pipeline printed, as NUON; nil when it failed or131+ // its output wasn't saved.132+ NUON []byte133+ // Err is nu's message when the run failed, and Detail the rest of134+ // what it said (its help line).135+ Err, Detail string136+ // Note says what was left out.137+ Note string138+ // Count is the run's number in this session, [3]; 0 for an output139+ // read from the file.140+ Count int141+ // Seq identifies the output among the workbook's, for telling142+ // whether what a cell read has changed since.143+ Seq int144+ // Took is how long the run took.145+ Took time.Duration146+ // Source is the cell's source when it ran.147+ Source string148+ // Reads are the Seqs of the outputs the run read, by name.149+ Reads map[string]int150+ // Selection is the range the run read as $selection, "Sheet1!A1:C9".151+ Selection string152+ // Unsaved is set on an output the file didn't keep, being larger153+ // than the caps allowed: there's nothing to show until it runs.154+ Unsaved bool155+}156+157+// Failed reports whether the run failed.158+func (o *Output) Failed() bool { return o != nil && o.Err != "" }@@ -0,0 +1,127 @@
1+package notebook2+3+import (4+ "slices"5+ "testing"6+)7+8+func TestSplitName(t *testing.T) {9+ for _, tc := range []struct{ src, name, pipe string }{10+ {"sales = open sales.csv", "sales", "open sales.csv"},11+ {"big=\n$sales | first", "big", "\n$sales | first"},12+ {"ls | where name == x", "", "ls | where name == x"},13+ {"a == b", "", "a == b"},14+ {"in = ls", "", "in = ls"},15+ {"9x = ls", "", "9x = ls"},16+ {"let x = 1", "", "let x = 1"},17+ {"__s = 1", "", "__s = 1"},18+ } {19+ name, p := SplitName(tc.src)20+ if name != tc.name || p != tc.pipe {21+ t.Errorf("SplitName(%q) = %q, %q", tc.src, name, p)22+ }23+ }24+ if got := WithName("ls", "files"); got != "files = ls" {25+ t.Errorf("WithName %q", got)26+ }27+ if got := WithName("files = ls", ""); got != "ls" {28+ t.Errorf("WithName without %q", got)29+ }30+ if (Cell{Kind: Note, Source: "x = y"}).Name() != "" {31+ t.Error("a note cell has a name")32+ }33+}34+35+func TestRefs(t *testing.T) {36+ got := Refs("$r1 | where x == $in.a and $nu.home | $r_2.x | $selection | $sheet.A1")37+ if !slices.Equal(got, []string{"r1", "r_2"}) {38+ t.Errorf("refs %v", got)39+ }40+ if !ReadsSelection("$selection | math sum") || ReadsSelection("$selections") {41+ t.Error("ReadsSelection")42+ }43+}44+45+func TestBind(t *testing.T) {46+ got, refs := Bind("$sheet.A1:C9 | append $sheet.'Q1 data'!B2:B5 | append $sheet.A1:C9.name | $x$sheet.A1 | $sheet.")47+ want := "$__sheet1 | append $__sheet2 | append $__sheet1.name | $x$sheet.A1 | $sheet."48+ if got != want {49+ t.Errorf("Bind = %q", got)50+ }51+ if len(refs) != 2 || refs[0].Ref != "A1:C9" || refs[1].Ref != "'Q1 data'!B2:B5" || refs[1].Var != "__sheet2" {52+ t.Errorf("refs %+v", refs)53+ }54+}55+56+func cells(srcs ...string) []Cell {57+ out := make([]Cell, len(srcs))58+ for i, s := range srcs {59+ out[i] = Cell{ID: i + 1, Source: s}60+ }61+ return out62+}63+64+func TestOrder(t *testing.T) {65+ cs := cells("big = $files | first", "files = ls", "# note", "$big | length", "a = $b", "b = $a")66+ cs[2].Kind = Note67+ order, cycle := Order(cs, []int{0, 1, 2, 3, 4, 5})68+ if !slices.Equal(order, []int{1, 0, 3}) || !slices.Equal(cycle, []int{4, 5}) {69+ t.Errorf("order %v cycle %v", order, cycle)70+ }71+ if got := Dependents(cs, 1); !slices.Equal(got, []int{0, 3}) {72+ t.Errorf("dependents %v", got)73+ }74+ outs := map[int]*Output{2: {NUON: []byte("x")}}75+ if got := Inputs(cs, 3, func(id int) *Output { return outs[id] }); !slices.Equal(got, []int{0}) {76+ t.Errorf("inputs %v", got)77+ }78+ dup := cells("x = ls", "X = ps")79+ if Taken(dup, 1) == "" || Taken(dup, 0) != "" {80+ t.Error("Taken")81+ }82+}83+84+func TestStale(t *testing.T) {85+ cs := cells("files = ls", "big = $files | first", "$big", "ps")86+ outs := map[int]*Output{}87+ out := func(id int) *Output { return outs[id] }88+ run := func(i int, seq int) {89+ o := &Output{NUON: []byte("x"), Seq: seq, Count: seq, Source: cs[i].Source, Reads: map[string]int{}}90+ for _, n := range Refs(cs[i].Pipeline()) {91+ if j, ok := Names(cs)[n]; ok && outs[cs[j].ID] != nil {92+ o.Reads[n] = outs[cs[j].ID].Seq93+ }94+ }95+ outs[cs[i].ID] = o96+ }97+ run(0, 1)98+ run(1, 2)99+ run(2, 3)100+ run(3, 4)101+ if st := Stale(cs, out); len(st) != 0 {102+ t.Fatalf("stale after running all: %v", st)103+ }104+ run(0, 5) // files ran again: big and what reads it are stale105+ if st := Stale(cs, out); !st[2] || !st[3] || st[1] || st[4] {106+ t.Errorf("stale %v", st)107+ }108+ run(1, 6)109+ run(2, 7)110+ cs[3].Source = "ps | first" // edited since it ran111+ if st := Stale(cs, out); len(st) != 1 || !st[4] {112+ t.Errorf("stale after editing %v", st)113+ }114+}115+116+func TestSettleAndKept(t *testing.T) {117+ cs := cells("files = ls", "$files | first", "ps")118+ outs := map[int]*Output{1: {NUON: []byte("12345"), Seq: 1}, 2: {NUON: []byte("123"), Seq: 2}, 3: {NUON: []byte("1234567"), Seq: 3}}119+ Settle(cs, outs)120+ if st := Stale(cs, func(id int) *Output { return outs[id] }); len(st) != 0 {121+ t.Errorf("stale after settling: %v", st)122+ }123+ kept := Kept(cs, func(id int) *Output { return outs[id] }, Caps{Cell: 6, Total: 9})124+ if !kept[1] || !kept[2] || kept[3] {125+ t.Errorf("kept %v", kept)126+ }127+}@@ -0,0 +1,127 @@
1+package notebook2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+)8+9+// What a pipeline reads. A code cell reads another's output as $name,10+// the selection on a sheet as $selection, and a range of a sheet as11+// $sheet.A1:C9 (or $sheet.Sales!A1:C9, $sheet.'Q1 data'!A1:B5). Each12+// reaches nu as a variable holding a table, read from a NUON file, never13+// as text spliced into the pipeline: a $sheet range is renamed to a14+// variable of its own ($__sheet1) before the pipeline runs.15+16+// Selection is the variable that holds the selection.17+const Selection = "selection"18+19+// sheetVar starts a range of a sheet.20+const sheetVar = "$sheet."21+22+// Refs returns the names a pipeline reads as $name, in the order it23+// first names them, leaving out nushell's variables and the notebook's24+// own ($selection, $sheet).25+func Refs(pipeline string) []string {26+ var out []string27+ for i := 0; i < len(pipeline); i++ {28+ if pipeline[i] != '$' {29+ continue30+ }31+ j := i + 132+ for j < len(pipeline) && isWord(pipeline[j]) {33+ j++34+ }35+ name := pipeline[i+1 : j]36+ if name != "" && ValidName(name) == nil && !slices.Contains(out, name) {37+ out = append(out, name)38+ }39+ i = j - 140+ }41+ return out42+}43+44+// ReadsSelection reports whether a pipeline reads $selection.45+func ReadsSelection(pipeline string) bool {46+ for i := 0; ; {47+ k := strings.Index(pipeline[i:], "$"+Selection)48+ if k < 0 {49+ return false50+ }51+ end := i + k + 1 + len(Selection)52+ if end == len(pipeline) || !isWord(pipeline[end]) {53+ return true54+ }55+ i = end56+ }57+}58+59+// SheetRef is a range of a sheet a pipeline reads, $sheet.A1:C9.60+type SheetRef struct {61+ // Ref is the range as written after $sheet.: "A1:C9", "Sales!A1:C9".62+ Ref string63+ // Var is the variable it's read as once renamed: __sheet1.64+ Var string65+}66+67+// Bind renames each range of a sheet the pipeline reads ($sheet.A1:C9)68+// to a variable of its own, returning the pipeline to run and the ranges69+// in the order they appear, the same range read twice once.70+func Bind(pipeline string) (string, []SheetRef) {71+ var b strings.Builder72+ var refs []SheetRef73+ rest := pipeline74+ for {75+ k := strings.Index(rest, sheetVar)76+ if k < 0 || k > 0 && isWord(rest[k-1]) {77+ if k < 0 {78+ b.WriteString(rest)79+ return b.String(), refs80+ }81+ b.WriteString(rest[:k+len(sheetVar)])82+ rest = rest[k+len(sheetVar):]83+ continue84+ }85+ b.WriteString(rest[:k])86+ rest = rest[k+len(sheetVar):]87+ n := refLen(rest)88+ if n == 0 {89+ b.WriteString(sheetVar)90+ continue91+ }92+ ref := rest[:n]93+ rest = rest[n:]94+ i := slices.IndexFunc(refs, func(r SheetRef) bool { return r.Ref == ref })95+ if i < 0 {96+ refs = append(refs, SheetRef{Ref: ref, Var: "__sheet" + strconv.Itoa(len(refs)+1)})97+ i = len(refs) - 198+ }99+ b.WriteString("$" + refs[i].Var)100+ }101+}102+103+// refLen is how long the range at the start of s is: a sheet's name,104+// quoted in ” if it needs to be, then ! and cells, or cells alone. A105+// dot ends it, so a cell path may follow: $sheet.A1:C9.name.106+func refLen(s string) int {107+ i := 0108+ if strings.HasPrefix(s, "'") {109+ for i = 1; i < len(s); i++ {110+ if s[i] == '\'' {111+ if i+1 < len(s) && s[i+1] == '\'' {112+ i++ // '' is a quote in the name113+ continue114+ }115+ break116+ }117+ }118+ if i >= len(s) || i+1 >= len(s) || s[i+1] != '!' {119+ return 0120+ }121+ i += 2122+ }123+ for i < len(s) && (isWord(s[i]) || strings.IndexByte("!:$", s[i]) >= 0) {124+ i++125+ }126+ return i127+}@@ -1,14 +1,13 @@
1-// Package nushell runs a notebook's commands: each command is a nushell2-// pipeline run by `nu` as a separate process, reading other regions'3-// tables as variables ($r1) and the selection as $in, and writing a4-// table back as NUON, which becomes the region's cells. Tables reach nu5-// as NUON in files it opens, never as text spliced into the command.6-// The package knows neither the terminal nor the workbook's regions:7-// the UI hands it a Job and gets back a table.1+// Package nushell runs a notebook's code cells: each is a nushell2+// pipeline run by `nu` as a separate process, reading other cells'3+// outputs and ranges of sheets as variables ($sales, $selection), and4+// printing what it made as NUON, which the notebook keeps as the cell's5+// output. Tables reach nu as NUON in files it opens, never as text6+// spliced into the pipeline. The package knows neither the terminal nor7+// the notebook: the UI hands it a Job and gets back NUON.88 package nushell99 1010 import (11- "bufio"1211 "bytes"1312 "context"1413 "errors"@@ -23,18 +22,15 @@ import (
2322 2423 "github.com/charmbracelet/x/ansi"2524 26- "github.com/FelineStateMachine/012/internal/fileio"27- "github.com/FelineStateMachine/012/internal/sheet"25+ "github.com/FelineStateMachine/012/internal/nuon"2826 )2927 30-// Job is a command to run.28+// Job is a pipeline to run.3129 type Job struct {3230 Command string33- // Tables are the regions the command reads, by name, as NUON: each34- // becomes the variable of its name.31+ // Tables are what the pipeline reads, by variable name, as NUON:32+ // each becomes the variable of its name.3533 Tables map[string][]byte36- // Input is the table the command reads as $in, as NUON, or nil.37- Input []byte3834 // Config runs nu with the user's config files rather than without.3935 Config bool4036 }@@ -45,10 +41,10 @@ type Runner interface {
4541 Run(ctx context.Context, job Job, script string, stdout io.Writer) error4642 }4743 48-// ErrMissing is what running a command says when nu isn't installed.44+// ErrMissing is what running a cell says when nu isn't installed.4945 var ErrMissing = errors.New("nu isn't installed or isn't on your PATH: see docs/nushell/notebooks.md")5046 51-// Error is a command that failed: nu's message, and all it wrote to47+// Error is a pipeline that failed: nu's message, and all it wrote to5248 // standard error.5349 type Error struct {5450 Msg string@@ -57,8 +53,18 @@ type Error struct {
5753 5854 func (e *Error) Error() string { return e.Msg }5955 56+// Help is the help line of nu's message, "help: ...", or "".57+func (e *Error) Help() string {58+ for line := range strings.SplitSeq(e.Stderr, "\n") {59+ if line = strings.TrimSpace(line); strings.HasPrefix(line, "help:") {60+ return line61+ }62+ }63+ return ""64+}65+6066 // Script is what nu runs for job: the tables bound to their names, then61-// the command, its output as NUON. The files are named by environment67+// the pipeline, its output as NUON. The files are named by environment6268 // variables (NU012_TABLE_0 and so on) the runner sets, so no data and no6369 // path is written into the script.6470 func Script(job Job, names []string) string {@@ -66,80 +72,47 @@ func Script(job Job, names []string) string {
6672 for i, name := range names {6773 fmt.Fprintf(&b, "let %s = (open --raw $env.NU012_TABLE_%d | from nuon)\n", name, i)6874 }69- if job.Input != nil {70- b.WriteString("$in | from nuon | ")71- }7275 b.WriteString("do {\n" + job.Command + "\n} | to nuon\n")7376 return b.String()7477 }7578 76-// Exec runs job and reads what it prints as a table, keeping at most77-// maxCells cells (0 for the max-cells setting), within timeout.78-func Exec(ctx context.Context, r Runner, job Job, timeout time.Duration, maxCells int) (*sheet.RegionData, error) {79+// ErrTooLarge is a run that printed more than it may keep.80+var ErrTooLarge = errors.New("the output is too large to keep")81+82+// Exec runs job and returns what it printed, NUON, at most maxBytes of83+// it, within timeout.84+func Exec(ctx context.Context, r Runner, job Job, timeout time.Duration, maxBytes int) ([]byte, error) {7985 if timeout > 0 {8086 var cancel context.CancelFunc8187 ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("stopped after %s", timeout))8288 defer cancel()8389 }84- names := sortedNames(job.Tables)85- pr, pw := io.Pipe()86- done := make(chan error, 1)87- go func() {88- err := r.Run(ctx, job, Script(job, names), pw)89- pw.CloseWithError(err)90- done <- err91- }()92- data, readErr := readTable(ctx, pr, maxCells)93- pr.CloseWithError(errors.New("done reading")) // the command stops if it's still writing94- runErr := <-done90+ ctx, cancel := context.WithCancelCause(ctx)91+ defer cancel(nil)92+ out := &capped{max: maxBytes, stop: func() { cancel(ErrTooLarge) }}93+ err := r.Run(ctx, job, Script(job, sortedNames(job.Tables)), out)9594 switch {9695 case ctx.Err() != nil:9796 return nil, context.Cause(ctx)98- case runErr != nil:99- return nil, runErr100- case readErr != nil:101- return nil, fmt.Errorf("nu's output isn't NUON: %w", readErr)97+ case err != nil:98+ return nil, err10299 }103- return data, nil100+ return bytes.TrimSpace(out.b.Bytes()), nil104101 }105102 106-// readTable reads NUON as a region's table. A value that isn't a table,107-// record or list (a number, a string, null) is read as a list of one,108-// a table of one column named value.109-func readTable(ctx context.Context, r io.Reader, maxCells int) (*sheet.RegionData, error) {110- br := bufio.NewReader(r)111- first, err := firstByte(br)112- if err != nil {113- return nil, err114- }115- in := io.Reader(br)116- if first != '[' && first != '{' {117- in = io.MultiReader(strings.NewReader("["), br, strings.NewReader("]"))118- }119- res, err := fileio.ImportReader(ctx, "nu", in, fileio.Options{MaxCells: maxCells})120- if err != nil {121- return nil, err122- }123- d := sheet.RegionDataOf(res.Sheet)124- d.Note = strings.Join(res.Notes, "; ")125- return d, nil103+// capped keeps at most max bytes, stopping the run past them.104+type capped struct {105+ b bytes.Buffer106+ max int107+ stop func()126108 }127109 128-// firstByte is the first byte past spaces, without taking it; 0 when129-// there's nothing else.130-func firstByte(br *bufio.Reader) (byte, error) {131- for {132- c, err := br.ReadByte()133- switch {134- case errors.Is(err, io.EOF):135- return 0, nil136- case err != nil:137- return 0, err138- case c == ' ' || c == '\t' || c == '\n' || c == '\r':139- continue140- }141- return c, br.UnreadByte()110+func (c *capped) Write(p []byte) (int, error) {111+ if c.max > 0 && c.b.Len()+len(p) > c.max {112+ c.stop()113+ return 0, ErrTooLarge142114 }115+ return c.b.Write(p)143116 }144117 145118 func sortedNames(tables map[string][]byte) []string {@@ -153,14 +126,18 @@ func sortedNames(tables map[string][]byte) []string {
153126 154127 // Commands lists nu's command names, for completing them.155128 func Commands(ctx context.Context, r Runner, timeout time.Duration) ([]string, error) {156- d, err := Exec(ctx, r, Job{Command: "help commands | get name"}, timeout, -1)129+ data, err := Exec(ctx, r, Job{Command: "help commands | get name"}, timeout, 0)130+ if err != nil {131+ return nil, err132+ }133+ v, err := nuon.Parse(data)157134 if err != nil {158135 return nil, err159136 }160137 var out []string161- for row := 1; row < d.Rows; row++ {162- if v, _ := d.At(row, 0); v.Kind == sheet.Text {163- out = append(out, v.Str)138+ for _, n := range v.List {139+ if n.Kind == nuon.String {140+ out = append(out, n.Str)164141 }165142 }166143 return out, nil@@ -198,15 +175,9 @@ func (n Nu) Run(ctx context.Context, job Job, script string, stdout io.Writer) e
198175 if !job.Config {199176 args = append([]string{"--no-config-file"}, args...)200177 }201- if job.Input != nil {202- args = append([]string{"--stdin"}, args...)203- }204178 cmd := exec.CommandContext(ctx, path, args...)205179 cmd.Env, cmd.Stdout = env, stdout206180 cmd.WaitDelay = 2 * time.Second207- if job.Input != nil {208- cmd.Stdin = bytes.NewReader(job.Input)209- }210181 var stderr bytes.Buffer211182 cmd.Stderr = &limited{w: &stderr, n: 64 << 10}212183 if err := cmd.Run(); err != nil {@@ -8,8 +8,6 @@ import (
88 "strings"99 "testing"1010 "time"11-12- "github.com/FelineStateMachine/012/internal/sheet"1311 )1412 1513 // fake prints out, or fails with err, and keeps the script it was given.@@ -29,71 +27,29 @@ func (f *fake) Run(ctx context.Context, job Job, script string, stdout io.Writer
2927 return err3028 }3129 32-func cells(d *sheet.RegionData) [][]string {33- var out [][]string34- for r := range d.Rows {35- var row []string36- for c := range d.Cols {37- v, f := d.At(r, c)38- row = append(row, sheet.FormatText(v, f))39- }40- out = append(out, row)41- }42- return out43-}44-4530 func TestScript(t *testing.T) {46- got := Script(Job{Command: "$r1 | where size > 1kb", Tables: map[string][]byte{"r1": nil}, Input: []byte("[]")}, []string{"big", "r1"})31+ got := Script(Job{Command: "$r1 | where size > 1kb", Tables: map[string][]byte{"r1": nil}}, []string{"big", "r1"})4732 want := "let big = (open --raw $env.NU012_TABLE_0 | from nuon)\n" +4833 "let r1 = (open --raw $env.NU012_TABLE_1 | from nuon)\n" +49- "$in | from nuon | do {\n$r1 | where size > 1kb\n} | to nuon\n"34+ "do {\n$r1 | where size > 1kb\n} | to nuon\n"5035 if got != want {5136 t.Errorf("script:\n%s\nwant:\n%s", got, want)5237 }5338 }5439 55-func TestExecReadsTables(t *testing.T) {56- for _, c := range []struct {57- out string58- want string59- }{60- {"[[name, size]; [a, 1kb], [b, 2b]]", "name size|a 1.0 kB|b 2 B"},61- {"{a: 1, b: true}", "a b|1 TRUE"},62- {"3", "value|3"},63- {`"hello"`, "value|hello"},64- {"null", "value"},65- {"[1, 2]", "value|1|2"},66- {"", ""},67- } {68- d, err := Exec(context.Background(), &fake{out: c.out}, Job{Command: "x"}, time.Second, 0)69- if err != nil {70- t.Errorf("%q: %v", c.out, err)71- continue72- }73- var rows []string74- for _, r := range cells(d) {75- rows = append(rows, strings.TrimSpace(strings.Join(r, " ")))76- }77- if got := strings.Join(rows, "|"); got != c.want {78- t.Errorf("%q read as %q, want %q", c.out, got, c.want)79- }40+func TestExec(t *testing.T) {41+ out, err := Exec(context.Background(), &fake{out: " [[name, size]; [a, 1kb]]\n"}, Job{Command: "x"}, time.Second, 0)42+ if err != nil || string(out) != "[[name, size]; [a, 1kb]]" {43+ t.Errorf("%q %v", out, err)8044 }81-}82-83-func TestExecFails(t *testing.T) {84- _, err := Exec(context.Background(), &fake{err: &Error{Msg: "Cannot find column 'x'"}}, Job{Command: "x"}, time.Second, 0)45+ _, err = Exec(context.Background(), &fake{err: &Error{Msg: "Cannot find column 'x'", Stderr: " × Cannot find column 'x'\n help: try get\n"}}, Job{Command: "x"}, time.Second, 0)8546 var e *Error86- if !errors.As(err, &e) || e.Msg != "Cannot find column 'x'" {47+ if !errors.As(err, &e) || e.Msg != "Cannot find column 'x'" || e.Help() != "help: try get" {8748 t.Errorf("error %v", err)8849 }89- _, err = Exec(context.Background(), &fake{out: "[[a]; [1"}, Job{Command: "x"}, time.Second, 0)90- if err == nil || !strings.Contains(err.Error(), "isn't NUON") {91- t.Errorf("bad output: %v", err)92- }93- // Past max-cells, whole rows are kept and the note says so.94- d, err := Exec(context.Background(), &fake{out: "[[a, b]; [1, 2], [3, 4], [5, 6]]"}, Job{Command: "x"}, time.Second, 4)95- if err != nil || d.Rows != 2 || d.Note == "" {96- t.Errorf("capped: %+v %v", d, err)50+ _, err = Exec(context.Background(), &fake{out: strings.Repeat("x", 100)}, Job{Command: "x"}, time.Second, 10)51+ if !errors.Is(err, ErrTooLarge) {52+ t.Errorf("too large: %v", err)9753 }9854 }9955 @@ -107,24 +63,25 @@ func TestMessage(t *testing.T) {
10763 }10864 }10965 110-// The real nu, when it's installed: tables go in as variables and $in,111-// and come back typed.66+// The real nu, when it's installed: tables go in as variables and come67+// back as NUON.11268 func TestNu(t *testing.T) {11369 if _, err := exec.LookPath("nu"); err != nil {11470 t.Skip("nu isn't installed")11571 }11672 job := Job{117- Command: "$in | append ($r1 | where size > 1kb)",118- Tables: map[string][]byte{"r1": []byte("[[name, size]; [a, 2kb], [b, 10b]]")},119- Input: []byte("[[name, size]; [c, 5mb]]"),73+ Command: "$r1 | where size > 1kb | append $__sheet1",74+ Tables: map[string][]byte{"r1": []byte("[[name, size]; [a, 2kb], [b, 10b]]"), "__sheet1": []byte("[[name, size]; [c, 5mb]]")},12075 }121- d, err := Exec(context.Background(), Nu{}, job, 30*time.Second, 0)76+ out, err := Exec(context.Background(), Nu{}, job, 30*time.Second, 0)12277 if err != nil {12378 t.Fatal(err)12479 }125- got := cells(d)126- if len(got) != 3 || got[1][0] != "c" || got[1][1] != "5.0 MB" || got[2][0] != "a" || got[2][1] != "2.0 kB" {127- t.Errorf("table %v", got)80+ if got := string(out); got != "[[name, size]; [a, 2000b], [c, 5000000b]]" && got != "[[name, size]; [a, 2kB], [c, 5MB]]" {81+ t.Logf("output %s", got) // nu's own spelling of sizes varies by version82+ if !strings.Contains(got, "a") || !strings.Contains(got, "c") || strings.Contains(got, "b,") {83+ t.Errorf("output %s", got)84+ }12885 }12986 _, err = Exec(context.Background(), Nu{}, Job{Command: "ls | get nosuchcolumn"}, 30*time.Second, 0)13087 var e *Error@@ -56,12 +56,10 @@ type fileFormat struct {
5656 Locale string `json:"locale,omitempty"`5757 // Macros and where they were made need no version bump either:5858 // earlier builds ignore them, and the sheets read the same.59- MacroOrigin string `json:"macroOrigin,omitempty"`60- // ShellHistory needs no version either: see shellhistory.go.61- ShellHistory []string `json:"shellHistory,omitempty"`62- Macros []fileMacro `json:"macros,omitempty"`63- fileSheet // versions 1 to 3: the only sheet64- Sheets []fileSheet `json:"sheets,omitempty"` // version 459+ MacroOrigin string `json:"macroOrigin,omitempty"`60+ Macros []fileMacro `json:"macros,omitempty"`61+ fileSheet // versions 1 to 3: the only sheet62+ Sheets []fileSheet `json:"sheets,omitempty"` // version 46563 }6664 6765 // fileSheet is one sheet of a file.@@ -80,9 +78,12 @@ type fileSheet struct {
8078 // Rules need no version: see rulefile.go.8179 CondFormats []fileCondFormat `json:"conditionalFormats,omitempty"`8280 Validations []fileValidation `json:"validations,omitempty"`83- // A notebook and its regions need no version: see regionfile.go.84- Notebook bool `json:"notebook,omitempty"`85- Regions []fileRegion `json:"regions,omitempty"`81+ // Regions need no version: see regionfile.go.82+ Regions []fileRegion `json:"regions,omitempty"`83+ // Nor do notebook tabs: see notebookfile.go.84+ Tab string `json:"tab,omitempty"`85+ Reactive bool `json:"reactive,omitempty"`86+ NotebookCells []fileNotebookCell `json:"notebookCells,omitempty"`8687 }8788 8889 // Write saves the workbook the sheet belongs to; see Workbook.Write.@@ -141,9 +142,10 @@ func (w *Workbook) Write(out io.Writer) error {
141142 }142143 143144 // single reports whether the workbook fits the single-sheet format of144-// versions 2 and 3: one sheet, and no formula naming a sheet.145+// versions 2 and 3: one sheet, not a notebook, and no formula naming a146+// sheet.145147 func (w *Workbook) single() bool {146- return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots()148+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook()147149 }148150 149151 func jsonString(s string) string {@@ -164,16 +166,12 @@ func (w *Workbook) headLines() string {
164166 if w.locale != nil {165167 lines = append(lines, `"locale": `+jsonString(w.locale.Tag))166168 }167- if w.macroOrigin != "" && (len(w.macros) > 0 || len(w.allRegions()) > 0) {169+ if w.macroOrigin != "" && (len(w.macros) > 0 || len(w.allRegions()) > 0 || w.hasNotebook()) {168170 lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))169171 }170172 if len(w.macros) > 0 {171173 lines = append(lines, w.macrosLines())172174 }173- if len(w.shellHistory) > 0 {174- raw, _ := json.Marshal(w.shellHistory)175- lines = append(lines, `"shellHistory": `+string(raw))176- }177175 return strings.Join(lines, ",\n ")178176 }179177 @@ -275,7 +273,10 @@ func (s *Sheet) writeObjects(b *bufio.Writer, indent string) error {
275273 fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)276274 }277275 s.writeRules(b, indent)278- return s.writeRegionDefs(b, indent)276+ if err := s.writeRegionDefs(b, indent); err != nil {277+ return err278+ }279+ return s.writeNotebook(b, indent)279280 }280281 281282 // Read loads a file written by Write, of this or an earlier version, and@@ -326,8 +327,8 @@ func (w *Workbook) readNames(names map[string]string) error {
326327 }327328 328329 // read fills a sheet, its cells read (fileread.go), from the rest of329-// its part of a file.330-func (s *Sheet) read(f fileSheet) error {330+// its part of a file, adding to old the command regions to convert.331+func (s *Sheet) read(f fileSheet, old *[]oldRegion) error {331332 s.tabHidden = f.Hidden332333 for name, width := range f.Widths {333334 c, ok := ParseCol(name)@@ -363,7 +364,10 @@ func (s *Sheet) read(f fileSheet) error {
363364 if err := s.readRules(f.CondFormats, f.Validations); err != nil {364365 return err365366 }366- if err := s.readRegions(f.Notebook, f.Regions); err != nil {367+ if err := s.readRegions(f.Regions, old); err != nil {368+ return err369+ }370+ if err := s.readNotebook(f); err != nil {367371 return err368372 }369373 return s.readView(f.fileView)@@ -88,8 +88,9 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
8888 if err := w.readNames(f.Names); err != nil {8989 return nil, err9090 }91+ var old []oldRegion9192 for _, rs := range list {92- if err := rs.s.read(rs.body); err != nil {93+ if err := rs.s.read(rs.body, &old); err != nil {9394 if len(list) > 1 {9495 err = fmt.Errorf("sheet %s: %w", rs.body.Name, err)9596 }@@ -109,7 +110,8 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
109110 return nil, err110111 }111112 w.macroOrigin = f.MacroOrigin112- w.readShellHistory(f.ShellHistory)113+ w.convertOld(old)114+ w.nb.changed = 0 // the outputs read are the file's113115 w.RecalcAll()114116 return w, nil115117 }@@ -43,6 +43,7 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
4343 if err := w.readNames(f.Names); err != nil {4444 return nil, false, err4545 }46+ var old []oldRegion4647 for i, body := range bodies {4748 s := w.sheets[i]4849 amb, err := placeWhole(s, body.Cells, f.Version)@@ -50,7 +51,7 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
5051 return nil, false, err5152 }5253 ambiguous = ambiguous || amb53- if err := s.read(body); err != nil {54+ if err := s.read(body, &old); err != nil {5455 return nil, false, err5556 }5657 }@@ -64,7 +65,8 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
6465 return nil, false, err6566 }6667 w.macroOrigin = f.MacroOrigin67- w.readShellHistory(f.ShellHistory)68+ w.convertOld(old)69+ w.nb.changed = 06870 w.RecalcAll()6971 return w, ambiguous, nil7072 }@@ -243,6 +245,14 @@ func streamSeeds(t testing.TB) [][]byte {
243245 []byte(`{"version": 2, "regions": [{"name": "r1", "command": "ls", "at": "A1"}, {"name": "R1", "command": "x", "at": "A5"}]}`),244246 []byte(`{"version": 2, "regions": [{"name": "in", "command": "ls", "at": "A1"}, {"name": "r2", "at": "ZZZZ1", "rows": -1}]}`),245247 []byte(`{"version": 2, "regions": [{"name": "a", "command": "$b", "at": "A1", "reads": ["b"]}, {"name": "b", "command": "$a", "at": "A3", "reads": ["a"], "sort": [{"column": 0}]}]}`),248+ []byte(`{"version": 4, "sheets": [{"name": "S", "cells": {}, "regions": [{"name": "files", "at": "B2", "output": true}]},249+ {"name": "Notebook", "tab": "notebook", "reactive": true, "cells": {}, "notebookCells": [250+ {"kind": "note", "source": "# Files"}, {"source": "files = ls", "output": "[[name, size]; [a, 2kb]]"},251+ {"source": "$files | nope", "error": "Command not found", "detail": "help: ls", "note": "x"}, {"source": "ls **/*", "unsaved": true}]}]}`),252+ []byte(`{"version": 4, "sheets": [{"name": "N", "tab": "notebook", "cells": {}, "notebookCells": [{"kind": "table", "source": "x"}]}]}`),253+ []byte(`{"version": 4, "sheets": [{"name": "N", "cells": {}, "notebookCells": [{"source": "x"}]}]}`),254+ []byte(`{"version": 2, "cells": {}, "regions": [{"name": "o", "at": "A1", "output": true, "command": "ls"}, {"name": "p", "at": "A1", "output": true, "path": "p"}]}`),255+ []byte(`{"version": 4, "sheets": [{"name": "Notebook", "tab": "notebook", "cells": {}}, {"name": "Shell 1", "notebook": true, "cells": {}, "regions": [{"name": "r1", "command": "ls", "at": "A1"}]}]}`),246256 []byte(`null`), []byte(`[]`), []byte(`{"version": 2, "cells": {"A1": "1",}}`), []byte(`{"version": 2, "cells": {"A1"`),247257 }248258 }@@ -9,10 +9,9 @@ import (
99 )1010 1111 // Linked files: regions (region.go) whose rows come from a file the UI12-// follows as it grows or is rewritten. A linked file has no label line:13-// its anchor is its table's first cell, the file's first row, which14-// stays; the rows under it are the file's, all of them up to max-cells,15-// or the last Window of them. Its rows arrive only as live operations12+// follows as it grows or is rewritten. Its anchor is its table's first13+// cell, the file's first row, which stays; the rows under it are the14+// file's, all of them up to max-cells, or the last Window of them. Its rows arrive only as live operations1615 // (live.go) and are never part of the undo state (see region.go for1716 // why), so a linked file is read again whenever its cells may no longer1817 // be the file's: undo put it back, it moved, or a cell blocking it was@@ -88,7 +87,7 @@ func (s *Sheet) linkedInfo(r Region) LinkedRegion {
8887 8988 // LinkedAt returns the linked file covering the cell at a.9089 func (s *Sheet) LinkedAt(a Addr) (LinkedRegion, bool) {91- if r, _, ok := s.RegionAt(a); ok && r.Linked() {90+ if r, ok := s.RegionAt(a); ok && r.Linked() {9291 return s.linkedInfo(r), true9392 }9493 return LinkedRegion{}, false@@ -138,20 +137,12 @@ var errLinkOver = errors.New("A linked file needs an empty cell to start in")
138137 139138 // AddLinked makes a linked file at a reading src, named after the file,140139 // as one undo step, and returns its name. It starts empty and stale: the141-// UI reads the file and sends its rows. On a notebook sheet it goes142-// below everything, as a command region does.140+// UI reads the file and sends its rows.143141 func (s *Sheet) AddLinked(a Addr, src LinkSource) (string, error) {144- if !a.Valid() || s.cells.filledAt(a) || s.InPivot(Rect{From: a, To: a}) {145- return "", errLinkOver146- }147- if _, _, ok := s.RegionAt(a); ok {148- return "", errLinkOver149- }150142 name := s.wb.linkName(src.Path)151143 if err := s.AddRegion(Region{Name: name, At: a, File: src}); err != nil {152144 return "", err153145 }154- s.meta(nameKey(name)).stale = true155146 return name, nil156147 }157148 @@ -175,29 +166,12 @@ func (w *Workbook) linkName(path string) string {
175166 if ValidRegionName(name) != nil {176167 name = "file_" + name177168 }178- for n := 2; w.checkRegionName(name) != nil; n++ {169+ for n := 2; w.checkRegionName(name, false) != nil; n++ {179170 name = strings.TrimSuffix(name, "_"+strconv.Itoa(n-1)) + "_" + strconv.Itoa(n)180171 }181172 return name182173 }183174 184-// putRegionsBack puts back a sheet's regions from an undo step. A185-// linked file the step brings back, or moves, is emptied and read again186-// once the change ends: its rows aren't part of the undo state, so the187-// cells undo put there may not be the file's.188-func (s *Sheet) putRegionsBack(st regionState) {189- for _, r := range st.list {190- if !r.Linked() {191- continue192- }193- i := s.regionIndex(nameKey(r.Name))194- if i < 0 || s.regions.list[i].At != r.At || s.regions.list[i].File != r.File {195- s.meta(nameKey(r.Name)).reread = true196- }197- }198- s.regions, s.regionsStale = st, true199-}200-201175 // SetLinkSource changes what a linked file reads (its window, say), as202176 // one undo step; it is read again.203177 func (w *Workbook) SetLinkSource(name string, src LinkSource) error {@@ -1,30 +1,19 @@
11 package sheet22 3-import (4- "slices"5- "testing"6-)3+import "testing"74 8-// Linked files and command regions are one set: a formula names a9-// linked file as nu.name, a command reads it as $name, and refreshing it10-// runs what reads it, while the linked file itself never runs.5+// Linked files and sent outputs are one set: a formula names either as6+// nu.name, and their names are one namespace.117 func TestLinkedAmongRegions(t *testing.T) {12- s := notebook(t)8+ s := New()139 w := s.Book()1410 id, err := s.AddLinked(Addr{}, LinkSource{Path: "logs/app-2026.csv"})1511 if err != nil || id != "app_2026" {1612 t.Fatalf("name %q, %v", id, err)1713 }1814 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("2"), liveRow("3")}})19- addShown(t, s, "r1", "$app_2026 | first", table([]string{"n"}, []string{"2"}))20- if _, r, _ := w.Region("r1"); !slices.Equal(r.Deps, []string{"app_2026"}) {21- t.Fatalf("r1 reads %v", r.Deps)22- }23- // Stacked on a notebook, the command region goes below the file.24- if _, r, _ := w.Region("r1"); r.At.Row <= 2 {25- t.Errorf("r1 at %v, over the linked file", r.At)26- }27- other, _ := w.AddSheet("Sheet2", 1)15+ sent(t, s, "r1", "D1")16+ other, _ := w.AddSheet("Sheet2", 2)2817 other.Set(Addr{}, "=SUM(nu.app_2026)")2918 if v := other.Value(Addr{}).Num; v != 5 {3019 t.Fatalf("SUM(nu.app_2026) = %v", other.Value(Addr{}))@@ -33,29 +22,21 @@ func TestLinkedAmongRegions(t *testing.T) {
3322 if v := other.Value(Addr{}).Num; v != 9 {3423 t.Fatalf("after a row arrived, SUM = %v", v)3524 }36- if got := w.RunOrder(); !slices.Equal(got, []string{"r1"}) {37- t.Errorf("run all: %v", got)38- }39- if got := w.RefreshOrder(id); !slices.Equal(got, []string{"r1"}) {40- t.Errorf("refreshing the linked file runs %v", got)41- }42- if err := s.EditRegion(id, "ls", nil, ""); err == nil {43- t.Error("a linked file took a command")44- }45- if err := s.ShowRegion(id, table([]string{"x"})); err == nil {46- t.Error("a linked file was shown a command's table")47- }4825 if got := w.linkName("logs/app-2026.csv"); got != "app_2026_2" {4926 t.Errorf("a second link to the file is named %q", got)5027 }5128 if got := w.linkName("9.csv"); got != "file_9" {5229 t.Errorf("a file named 9 is %q", got)5330 }31+ if got := w.linkName("r1.csv"); got != "r1_2" {32+ t.Errorf("a file named like a cell is %q", got)33+ }34+ if len(w.LinkedRegions()) != 1 {35+ t.Errorf("linked regions %+v", w.LinkedRegions())36+ }5437 }5538 56-// A linked file's rows are applied as ops outside the undo history,57-// while a command region's run is an undo step: the policies region.go58-// gives the two kinds.39+// Rows arriving are never undo steps, for either kind.5940 func TestRegionKindsUndo(t *testing.T) {6041 s := New()6142 id, _ := s.AddLinked(Addr{}, LinkSource{Path: "log.csv"})@@ -63,11 +44,11 @@ func TestRegionKindsUndo(t *testing.T) {
6344 if s.Book().UndoLabel() != "add log" {6445 t.Errorf("rows arriving made a step: %q", s.Book().UndoLabel())6546 }66- s.AddRegion(Region{Name: "r1", Command: "ls", At: Addr{Col: 3}})67- s.ShowRegion("r1", table([]string{"n"}, []string{"7"}))68- if s.Book().UndoLabel() != "run r1" {69- t.Errorf("a run isn't a step: %q", s.Book().UndoLabel())47+ sent(t, s, "r1", "D1")48+ apply(t, s, LiveOp{Region: "r1", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("7")}})49+ if s.Book().UndoLabel() != "add r1" {50+ t.Errorf("an output arriving made a step: %q", s.Book().UndoLabel())7051 }7152 s.Book().Undo()72- wantShown(t, s, map[string]string{"D1": "r1 ls not run", "D3": "", "A2": "1"})53+ wantShown(t, s, map[string]string{"D1": "", "D2": "", "A2": "1"})7354 }@@ -16,8 +16,8 @@ import (
1616 // depends on nothing but the region: the same op applied to the same1717 // workbook elsewhere makes the same cells, which is what a session1818 // following another's would be sent. Whatever reads a linked file sends19-// them; a command region's table is written through the same path20-// (writeTable), from its RegionData.19+// them, and the UI sends a notebook cell's output to the region it was20+// sent to whenever the cell runs.2121 2222 // LiveCell is a value as a source gave it, with its format (a date's,2323 // a file size's), Automatic for none.@@ -65,9 +65,7 @@ var OnLive func(trace any, i LiveInfo)
6565 6666 // ApplyLive applies op to its region: its rows are written, a window6767 // drops the oldest, and what reads the cells that changed recalculates.68-// A command region's table so written lasts until the region shows its69-// table again (ShowRegion). It returns ErrNoRegion for a region the70-// workbook doesn't hold.68+// It returns ErrNoRegion for a region the workbook doesn't hold.7169 func (w *Workbook) ApplyLive(op LiveOp) error {7270 s, r, ok := w.Region(op.Region)7371 if !ok {@@ -172,7 +170,7 @@ func (s *Sheet) liveRows(r Region, me *regionMeta, op LiveOp) (rows []LiveRow, h
172170 // readRow reads row i of the region's table (0 for the header) back173171 // from its cells.174172 func (s *Sheet) readRow(r Region, me *regionMeta, i int) LiveRow {175- o := tableOrigin(r)173+ o := r.At176174 row := make(LiveRow, me.cols)177175 for c := range me.cols {178176 v, lk, kind := s.cells.derivedOf(Addr{Col: o.Col + c, Row: o.Row + i})@@ -0,0 +1,229 @@
1+package sheet2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+)10+11+// Notebook tabs: a sheet of the workbook that holds a notebook's cells12+// rather than a grid (package notebook). The tab sits among the sheets,13+// is named, moved, hidden and deleted as a sheet is, and its cells are14+// part of the undo state (regionState, region.go), so adding, editing,15+// moving and deleting cells undo as any change does. What running a cell16+// left, its output, isn't: a run can't be taken back, as a linked file's17+// rows can't. Outputs are kept by the cell's ID in the workbook, and18+// saved with it up to the caps (notebookfile.go).19+20+// notebookState is the workbook's side of its notebooks: the outputs,21+// and the counters that make IDs and Seqs.22+type notebookState struct {23+ outputs map[int]*notebook.Output24+ lastID int25+ lastSeq int26+ // changed counts changes to outputs, which the modified flag27+ // follows as it follows the undo history.28+ changed int29+ caps notebook.Caps30+ // notes say what opening the file did that the user should know:31+ // the notebook sheets it converted.32+ notes []string33+}34+35+// IsNotebook reports whether the sheet is a notebook tab.36+func (s *Sheet) IsNotebook() bool { return s.regions.notebook }37+38+// MakeNotebook turns an empty sheet into a notebook tab, without an undo39+// step, as 012 nu does with a new file's sheet.40+func (s *Sheet) MakeNotebook() { s.regions.notebook = true }41+42+// AddNotebook adds a notebook tab named name ("" for Notebook, then43+// Notebook 2 and so on) after the sheet after, as one undo step.44+func (w *Workbook) AddNotebook(name string, after *Sheet) (*Sheet, error) {45+ if name == "" {46+ name = w.freeName("Notebook")47+ }48+ if err := w.checkName(nil, name); err != nil {49+ return nil, err50+ }51+ s := w.newSheet(name)52+ s.regions.notebook = true53+ at := len(w.sheets)54+ if i := w.Index(after); i >= 0 {55+ at = i + 156+ }57+ w.change(s, "insert notebook "+name, Rect{}, func() {58+ w.recordSheets()59+ w.insert(s, at)60+ })61+ return s, nil62+}63+64+// Notebook is the workbook's first notebook tab, or nil.65+func (w *Workbook) Notebook() *Sheet {66+ for _, s := range w.sheets {67+ if s.IsNotebook() {68+ return s69+ }70+ }71+ return nil72+}73+74+// NotebookCells returns a notebook tab's cells.75+func (s *Sheet) NotebookCells() []notebook.Cell { return slices.Clone(s.regions.cells) }76+77+// SetNotebookCells replaces a notebook tab's cells as one undo step78+// described by label ("add cell"). Cells without an ID are given one.79+func (s *Sheet) SetNotebookCells(label string, cells []notebook.Cell) {80+ cells = slices.Clone(cells)81+ for i := range cells {82+ if cells[i].ID == 0 {83+ cells[i].ID = s.wb.NewCellID()84+ }85+ }86+ if slices.Equal(cells, s.regions.cells) {87+ return88+ }89+ s.change(label, Rect{}, func() {90+ st := s.regionsCopy()91+ st.cells = cells92+ s.putRegions(st)93+ })94+}95+96+// NewCellID is an ID no cell of the workbook has had.97+func (w *Workbook) NewCellID() int {98+ w.nb.lastID++99+ return w.nb.lastID100+}101+102+// Reactive reports whether the notebook runs the cells reading a cell103+// again whenever it runs.104+func (s *Sheet) Reactive() bool { return s.regions.reactive }105+106+// SetReactive turns the notebook's reactive setting on or off, as one107+// undo step.108+func (s *Sheet) SetReactive(on bool) {109+ if s.regions.reactive == on {110+ return111+ }112+ s.change("reactive notebook", Rect{}, func() {113+ st := s.regionsCopy()114+ st.reactive = on115+ s.putRegions(st)116+ })117+}118+119+// Output is what the cell with the ID id left when it last ran, or nil.120+func (w *Workbook) Output(id int) *notebook.Output { return w.nb.outputs[id] }121+122+// SetOutput keeps o as what the cell with the ID id left, nil for123+// nothing, giving it a Seq of its own. It isn't an undo step.124+func (w *Workbook) SetOutput(id int, o *notebook.Output) {125+ w.nb.changed++126+ if o == nil {127+ delete(w.nb.outputs, id)128+ return129+ }130+ cp := *o131+ w.nb.lastSeq++132+ cp.Seq = w.nb.lastSeq133+ if w.nb.outputs == nil {134+ w.nb.outputs = map[int]*notebook.Output{}135+ }136+ w.nb.outputs[id] = &cp137+}138+139+// OutputsChanged counts changes to outputs, for the modified flag.140+func (w *Workbook) OutputsChanged() int { return w.nb.changed }141+142+// SetOutputCaps sets how much of the outputs a file keeps.143+func (w *Workbook) SetOutputCaps(c notebook.Caps) { w.nb.caps = c }144+145+// OutputCaps are how much of the outputs a file keeps.146+func (w *Workbook) OutputCaps() notebook.Caps {147+ if w.nb.caps == (notebook.Caps{}) {148+ return notebook.DefaultCaps149+ }150+ return w.nb.caps151+}152+153+// LoadNotes say what opening the file changed that the user should154+// know, such as notebook sheets converted.155+func (w *Workbook) LoadNotes() []string { return slices.Clone(w.nb.notes) }156+157+// cellNamed reports whether a notebook's cell is named name, ignoring158+// case.159+func (w *Workbook) cellNamed(name string) bool {160+ for _, s := range w.sheets {161+ for _, c := range s.regions.cells {162+ if strings.EqualFold(c.Name(), name) {163+ return true164+ }165+ }166+ }167+ return false168+}169+170+// hasNotebook reports whether the workbook has a notebook tab.171+func (w *Workbook) hasNotebook() bool { return w.Notebook() != nil }172+173+// nextNotebookCellName is a name for a cell no cell and no region has:174+// cell1, cell2 and so on.175+func (w *Workbook) nextNotebookCellName() string {176+ for n := 1; ; n++ {177+ name := "cell" + strconv.Itoa(n)178+ if w.checkRegionName(name, false) == nil {179+ return name180+ }181+ }182+}183+184+// FreeCellName is a name like base that no cell and no region of the185+// workbook has: base, then base_2 and so on; cell1 and on for "".186+func (w *Workbook) FreeCellName(base string) string {187+ if base == "" || ValidRegionName(base) != nil {188+ return w.nextNotebookCellName()189+ }190+ name := base191+ for n := 2; w.checkRegionName(name, false) != nil; n++ {192+ name = base + "_" + strconv.Itoa(n)193+ }194+ return name195+}196+197+// StaleOutputs names the outputs sent to sheets that are to be sent198+// again: new, put back by undo, moved, or no longer blocked.199+func (w *Workbook) StaleOutputs() []string {200+ var out []string201+ for _, s := range w.sheets {202+ for _, r := range s.regions.list {203+ if me := s.regionMeta[nameKey(r.Name)]; r.Output && (me == nil || me.stale || me.reread) {204+ out = append(out, r.Name)205+ }206+ }207+ }208+ return out209+}210+211+// RenameRegion gives a sent output its cell's new name, as one undo212+// step; it is sent again.213+func (w *Workbook) RenameRegion(old, name string) error {214+ s, r, ok := w.Region(old)215+ if !ok {216+ return ErrNoRegion217+ }218+ if err := w.checkRegionName(name, r.Output); err != nil && nameKey(old) != nameKey(name) {219+ return err220+ }221+ k := nameKey(r.Name)222+ s.change("rename "+r.Name, s.covered(r), func() {223+ st := s.regionsCopy()224+ st.list[s.regionIndex(k)].Name = name225+ s.putRegions(st)226+ })227+ s.meta(nameKey(name)).stale = true228+ return nil229+}@@ -0,0 +1,201 @@
1+package sheet2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// A notebook tab in the file: a sheet marked "tab": "notebook", its13+// cells one per line, each with its output as NUON text when the caps14+// (OutputCaps) keep it, or "unsaved" when they don't. None of it needs a15+// version: earlier builds ignore the fields and open the tab as an16+// empty sheet.17+//18+// "tab": "notebook",19+// "reactive": true,20+// "notebookCells": [21+// {"kind": "note", "source": "# Sales"},22+// {"source": "sales = open sales.csv", "output": "[[Region, Units]; [North, 120]]"},23+// {"source": "$sales | where Units > 500", "error": "Column not found", "detail": "help: ..."},24+// {"source": "ls **/*", "unsaved": true}25+// ]26+type fileNotebookCell struct {27+ Kind string `json:"kind,omitempty"`28+ Source string `json:"source"`29+ Output string `json:"output,omitempty"`30+ Error string `json:"error,omitempty"`31+ Detail string `json:"detail,omitempty"`32+ Note string `json:"note,omitempty"`33+ Unsaved bool `json:"unsaved,omitempty"`34+}35+36+// notebookTab is what "tab" says of a notebook.37+const notebookTab = "notebook"38+39+// maxSavedError caps what a file keeps of a failed run's message.40+const maxSavedError = 4 << 1041+42+// writeNotebook writes a notebook tab's fields.43+func (s *Sheet) writeNotebook(b *bufio.Writer, indent string) error {44+ if !s.regions.notebook {45+ return nil46+ }47+ b.WriteString(",\n" + indent + `"tab": "notebook"`)48+ if s.regions.reactive {49+ b.WriteString(",\n" + indent + `"reactive": true`)50+ }51+ if len(s.regions.cells) == 0 {52+ return nil53+ }54+ w := s.wb55+ kept := notebook.Kept(s.regions.cells, w.Output, w.OutputCaps())56+ b.WriteString(",\n" + indent + `"notebookCells": [`)57+ for i, c := range s.regions.cells {58+ fc := fileNotebookCell{Source: c.Source}59+ if c.Kind == notebook.Note {60+ fc.Kind = c.Kind.String()61+ }62+ if o := w.Output(c.ID); o != nil {63+ fc.Error, fc.Detail, fc.Note = clip(o.Err), clip(o.Detail), clip(o.Note)64+ switch {65+ case kept[c.ID]:66+ fc.Output = string(o.NUON)67+ case o.NUON != nil || o.Unsaved:68+ fc.Unsaved = true69+ }70+ }71+ raw, err := json.Marshal(fc)72+ if err != nil {73+ return err74+ }75+ if i > 0 {76+ b.WriteByte(',')77+ }78+ fmt.Fprintf(b, "\n%s %s", indent, raw)79+ }80+ b.WriteString("\n" + indent + "]")81+ return nil82+}83+84+func clip(s string) string {85+ if len(s) > maxSavedError {86+ return strings.ToValidUTF8(s[:maxSavedError], "") + "…"87+ }88+ return s89+}90+91+// UnsavedOutputs names the cells whose outputs a file written now would92+// leave out, over the caps.93+func (w *Workbook) UnsavedOutputs() []string {94+ var out []string95+ for _, s := range w.sheets {96+ kept := notebook.Kept(s.regions.cells, w.Output, w.OutputCaps())97+ for i, c := range s.regions.cells {98+ if o := w.Output(c.ID); o != nil && o.NUON != nil && !kept[c.ID] {99+ name := c.Name()100+ if name == "" {101+ name = "cell " + fmt.Sprint(i+1)102+ }103+ out = append(out, name)104+ }105+ }106+ }107+ return out108+}109+110+// readNotebook reads a notebook tab's cells and their outputs, as its111+// cells would have left them.112+func (s *Sheet) readNotebook(f fileSheet) error {113+ if f.Tab != notebookTab {114+ if len(f.NotebookCells) > 0 {115+ return fmt.Errorf("notebook cells on a sheet that isn't a notebook")116+ }117+ return nil118+ }119+ w := s.wb120+ s.regions.notebook, s.regions.reactive = true, f.Reactive121+ outputs := map[int]*notebook.Output{}122+ for i, fc := range f.NotebookCells {123+ kind, ok := notebook.ParseKind(fc.Kind)124+ if !ok {125+ return fmt.Errorf("cell %d: invalid kind %q", i+1, fc.Kind)126+ }127+ c := notebook.Cell{ID: w.NewCellID(), Kind: kind, Source: fc.Source}128+ s.regions.cells = append(s.regions.cells, c)129+ if fc.Output == "" && fc.Error == "" && !fc.Unsaved {130+ continue131+ }132+ o := ¬ebook.Output{Err: fc.Error, Detail: fc.Detail, Note: fc.Note, Unsaved: fc.Unsaved}133+ if fc.Output != "" && !fc.Unsaved {134+ o.NUON = []byte(fc.Output)135+ }136+ w.SetOutput(c.ID, o)137+ outputs[c.ID] = w.Output(c.ID)138+ }139+ notebook.Settle(s.regions.cells, outputs)140+ return nil141+}142+143+// oldRegion is a command region of a notebook sheet as earlier builds144+// wrote it, which opening the file converts: its command becomes a145+// code cell of the workbook's notebook tab, and its table stays on the146+// sheet as the cell's output sent there.147+type oldRegion struct {148+ s *Sheet149+ fr fileRegion150+}151+152+// convertOld turns the command regions of a file into code cells of a153+// notebook tab, made after the last sheet that held them, and their154+// tables into sent outputs where the tables were, saying so in the load155+// notes.156+func (w *Workbook) convertOld(old []oldRegion) {157+ if len(old) == 0 {158+ return159+ }160+ nb := w.Notebook()161+ if nb == nil {162+ nb = w.newSheet(w.freeName("Notebook"))163+ nb.regions.notebook = true164+ w.insert(nb, w.Index(old[len(old)-1].s)+1)165+ }166+ var from []string167+ for _, o := range old {168+ fr := o.fr169+ src := fr.Command170+ if fr.Input != "" {171+ src = "$sheet." + quoteRef(fr.Input) + " | do {\n" + src + "\n}"172+ }173+ nb.regions.cells = append(nb.regions.cells, notebook.Cell{ID: w.NewCellID(), Source: fr.Name + " = " + src})174+ at, _ := ParseAddr(fr.At)175+ at.Row = min(at.Row+1, MaxRows-1) // the table was under the label line176+ r := Region{Name: fr.Name, At: at, Output: true}177+ o.s.regions.list = append(o.s.regions.list, r)178+ o.s.meta(nameKey(r.Name)).stale = true179+ o.s.regionsStale = true180+ if name := o.s.name; len(from) == 0 || from[len(from)-1] != name {181+ from = append(from, name)182+ }183+ }184+ w.nb.notes = append(w.nb.notes, fmt.Sprintf("%s's shell commands are now code cells of %s; their tables stay where they were, sent from the cells, and show once the cells run",185+ strings.Join(from, ", "), nb.name))186+}187+188+// quoteRef writes Sheet!A1:B2 as $sheet. reads it: the sheet's name in189+// quotes when it isn't a plain word.190+func quoteRef(ref string) string {191+ sheet, rest := SplitSheet(ref)192+ if sheet == "" {193+ return rest194+ }195+ for i := range len(sheet) {196+ if c := sheet[i]; !isLetter(c) && !isDigit(c) && c != '_' {197+ return "'" + strings.ReplaceAll(sheet, "'", "''") + "'!" + rest198+ }199+ }200+ return sheet + "!" + rest201+}@@ -3,201 +3,107 @@ package sheet
33 import (44 "errors"55 "fmt"6- "maps"76 "slices"8- "strconv"9- "strings"7+8+ "github.com/FelineStateMachine/012/internal/notebook"109 )1110 1211 // Regions are blocks of cells whose values come from outside the13-// engine, of two kinds: a command region (a nushell command's output on14-// a notebook sheet) and a linked file (a file followed as it grows or is15-// rewritten, linked.go). A region is an anchor (At: a command region's16-// label line, a linked file's first cell) and a definition saying where17-// its table comes from (Command, opaque to the engine, or File). Like a18-// spill, its cells are derived: written by the engine outside the undo19-// history, refused by Set, values to formulas, copies and exports,20-// formatting kept, never saved; the file keeps the definitions only.12+// engine, of two kinds: a linked file (a file followed as it grows or is13+// rewritten, linked.go) and a notebook cell's output sent to a sheet14+// (Output). A region is an anchor (At, its table's first cell) and a15+// definition saying where its table comes from. Like a spill, its cells16+// are derived: written by the engine outside the undo history, refused17+// by Set, values to formulas, copies and exports, formatting kept, never18+// saved; the file keeps the definitions only.2119 //2220 // Both kinds are one indexed set, the sheet's regionState, and their2321 // cells have one write path: rows arrive as live operations (LiveOp,24-// live.go), written by writeTable (regionwrite.go). What differs is what25-// undo keeps, a policy per kind:26-//27-// - A command region's table (RegionData, handed over by ShowRegion)28-// is part of the undo state, so undoing a run shows the table it29-// replaced: the run was the user's action, and its output can't be30-// had again (the command may read what has changed since). The31-// engine writes the table again from it whenever the region moves.32-// - A linked file's rows are not: the file is where they come from,33-// and undo can't bring back what the file held. Its rows live only34-// in its cells; when undo puts the region back, or it moves, it is35-// emptied and marked stale, and the UI reads the file again.36-//37-// The definitions of both are undo steps.22+// live.go), written by writeTable (regionwrite.go). Their rows are never23+// part of the undo state: the source is where they come from, and undo24+// can't bring back what it held. When undo puts a region back, or it25+// moves, it is emptied and marked stale, and the UI sends its rows26+// again: the file read again, or the cell's output as it is now.3827 //39-// On a notebook sheet regions stack in run order, a gap row between40-// them: a new one goes below everything on the sheet, and a table that41-// grows or shrinks inserts or deletes rows under it, so what's below42-// moves as rows inserted by hand would move it.28+// The definitions are undo steps. So are a notebook tab's cells, which29+// the same state holds (notebook.go).4330 //44-// In formulas a region is a name: nu. and its own (=SUM(nu.r1)), which45-// stands for its table, header row included.31+// In formulas a region is a name: nu. and its own (=SUM(nu.sales)),32+// which stands for its table, header row included.4633 4734 // Region is a region's definition.4835 type Region struct {49- // Name identifies the region in the workbook, ignoring case: r1, or50- // a name the user gave. Commands read other regions as $name.36+ // Name identifies the region in the workbook, ignoring case: a37+ // linked file's is made from the file's name, a sent output's is its38+ // cell's. Notebook cells read linked files as $name.5139 Name string52- // Command is what fills the region: for a notebook, a nushell53- // pipeline.54- Command string55- // At is the label line's first cell; the table starts below it.40+ // At is the table's first cell.5641 At Addr57- // Rows and Cols are the table's size as last shown, header row58- // included, so a region not run yet keeps its place.59- Rows, Cols int60- // Deps are the regions the command reads, in the order it names61- // them.62- Deps []string63- // Input is the range the command reads as $in, qualified with its64- // sheet ("Sheet1!A1:C9"), or "".65- Input string66- // Sort orders the table's rows below its header, by columns counted67- // from the region's first.68- Sort []SortKey69- // File is the file a linked file follows; zero for a command region.42+ // File is the file a linked file follows; zero for a sent output.7043 File LinkSource44+ // Output is set on a notebook cell's output sent to the sheet: the45+ // output of the cell of the region's name.46+ Output bool7147 }7248 73-// Linked reports whether the region is a linked file rather than a74-// command region.49+// Linked reports whether the region is a linked file.7550 func (r Region) Linked() bool { return r.File.Path != "" }7651 77-// labelled reports whether the region has a label line above its table:78-// a command region does.79-func (r Region) labelled() bool { return !r.Linked() }80-8152 // regionState is what a sheet keeps of its regions in the undo history:82-// the definitions in run order and the data each shows, by name key.83-// Both are replaced whole, never changed in place.53+// the definitions in the order they were made and, on a notebook tab,54+// its cells. It is replaced whole, never changed in place.8455 type regionState struct {8556 notebook bool8657 list []Region87- data map[string]*RegionData58+ cells []notebook.Cell59+ reactive bool8860 }8961 90-// regionMeta is what isn't undone: what the region's label says it's91-// doing, the cells written last and what the rows written made of it.62+// regionMeta is what isn't undone: the cells written last and what the63+// rows written made of the region.9264 type regionMeta struct {93- status string9465 written Rect9566 has bool // written holds something9667 why string // why the table isn't shown, or ""9768 need Rect // the cells the table needs while it isn't shown9869 // rows and cols are the table's size as written, header included.9970 rows, cols int100- liveMeta // a linked file's: see linked.go71+ liveMeta // how its rows arrive: see linked.go10172 }10273 10374 // Errors of regions.10475 var (105- // ErrRegionEdit is what typing into a region's cell says.106- ErrRegionEdit = errors.New("That cell is part of a shell region: change its command, or Freeze it to edit its values")76+ // ErrOutputEdit is what typing into a sent output's cell says.77+ ErrOutputEdit = errors.New("That cell shows a notebook cell's output: change the cell, or freeze the region to edit its values")10778 // ErrNoRegion is returned for a region the workbook doesn't hold:10879 // deleted, unlinked, undone, or on a sheet deleted.109- ErrNoRegion = errors.New("There's no region by that name")110- errFileRegion = errors.New("That region is a linked file, which has no command")80+ ErrNoRegion = errors.New("There's no region by that name")11181 )11282 11383 // regionPrefix starts a region's name in formulas.11484 const regionPrefix = "nu."11585 116-// FormulaName is how formulas name the region: nu.r1.86+// FormulaName is how formulas name the region: nu.sales.11787 func (r Region) FormulaName() string { return regionPrefix + r.Name }11888 119-// reservedRegionNames are nushell's own variables.120-var reservedRegionNames = []string{"in", "env", "nu", "it"}121-12289 // ValidRegionName checks that name can name a region: a nushell123-// variable name of letters, digits and _, not starting with a digit.124-func ValidRegionName(name string) error {125- switch {126- case name == "":127- return errors.New("Enter a name")128- case len(name) > 64:129- return errors.New("A region's name can be at most 64 characters")130- case isDigit(name[0]):131- return errors.New("A region's name can't start with a digit")132- case slices.Contains(reservedRegionNames, strings.ToLower(name)):133- return fmt.Errorf("$%s is nushell's own", name)134- }135- for i := range len(name) {136- if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' {137- return errors.New("A region's name can only have letters, digits and _")138- }139- }140- return nil141-}142-143-// Notebook reports whether the sheet is a notebook: its regions stack144-// in run order.145-func (s *Sheet) Notebook() bool { return s.regions.notebook }146-147-// MakeNotebook turns an empty sheet into a notebook, as a loader does,148-// without an undo step.149-func (s *Sheet) MakeNotebook() { s.regions.notebook = true }150-151-// AddNotebook adds a notebook sheet named name ("" for the next Shell152-// N) after the active one, as one undo step.153-func (w *Workbook) AddNotebook(name string, after *Sheet) (*Sheet, error) {154- if name == "" {155- name = w.NextNotebookName()156- }157- if err := w.checkName(nil, name); err != nil {158- return nil, err159- }160- s := w.newSheet(name)161- s.regions.notebook = true162- at := len(w.sheets)163- if i := w.Index(after); i >= 0 {164- at = i + 1165- }166- w.change(s, "insert sheet "+name, Rect{}, func() {167- w.recordSheets()168- w.insert(s, at)169- })170- return s, nil171-}172-173-// NextNotebookName is Shell 1, then 2 and so on.174-func (w *Workbook) NextNotebookName() string {175- for n := 1; ; n++ {176- if name := "Shell " + strconv.Itoa(n); w.Lookup(name) == nil {177- return name178- }179- }180-}90+// variable's name of letters, digits and _, not starting with a digit,91+// as a notebook cell's name is.92+func ValidRegionName(name string) error { return notebook.ValidName(name) }18193 18294 // HasRegions reports whether the sheet has regions, so what draws it can18395 // skip looking for them.18496 func (s *Sheet) HasRegions() bool { return len(s.regions.list) > 0 }18597 186-// Regions returns the sheet's regions in run order.187-func (s *Sheet) Regions() []Region {188- out := slices.Clone(s.regions.list)189- for i := range out {190- out[i].Deps, out[i].Sort = slices.Clone(out[i].Deps), slices.Clone(out[i].Sort)191- }192- return out193-}98+// Regions returns the sheet's regions in the order they were made.99+func (s *Sheet) Regions() []Region { return slices.Clone(s.regions.list) }194100 195101 // Region finds a region by name, ignoring case, on any sheet.196102 func (w *Workbook) Region(name string) (*Sheet, Region, bool) {197103 k := nameKey(name)198104 for _, s := range w.sheets {199105 if i := s.regionIndex(k); i >= 0 {200- return s, s.Regions()[i], true106+ return s, s.regions.list[i], true201107 }202108 }203109 return nil, Region{}, false@@ -207,19 +113,20 @@ func (s *Sheet) regionIndex(k string) int {
207113 return slices.IndexFunc(s.regions.list, func(r Region) bool { return nameKey(r.Name) == k })208114 }209115 210-// NextRegionName is the first of r1, r2 and so on that no region or211-// named range has.212-func (w *Workbook) NextRegionName() string {213- for n := 1; ; n++ {214- name := "r" + strconv.Itoa(n)215- if w.checkRegionName(name) == nil {216- return name217- }116+// allRegions returns every region of the workbook's sheets, sheet by117+// sheet.118+func (w *Workbook) allRegions() []Region {119+ var out []Region120+ for _, s := range w.sheets {121+ out = append(out, s.regions.list...)218122 }123+ return out219124 }220125 221-// checkRegionName reports why name can't name a new region.222-func (w *Workbook) checkRegionName(name string) error {126+// checkRegionName reports why name can't name a new region. A sent127+// output (output set) takes its cell's name, which a notebook's cell128+// has; any other region's name is one no cell has.129+func (w *Workbook) checkRegionName(name string, output bool) error {223130 if err := ValidRegionName(name); err != nil {224131 return err225132 }@@ -229,84 +136,30 @@ func (w *Workbook) checkRegionName(name string) error {
229136 if n, ok := w.LookupName(regionPrefix + name); ok {230137 return fmt.Errorf("%s already names %s", n.Name, n.Ref())231138 }232- return nil233-}234-235-// RegionShown reports whether the region has data to show: it has run236-// since the file was opened.237-func (s *Sheet) RegionShown(name string) bool {238- return s.regions.data[nameKey(name)] != nil239-}240-241-// RegionStatus is what the region's label says it's doing: "Running…",242-// "not run", why its table can't be shown, or "".243-func (s *Sheet) RegionStatus(name string) string {244- k := nameKey(name)245- if me := s.regionMeta[k]; me != nil {246- switch {247- case me.status != "":248- return me.status249- case me.why != "":250- return me.why251- }139+ if !output && w.cellNamed(name) {140+ return fmt.Errorf("A notebook cell is named %s", name)252141 }253- switch d := s.regions.data[k]; {254- case d == nil:255- return "not run"256- case d.Rows == 0:257- return "no rows"258- }259- return ""260-}261-262-// RegionNote is what the data shown left out, or "".263-func (s *Sheet) RegionNote(name string) string {264- if d := s.regions.data[nameKey(name)]; d != nil {265- return d.Note266- }267- return ""142+ return nil268143 }269144 270-// AddRegion adds a region to the sheet as one undo step. On a notebook271-// its label goes below everything on the sheet, a row left free, and r.At272-// is ignored.145+// AddRegion adds a region to the sheet as one undo step. It starts146+// empty and stale: the UI sends its rows.273147 func (s *Sheet) AddRegion(r Region) error {274- if err := s.wb.checkRegionName(r.Name); err != nil {148+ if err := s.wb.checkRegionName(r.Name, r.Output); err != nil {275149 return err276150 }277- if err := s.wb.checkDeps(r.Name, r.Deps); err != nil {278- return err151+ if !r.At.Valid() || s.cells.filledAt(r.At) || s.InPivot(Rect{From: r.At, To: r.At}) {152+ return errLinkOver279153 }280- if s.regions.notebook {281- r.At = Addr{Row: s.nextRegionRow()}154+ if _, ok := s.RegionAt(r.At); ok {155+ return errLinkOver282156 }283- r.Deps, r.Sort = slices.Clone(r.Deps), slices.Clone(r.Sort)284157 s.change("add "+r.Name, Rect{From: r.At, To: r.At}, func() {285158 st := s.regionsCopy()286159 st.list = append(st.list, r)287160 s.putRegions(st)288161 })289- return nil290-}291-292-// EditRegion changes a region's command, what it reads and its input,293-// as one undo step. The table stays until the command runs again.294-func (s *Sheet) EditRegion(name, command string, deps []string, input string) error {295- i := s.regionIndex(nameKey(name))296- if i < 0 {297- return ErrNoRegion298- }299- if s.regions.list[i].Linked() {300- return errFileRegion301- }302- if err := s.wb.checkDeps(s.regions.list[i].Name, deps); err != nil {303- return err304- }305- s.change("edit "+s.regions.list[i].Name, Rect{From: s.regions.list[i].At, To: s.regions.list[i].At}, func() {306- st := s.regionsCopy()307- st.list[i].Command, st.list[i].Deps, st.list[i].Input = command, slices.Clone(deps), input308- s.putRegions(st)309- })162+ s.meta(nameKey(r.Name)).stale = true310163 return nil311164 }312165 @@ -318,10 +171,9 @@ func (s *Sheet) DeleteRegion(name string) error {
318171 return ErrNoRegion319172 }320173 r := s.regions.list[i]321- s.change("delete "+r.Name, s.regionArea(r), func() {174+ s.change("delete "+r.Name, s.covered(r), func() {322175 st := s.regionsCopy()323176 st.list = slices.Delete(st.list, i, i+1)324- delete(st.data, k)325177 s.putRegions(st)326178 })327179 return nil@@ -350,7 +202,6 @@ func (s *Sheet) FreezeRegion(name string) error {
350202 // The region goes first, so its cells take the values placed.351203 st := s.regionsCopy()352204 st.list = slices.Delete(st.list, i, i+1)353- delete(st.data, k)354205 s.putRegions(st)355206 for j, a := range at {356207 s.place(a, cells[j])@@ -369,49 +220,17 @@ func frozen(c *Cell) *Cell {
369220 return p.plain().withNote(c.Note)370221 }371222 372-// ShowRegion shows d in the region, as one undo step: the table grows or373-// shrinks, and on a notebook the rows under it move with it.374-func (s *Sheet) ShowRegion(name string, d *RegionData) error {375- k := nameKey(name)376- i := s.regionIndex(k)377- if i < 0 {378- return ErrNoRegion379- }380- if d == nil {381- d = &RegionData{}382- }383- r := s.regions.list[i]384- if r.Linked() {385- return errFileRegion386- }387- s.change("run "+r.Name, s.regionArea(r), func() {388- if s.regions.notebook {389- s.fitRegion(k, d.Rows)390- }391- st := s.regionsCopy()392- i := s.regionIndex(k) // fitting may have moved it393- if i < 0 {394- return395- }396- st.list[i].Rows, st.list[i].Cols = d.Rows, d.Cols397- st.data[k] = d398- s.putRegions(st)399- })400- return nil401-}402-403223 // regionsCopy is a copy of the sheet's regions to change and put back.404224 func (s *Sheet) regionsCopy() regionState {405- return regionState{notebook: s.regions.notebook, list: slices.Clone(s.regions.list), data: maps.Clone(s.regions.data)}225+ st := s.regions226+ st.list = slices.Clone(st.list)227+ return st406228 }407229 408230 // putRegions replaces the sheet's regions, recording them for undo, and409231 // has their cells written again.410232 func (s *Sheet) putRegions(st regionState) {411233 s.recordRegions()412- if st.data == nil {413- st.data = map[string]*RegionData{}414- }415234 s.regions = st416235 s.regionsStale = true417236 }@@ -429,15 +248,19 @@ func (s *Sheet) recordRegions() {
429248 // sameRegions reports whether two states of a sheet's regions are the430249 // same.431250 func sameRegions(a, b regionState) bool {432- if a.notebook != b.notebook || len(a.list) != len(b.list) || !maps.Equal(a.data, b.data) {433- return false434- }435- for i := range a.list {436- x, y := a.list[i], b.list[i]437- if x.Name != y.Name || x.Command != y.Command || x.At != y.At || x.Rows != y.Rows || x.Cols != y.Cols ||438- x.Input != y.Input || x.File != y.File || !slices.Equal(x.Deps, y.Deps) || !slices.Equal(x.Sort, y.Sort) {439- return false251+ return a.notebook == b.notebook && a.reactive == b.reactive && slices.Equal(a.list, b.list) && slices.Equal(a.cells, b.cells)252+}253+254+// putRegionsBack puts back a sheet's regions from an undo step. A255+// region the step brings back, or moves, is emptied and fed again once256+// the change ends: its rows aren't part of the undo state, so the cells257+// undo put there may not be its source's.258+func (s *Sheet) putRegionsBack(st regionState) {259+ for _, r := range st.list {260+ i := s.regionIndex(nameKey(r.Name))261+ if i < 0 || s.regions.list[i] != r {262+ s.meta(nameKey(r.Name)).reread = true440263 }441264 }442- return true265+ s.regions, s.regionsStale = st, true443266 }@@ -1,189 +1,238 @@
11 package sheet22 33 import (4- "slices"4+ "bytes"5+ "strings"56 "testing"7+8+ "github.com/FelineStateMachine/012/internal/notebook"69 )710 8-// table makes region data of text rows: the first is the header, and9-// cells that read as numbers are numbers.10-func table(rows ...[]string) *RegionData {11- d := &RegionData{Rows: len(rows)}12- for _, r := range rows {13- d.Cols = max(d.Cols, len(r))14- }15- d.Values = make([]Value, d.Rows*d.Cols)16- for i, r := range rows {17- for j, text := range r {18- v, _, ok := ParseValue(text)19- switch {20- case text == "":21- continue22- case ok && i > 0:23- d.Values[i*d.Cols+j] = Value{Kind: Number, Num: v}24- default:25- d.Values[i*d.Cols+j] = Value{Kind: Text, Str: text}26- }11+// sent adds a notebook cell named name to a new notebook tab and sends12+// its output to s at a, returning the notebook.13+func sent(t *testing.T, s *Sheet, name, a string) *Sheet {14+ t.Helper()15+ w := s.Book()16+ nb := w.Notebook()17+ if nb == nil {18+ var err error19+ if nb, err = w.AddNotebook("", s); err != nil {20+ t.Fatal(err)2721 }2822 }29- return d23+ nb.SetNotebookCells("add cell", append(nb.NotebookCells(), notebook.Cell{Source: name + " = ls"}))24+ if err := s.AddRegion(Region{Name: name, At: at(a), Output: true}); err != nil {25+ t.Fatal(err)26+ }27+ return nb3028 }3129 32-func notebook(t *testing.T) *Sheet {33- t.Helper()30+func TestSentOutputFollowsRuns(t *testing.T) {3431 s := New()35- s.MakeNotebook()36- return s32+ w := s.Book()33+ sent(t, s, "files", "B2")34+ other, _ := w.AddSheet("Sheet2", 1)35+ other.Set(at("A1"), "=SUM(nu.files)")36+ wantShown(t, other, map[string]string{"A1": "#REF!"}) // not run yet37+ apply(t, s, LiveOp{Region: "files", Reset: true, Header: liveRow("n", "size"), Rows: []LiveRow{liveRow("a", "1"), liveRow("b", "2")}})38+ wantShown(t, s, map[string]string{"B2": "n", "C3": "1", "B4": "b", "B1": ""})39+ wantShown(t, other, map[string]string{"A1": "3"})40+ // A run that gives fewer rows clears the rest.41+ apply(t, s, LiveOp{Region: "files", Reset: true, Header: liveRow("n", "size"), Rows: []LiveRow{liveRow("c", "10")}})42+ wantShown(t, s, map[string]string{"B3": "c", "B4": ""})43+ wantShown(t, other, map[string]string{"A1": "10"})44+ if err := s.Set(at("B3"), "x"); err != ErrOutputEdit {45+ t.Errorf("typing over an output: %v", err)46+ }47+ if w.UndoLabel() != "edit A1" {48+ t.Errorf("rows arriving made an undo step: %q", w.UndoLabel())49+ }3750 }3851 39-func addShown(t *testing.T, s *Sheet, name, command string, d *RegionData) {40- t.Helper()41- if err := s.AddRegion(Region{Name: name, Command: command, Deps: s.Book().RegionDeps(command)}); err != nil {52+func TestSentOutputNames(t *testing.T) {53+ s := New()54+ w := s.Book()55+ sent(t, s, "files", "A1")56+ if err := s.AddRegion(Region{Name: "files", At: at("D1"), Output: true}); err == nil {57+ t.Error("two regions share a name")58+ }59+ if _, err := s.AddLinked(at("F1"), LinkSource{Path: "files.csv"}); err != nil {4260 t.Fatal(err)4361 }44- if err := s.ShowRegion(name, d); err != nil {62+ if _, r, _ := w.Region("files_2"); !r.Linked() {63+ t.Error("a linked file took a cell's name")64+ }65+ if got := w.FreeCellName("files"); got != "files_3" {66+ t.Errorf("a free name like files: %q", got)67+ }68+ if err := w.DefineName("nu.big", s, rect("A9")); err != nil {4569 t.Fatal(err)4670 }71+ if err := s.AddRegion(Region{Name: "big", At: at("H1"), Output: true}); err == nil {72+ t.Error("a region took a named range's name")73+ }4774 }4875 49-func TestRegionsStack(t *testing.T) {50- s := notebook(t)51- addShown(t, s, "r1", "ls", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}))52- wantShown(t, s, map[string]string{"A1": "r1 ls", "A2": "name", "B2": "size", "A3": "a", "B4": "2", "A5": ""})53- addShown(t, s, "r2", "$r1 | where size > 1", table([]string{"name", "size"}, []string{"b", "2"}))54- // A gap row, then r2's label.55- wantShown(t, s, map[string]string{"A5": "", "A6": "r2 $r1 | where size > 1", "A7": "name", "A8": "b"})56- if r, ok := s.RegionTable("r2"); !ok || r != rect("A7:B8") {57- t.Errorf("r2's table %v %v", r, ok)58- }59- // r1 growing pushes r2 down; shrinking pulls it back.60- if err := s.ShowRegion("r1", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}, []string{"c", "3"})); err != nil {76+// Undo puts a region back emptied and stale, for its source to send its77+// rows again; moving it with inserted rows does the same.78+func TestSentOutputUndoAndShift(t *testing.T) {79+ s := New()80+ w := s.Book()81+ sent(t, s, "t", "A2")82+ apply(t, s, LiveOp{Region: "t", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("1")}})83+ if err := s.DeleteRegion("t"); err != nil {6184 t.Fatal(err)6285 }63- wantShown(t, s, map[string]string{"A5": "c", "A6": "", "A7": "r2 $r1 | where size > 1", "A9": "b"})64- s.ShowRegion("r1", table([]string{"name"}, []string{"a"}))65- wantShown(t, s, map[string]string{"A3": "a", "B2": "", "A4": "", "A5": "r2 $r1 | where size > 1", "A7": "b"})66- // Undo shows the table it replaced, with r2 where it was.67- s.Book().Undo()68- wantShown(t, s, map[string]string{"A5": "c", "A7": "r2 $r1 | where size > 1"})69- s.Book().Undo()70- s.Book().Undo() // r2's run71- wantShown(t, s, map[string]string{"A6": "r2 $r1 | where size > 1 not run", "A7": ""})72- s.Book().Undo() // r2 itself73- wantShown(t, s, map[string]string{"A6": ""})74-}75-76-func TestRegionGuards(t *testing.T) {77- s := notebook(t)78- addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}))79- for _, a := range []string{"A1", "A2", "A3"} {80- if err := s.Set(at(a), "x"); err != ErrRegionEdit {81- t.Errorf("Set %s: %v, want ErrRegionEdit", a, err)82- }86+ wantShown(t, s, map[string]string{"A2": "", "A3": ""})87+ w.Undo()88+ if !s.regionMeta[nameKey("t")].stale {89+ t.Error("the region undo put back isn't stale")8390 }84- if a, r, ok := s.InRegion(rect("A3:C9")); !ok || a != at("A3") || r.Name != "r1" {85- t.Errorf("InRegion = %v %v %v", a, r.Name, ok)91+ apply(t, s, LiveOp{Region: "t", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("1")}})92+ s.InsertRows(0, 2)93+ if _, r, _ := w.Region("t"); r.At != at("A4") {94+ t.Errorf("after inserting rows the region is at %v", r.At)8695 }87- // Formatting stays, and the value with it.88- s.Batch(Change{Label: "bold"}, func() error {89- c := s.Cell(at("A3")).clone()90- c.Style.Bold = true91- s.place(at("A3"), c)92- return nil93- })94- if c := s.Cell(at("A3")); c == nil || !c.Style.Bold || c.Value.Num != 1 {95- t.Errorf("A3 after bold: %+v", c)96+ if !s.regionMeta[nameKey("t")].stale {97+ t.Error("the region moved isn't stale")9698 }97- // Saved: the definition, not the table.98- t2 := roundTrip(t, s)99- if !t2.Notebook() || len(t2.Regions()) != 1 || t2.RegionShown("r1") {100- t.Fatalf("read back: notebook %v, regions %+v", t2.Notebook(), t2.Regions())99+ if err := s.FreezeRegion("t"); err != nil {100+ t.Fatal(err)101101 }102- wantShown(t, t2, map[string]string{"A1": "r1 ls not run", "A3": ""})103- if !t2.Cell(at("A3")).Style.Bold {104- t.Error("the table's formatting wasn't kept")102+}103+104+func TestNotebookCellsUndo(t *testing.T) {105+ w := NewBook()106+ nb, err := w.AddNotebook("", w.Sheet(0))107+ if err != nil || nb.Name() != "Notebook" || !nb.IsNotebook() {108+ t.Fatalf("%v %v", nb, err)109+ }110+ nb.SetNotebookCells("add cell", []notebook.Cell{{Source: "ls"}})111+ cells := nb.NotebookCells()112+ if len(cells) != 1 || cells[0].ID == 0 {113+ t.Fatalf("cells %+v", cells)114+ }115+ nb.SetNotebookCells("edit cell 1", []notebook.Cell{{ID: cells[0].ID, Source: "files = ls"}})116+ w.Undo()117+ if got := nb.NotebookCells(); got[0].Source != "ls" {118+ t.Errorf("after undo: %+v", got)119+ }120+ nb.SetReactive(true)121+ w.Undo()122+ if nb.Reactive() {123+ t.Error("undo left the notebook reactive")124+ }125+ if w.Notebook() != nb {126+ t.Error("the workbook's notebook")105127 }106128 }107129 108-func TestRegionFormulaName(t *testing.T) {109- s := notebook(t)110- w := s.Book()111- other, _ := w.AddSheet("Sheet2", 1)112- if err := other.Set(at("A1"), "=SUM(nu.r1)"); err != nil {130+func writeBook(t *testing.T, w *Workbook) string {131+ t.Helper()132+ var b bytes.Buffer133+ if err := w.Write(&b); err != nil {113134 t.Fatal(err)114135 }115- wantShown(t, other, map[string]string{"A1": "#NAME?"})116- addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}, []string{"2"}))117- wantShown(t, other, map[string]string{"A1": "3"})118- s.ShowRegion("r1", table([]string{"n"}, []string{"5"}))119- wantShown(t, other, map[string]string{"A1": "5"})120- if err := w.DefineName("nu.r2", s, rect("A1")); err != nil {136+ return b.String()137+}138+139+func TestNotebookRoundTrip(t *testing.T) {140+ w := NewBook()141+ nb, _ := w.AddNotebook("", w.Sheet(0))142+ nb.SetNotebookCells("add cells", []notebook.Cell{143+ {Kind: notebook.Note, Source: "# Files"},144+ {Source: "files = ls"},145+ {Source: "$files | where size > 1kb"},146+ {Source: "nope"},147+ })148+ nb.SetReactive(true)149+ cells := nb.NotebookCells()150+ w.SetOutput(cells[1].ID, ¬ebook.Output{NUON: []byte(`[[name, size]; [a, 2kb]]`), Count: 1, Source: cells[1].Source})151+ w.SetOutput(cells[2].ID, ¬ebook.Output{NUON: []byte(strings.Repeat("x", 100)), Count: 2})152+ w.SetOutput(cells[3].ID, ¬ebook.Output{Err: "Command `nope` not found", Detail: "help: try ls"})153+ w.SetOutputCaps(notebook.Caps{Cell: 50, Total: 1000})154+ if got := w.UnsavedOutputs(); len(got) != 1 || got[0] != "cell 3" {155+ t.Errorf("unsaved %v", got)156+ }157+ text := writeBook(t, w)158+ for _, want := range []string{`"tab": "notebook"`, `"reactive": true`, `{"kind":"note","source":"# Files"}`,159+ `"output":"[[name, size]; [a, 2kb]]"`, `"unsaved":true`, `"error":"Command`} {160+ if !strings.Contains(text, want) {161+ t.Errorf("the file lacks %s:\n%s", want, text)162+ }163+ }164+ back, err := ReadBook(strings.NewReader(text))165+ if err != nil {121166 t.Fatal(err)122167 }123- if err := s.AddRegion(Region{Name: "r2", Command: "x"}); err == nil {124- t.Error("a region took a named range's name")168+ nb2 := back.Notebook()169+ if nb2 == nil || !nb2.Reactive() || nb2.Name() != "Notebook" {170+ t.Fatalf("read back %v", nb2)125171 }126-}127-128-func TestRegionGraph(t *testing.T) {129- s := notebook(t)130- w := s.Book()131- addShown(t, s, "r1", "ls", table([]string{"n"}))132- addShown(t, s, "r2", "$r1 | first", table([]string{"n"}))133- addShown(t, s, "big", "$r2 | append $r1", table([]string{"n"}))134- addShown(t, s, "r3", "$env.PATH | $in", table([]string{"n"}))135- if got := w.RefreshOrder("r1"); !slices.Equal(got, []string{"r1", "r2", "big"}) {136- t.Errorf("refresh r1: %v", got)172+ got := nb2.NotebookCells()173+ if len(got) != 4 || got[0].Kind != notebook.Note || got[1].Source != "files = ls" {174+ t.Fatalf("cells %+v", got)175+ }176+ if o := back.Output(got[1].ID); o == nil || string(o.NUON) != `[[name, size]; [a, 2kb]]` || o.Count != 0 {177+ t.Errorf("output of files: %+v", o)137178 }138- if got := w.RefreshOrder("r2"); !slices.Equal(got, []string{"r2", "big"}) {139- t.Errorf("refresh r2: %v", got)179+ if o := back.Output(got[2].ID); o == nil || !o.Unsaved || o.NUON != nil {180+ t.Errorf("the output over the cap: %+v", o)140181 }141- if got := w.RunOrder(); !slices.Equal(got, []string{"r1", "r2", "big", "r3"}) {142- t.Errorf("run all: %v", got)182+ if o := back.Output(got[3].ID); o == nil || o.Err == "" || o.Detail != "help: try ls" {183+ t.Errorf("the error: %+v", o)143184 }144- if err := s.EditRegion("r1", "$big", w.RegionDeps("$big"), ""); err == nil {145- t.Error("a cycle was accepted")185+ if st := notebook.Stale(got, back.Output); len(st) != 0 {186+ t.Errorf("stale on opening: %v", st)146187 }147- if err := s.EditRegion("r1", "$r1", w.RegionDeps("$r1"), ""); err == nil {148- t.Error("a region reading itself was accepted")188+ if back.OutputsChanged() != 0 {189+ t.Error("reading outputs counts as a change")149190 }150- if got := RegionRefs("$r1 | where x == $in.a and $nu.home | $r_2.x"); !slices.Equal(got, []string{"r1", "r_2"}) {151- t.Errorf("refs %v", got)191+ // Written again, it's the same file.192+ if again := writeBook(t, back); again != text {193+ t.Errorf("written again:\n%s\nwas\n%s", again, text)152194 }153195 }154196 155-func TestRegionFreezeAndSort(t *testing.T) {156- s := notebook(t)157- addShown(t, s, "r1", "ls", table([]string{"n", "s"}, []string{"b", "2"}, []string{"a", "10"}, []string{"c", ""}))158- if err := s.SortRegion("r1", []SortKey{{Col: 1, Desc: true}}); err != nil {197+// A notebook sheet of an earlier build opens with its commands as code198+// cells of a notebook tab, their tables sent where they were.199+func TestOldNotebookConverted(t *testing.T) {200+ old := `{"version": 4, "sheets": [201+ {"name": "Shell 1", "notebook": true, "cells": {}, "regions": [202+ {"name": "r1", "command": "ls", "at": "A1", "rows": 3, "cols": 2},203+ {"name": "big", "command": "$r1 | where size > 1kb", "at": "A6", "reads": ["r1"]},204+ {"name": "sel", "command": "$in | math sum", "at": "A9", "input": "Data 1!A1:A4"}205+ ]},206+ {"name": "Data 1", "cells": {"B1": "=SUM(nu.big)"}}207+]}`208+ w, err := ReadBook(strings.NewReader(old))209+ if err != nil {159210 t.Fatal(err)160211 }161- wantShown(t, s, map[string]string{"A3": "a", "A4": "b", "A5": "c"})162- // Running again keeps the order.163- s.ShowRegion("r1", table([]string{"n", "s"}, []string{"x", "1"}, []string{"y", "3"}))164- wantShown(t, s, map[string]string{"A3": "y", "A4": "x"})165- if err := s.FreezeRegion("r1"); err != nil {166- t.Fatal(err)212+ nb := w.Notebook()213+ if nb == nil || w.Index(nb) != 1 || nb.Name() != "Notebook" {214+ t.Fatalf("notebook %v at %d", nb, w.Index(nb))167215 }168- wantShown(t, s, map[string]string{"A1": "", "A2": "n", "A3": "y", "B3": "3"})169- if err := s.Set(at("A3"), "z"); err != nil {170- t.Errorf("a frozen cell can't be edited: %v", err)216+ cells := nb.NotebookCells()217+ want := []string{"r1 = ls", "big = $r1 | where size > 1kb", "sel = $sheet.'Data 1'!A1:A4 | do {\n$in | math sum\n}"}218+ for i, c := range cells {219+ if c.Source != want[i] {220+ t.Errorf("cell %d: %q, want %q", i+1, c.Source, want[i])221+ }171222 }172- s.Book().Undo()173- s.Book().Undo()174- wantShown(t, s, map[string]string{"A1": "r1 ls", "A3": "y"})175-}176-177-func TestRegionBlocked(t *testing.T) {178- s := New()179- if err := s.Set(at("B3"), "mine"); err != nil {180- t.Fatal(err)223+ shell := w.Lookup("Shell 1")224+ if shell.IsNotebook() {225+ t.Error("the old notebook sheet is a notebook tab")181226 }182- if err := s.AddRegion(Region{Name: "feed", Command: "tail", At: at("A1")}); err != nil {183- t.Fatal(err)227+ if _, r, ok := w.Region("big"); !ok || !r.Output || r.At != at("A7") {228+ t.Errorf("big's table: %+v %v", r, ok)229+ }230+ if notes := w.LoadNotes(); len(notes) != 1 || !strings.Contains(notes[0], "Shell 1") {231+ t.Errorf("notes %v", notes)232+ }233+ apply(t, shell, LiveOp{Region: "big", Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("4")}})234+ wantShown(t, w.Lookup("Data 1"), map[string]string{"B1": "4"})235+ if text := writeBook(t, w); strings.Contains(text, `"command"`) || !strings.Contains(text, `"output":true`) {236+ t.Errorf("saved again:\n%s", text)184237 }185- s.ShowRegion("feed", table([]string{"a", "b"}, []string{"1", "2"}, []string{"3", "4"}))186- wantShown(t, s, map[string]string{"A1": "feed tail can't show: it would overwrite data in B3", "A2": ""})187- s.Set(at("B3"), "")188- wantShown(t, s, map[string]string{"A1": "feed tail", "A2": "a", "B3": "2", "B4": "4"})189238 }@@ -1,66 +0,0 @@
1-package sheet2-3-import "github.com/FelineStateMachine/012/internal/formula"4-5-// What fills a region, and how formulas name one (see region.go).6-7-// RegionData is a region's table: Rows by Cols values, row by row, the8-// header row first, with the formats they come in (a file size's Size,9-// a date's Date time) by index.10-type RegionData struct {11- Rows, Cols int12- Values []Value13- Formats map[int]Format14- // Note says what was left out, e.g. rows past max-cells.15- Note string16-}17-18-// At returns the value at row r, column c and its format.19-func (d *RegionData) At(r, c int) (Value, Format) {20- if d == nil || r < 0 || c < 0 || r >= d.Rows || c >= d.Cols {21- return Value{}, Format{}22- }23- i := r*d.Cols + c24- return d.Values[i], d.Formats[i]25-}26-27-// RegionDataOf copies the used range of src, from A1, as a region's28-// table: how a table read by an importer becomes one.29-func RegionDataOf(src *Sheet) *RegionData {30- used, ok := src.UsedRange()31- if !ok {32- return &RegionData{}33- }34- d := &RegionData{Rows: used.To.Row + 1, Cols: used.To.Col + 1}35- d.Values = make([]Value, d.Rows*d.Cols)36- for a := range src.cells.keysIn(Rect{To: used.To}) {37- i := a.Row*d.Cols + a.Col38- d.Values[i] = src.Value(a)39- if f := src.DisplayFormat(a); !f.IsZero() {40- if d.Formats == nil {41- d.Formats = map[int]Format{}42- }43- d.Formats[i] = f44- }45- }46- return d47-}48-49-// boundRegion is a name in a formula that isn't a named range: a50-// region's table (nu.r1), #REF! while it has none, or the name as51-// written, which shows #NAME?.52-func (s *Sheet) boundRegion(nn formula.Name) Node {53- t, r, ok := s.wb.regionName(nameKey(nn.Name))54- switch {55- case !ok:56- return nn57- case r == (Rect{}):58- return formula.RefErr{}59- }60- sheet := ""61- if t != s {62- sheet = t.name63- }64- const fixed = formula.AbsCol | formula.AbsRow65- return formula.Range{Rect: r, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}66-}@@ -8,55 +8,52 @@ import (
88 99 // fileRegion is a region in the file: its definition, one line per1010 // region after the cells of its sheet, never its table, which its11-// command makes again when it runs or its file gives again when it's12-// read. They need no version: earlier builds ignore them and open the13-// sheet without its regions.11+// source sends again when the file is opened. They need no version:12+// earlier builds ignore them and open the sheet without its regions.1413 //15-// "notebook": true,1614 // "regions": [17-// {"name": "r1", "command": "ls", "at": "A1", "rows": 12, "cols": 4},18-// {"name": "big", "command": "$r1 | where size > 1kb", "at": "A16", "rows": 3, "cols": 4, "reads": ["r1"]},15+// {"name": "sales", "at": "A1", "output": true},1916 // {"name": "app", "at": "F1", "path": "logs/app.csv", "window": 500}2017 // ]18+//19+// A region with a command is a notebook sheet's region as earlier20+// builds wrote them, with its label line at "at" and its table under21+// it; opening the file converts it (convertOld).2122 type fileRegion struct {22- Name string `json:"name"`23- Command string `json:"command,omitempty"`24- At string `json:"at"`25- Rows int `json:"rows,omitempty"`26- Cols int `json:"cols,omitempty"`27- Reads []string `json:"reads,omitempty"`28- Input string `json:"input,omitempty"`29- Sort []fileRegionSort `json:"sort,omitempty"`23+ Name string `json:"name"`24+ At string `json:"at"`25+ // A sent output's.26+ Output bool `json:"output,omitempty"`3027 // A linked file's: see linked.go.3128 Path string `json:"path,omitempty"`3229 Format string `json:"format,omitempty"`3330 Table string `json:"table,omitempty"`3431 Query string `json:"query,omitempty"`3532 Window int `json:"window,omitempty"`33+ // A command region's, read to convert it.34+ Command string `json:"command,omitempty"`35+ Input string `json:"input,omitempty"`36+ Rows int `json:"rows,omitempty"`37+ Cols int `json:"cols,omitempty"`38+ Reads []string `json:"reads,omitempty"`39+ Sort []fileRegionSort `json:"sort,omitempty"`3640 }3741 38-// fileRegionSort is a column a region's table is sorted by, counted from39-// 1 at the region's first column.42+// fileRegionSort is a column a command region's table was sorted by.4043 type fileRegionSort struct {4144 Column int `json:"column"`4245 Desc bool `json:"desc,omitempty"`4346 }4447 45-// writeRegionDefs writes the notebook flag and the regions, one per line.48+// writeRegionDefs writes the regions, one per line.4649 func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {47- if s.regions.notebook {48- b.WriteString(",\n" + indent + `"notebook": true`)49- }5050 if len(s.regions.list) == 0 {5151 return nil5252 }5353 b.WriteString(",\n" + indent + `"regions": [`)5454 for i, r := range s.regions.list {55- fr := fileRegion{Name: r.Name, Command: r.Command, At: r.At.String(), Rows: r.Rows, Cols: r.Cols, Reads: r.Deps, Input: r.Input,55+ fr := fileRegion{Name: r.Name, At: r.At.String(), Output: r.Output,5656 Path: r.File.Path, Format: r.File.Format, Table: r.File.Table, Query: r.File.Query, Window: r.File.Window}57- for _, k := range r.Sort {58- fr.Sort = append(fr.Sort, fileRegionSort{Column: k.Col + 1, Desc: k.Desc})59- }6057 raw, err := json.Marshal(fr)6158 if err != nil {6259 return err@@ -70,63 +67,47 @@ func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {
7067 return nil7168 }7269 73-// readRegions reads a sheet's regions, not yet shown.74-func (s *Sheet) readRegions(notebook bool, frs []fileRegion) error {75- s.regions.notebook = notebook70+// readRegions reads a sheet's regions, stale until their sources send71+// their rows, adding the command regions of earlier builds to old.72+func (s *Sheet) readRegions(frs []fileRegion, old *[]oldRegion) error {7673 for _, fr := range frs {77- r, err := decodeRegion(fr)78- if err != nil {79- return err74+ if err := ValidRegionName(fr.Name); err != nil {75+ return fmt.Errorf("region %q: %w", fr.Name, err)76+ }77+ if _, _, dup := s.wb.Region(fr.Name); dup || s.regionIndex(nameKey(fr.Name)) >= 0 || s.wb.converting(*old, fr.Name) {78+ return fmt.Errorf("region %q is defined twice", fr.Name)8079 }81- if _, _, dup := s.wb.Region(r.Name); dup || s.regionIndex(nameKey(r.Name)) >= 0 {82- return fmt.Errorf("region %q is defined twice", r.Name)80+ at, ok := ParseAddr(fr.At)81+ if !ok {82+ return fmt.Errorf("region %s: invalid cell %q", fr.Name, fr.At)83+ }84+ r := Region{Name: fr.Name, At: at, Output: fr.Output,85+ File: LinkSource{Path: fr.Path, Format: fr.Format, Table: fr.Table, Query: fr.Query, Window: fr.Window}}86+ switch {87+ case fr.Window < 0:88+ return fmt.Errorf("region %s: invalid window %d", fr.Name, fr.Window)89+ case r.Linked() && (r.Output || fr.Command != ""):90+ return fmt.Errorf("region %s: a linked file has no command", fr.Name)91+ case r.Output && fr.Command != "":92+ return fmt.Errorf("region %s: a sent output has no command", fr.Name)93+ case !r.Linked() && !r.Output:94+ *old = append(*old, oldRegion{s, fr})95+ continue8396 }8497 s.regions.list = append(s.regions.list, r)85- }86- if len(s.regions.list) > 0 {87- s.regions.data = map[string]*RegionData{}98+ s.meta(nameKey(r.Name)).stale = true8899 s.regionsStale = true89100 }90- for _, r := range s.regions.list {91- if r.Linked() {92- s.meta(nameKey(r.Name)).stale = true93- }94- }95101 return nil96102 }97103 98-func decodeRegion(fr fileRegion) (Region, error) {99- if err := ValidRegionName(fr.Name); err != nil {100- return Region{}, fmt.Errorf("region %q: %w", fr.Name, err)101- }102- at, ok := ParseAddr(fr.At)103- if !ok {104- return Region{}, fmt.Errorf("region %s: invalid cell %q", fr.Name, fr.At)105- }106- if fr.Rows < 0 || fr.Cols < 0 || at.Row+fr.Rows >= MaxRows || at.Col+fr.Cols > MaxCols {107- return Region{}, fmt.Errorf("region %s: invalid size %d by %d", fr.Name, fr.Rows, fr.Cols)108- }109- r := Region{Name: fr.Name, Command: fr.Command, At: at, Rows: fr.Rows, Cols: fr.Cols, Input: fr.Input,110- File: LinkSource{Path: fr.Path, Format: fr.Format, Table: fr.Table, Query: fr.Query, Window: fr.Window}}111- switch {112- case fr.Window < 0:113- return Region{}, fmt.Errorf("region %s: invalid window %d", fr.Name, fr.Window)114- case r.Linked() && (r.Command != "" || len(fr.Reads) > 0 || len(fr.Sort) > 0 || r.Input != ""):115- return Region{}, fmt.Errorf("region %s: a linked file has no command", fr.Name)116- case r.Linked():117- r.Rows, r.Cols = 0, 0 // its size is the file's118- }119- for _, d := range fr.Reads {120- if err := ValidRegionName(d); err != nil {121- return Region{}, fmt.Errorf("region %s reads %q: %w", fr.Name, d, err)122- }123- r.Deps = append(r.Deps, d)124- }125- for _, k := range fr.Sort {126- if k.Column < 1 || k.Column > MaxCols {127- return Region{}, fmt.Errorf("region %s: invalid sort column %d", fr.Name, k.Column)104+// converting reports whether name is among the command regions being105+// converted.106+func (w *Workbook) converting(old []oldRegion, name string) bool {107+ for _, o := range old {108+ if nameKey(o.fr.Name) == nameKey(name) {109+ return true128110 }129- r.Sort = append(r.Sort, SortKey{Col: k.Column - 1, Desc: k.Desc})130111 }131- return r, nil112+ return false132113 }@@ -1,161 +0,0 @@
1-package sheet2-3-import (4- "fmt"5- "slices"6- "strings"7-)8-9-// The regions' dependency graph: a command reading $r1 depends on region10-// r1, so refreshing r1 runs it again and then every region that reads11-// it, each after what it reads. A command can't read itself, directly12-// or through others.13-14-// RegionRefs returns the names a command reads as $name, in the order it15-// first names them, leaving out nushell's own variables.16-func RegionRefs(command string) []string {17- var out []string18- for i := 0; i < len(command); i++ {19- if command[i] != '$' {20- continue21- }22- j := i + 123- for j < len(command) && (isLetter(command[j]) || isDigit(command[j]) || command[j] == '_') {24- j++25- }26- name := command[i+1 : j]27- if name != "" && ValidRegionName(name) == nil && !slices.Contains(out, name) {28- out = append(out, name)29- }30- i = j - 131- }32- return out33-}34-35-// RegionDeps returns the regions a command reads: the names in it that36-// regions of the workbook have, as the regions spell them.37-func (w *Workbook) RegionDeps(command string) []string {38- var out []string39- for _, name := range RegionRefs(command) {40- if _, r, ok := w.Region(name); ok && r.Name == name {41- out = append(out, r.Name)42- }43- }44- return out45-}46-47-// allRegions returns every region of the workbook's sheets, sheet by48-// sheet in run order.49-func (w *Workbook) allRegions() []Region {50- var out []Region51- for _, s := range w.sheets {52- out = append(out, s.regions.list...)53- }54- return out55-}56-57-// checkDeps refuses deps for the region name when one of them reads it,58-// directly or through others.59-func (w *Workbook) checkDeps(name string, deps []string) error {60- k := nameKey(name)61- byKey := map[string]Region{}62- for _, r := range w.allRegions() {63- byKey[nameKey(r.Name)] = r64- }65- seen := map[string]bool{}66- var reads func(d string) bool67- reads = func(d string) bool {68- dk := nameKey(d)69- if dk == k {70- return true71- }72- if seen[dk] {73- return false74- }75- seen[dk] = true76- return slices.ContainsFunc(byKey[dk].Deps, reads)77- }78- for _, d := range deps {79- if reads(d) {80- return fmt.Errorf("%s can't read $%s, which reads %s: that would be a cycle", name, d, name)81- }82- }83- return nil84-}85-86-// RefreshOrder returns the regions to run when name is refreshed: name87-// (unless it's a linked file, which doesn't run),88-// then every region that reads it, directly or through others, each89-// after the regions it reads.90-func (w *Workbook) RefreshOrder(name string) []string {91- regions := w.allRegions()92- want := map[string]bool{nameKey(name): true}93- for changed := true; changed; {94- changed = false95- for _, r := range regions {96- k := nameKey(r.Name)97- if !want[k] && slices.ContainsFunc(r.Deps, func(d string) bool { return want[nameKey(d)] }) {98- want[k], changed = true, true99- }100- }101- }102- commands(regions, want)103- return w.ordered(regions, want)104-}105-106-// commands leaves out of want the linked files, which don't run.107-func commands(regions []Region, want map[string]bool) {108- for _, r := range regions {109- if r.Linked() {110- delete(want, nameKey(r.Name))111- }112- }113-}114-115-// RunOrder returns every region, each after the regions it reads, in run116-// order otherwise: what Run all runs.117-func (w *Workbook) RunOrder() []string {118- regions := w.allRegions()119- want := map[string]bool{}120- for _, r := range regions {121- want[nameKey(r.Name)] = true122- }123- commands(regions, want)124- return w.ordered(regions, want)125-}126-127-// ordered lists the regions want names, each after those it reads among128-// them, otherwise in run order.129-func (w *Workbook) ordered(regions []Region, want map[string]bool) []string {130- var out []string131- done := map[string]bool{}132- for len(out) < len(want) {133- progress := false134- for _, r := range regions {135- k := nameKey(r.Name)136- if !want[k] || done[k] {137- continue138- }139- ready := !slices.ContainsFunc(r.Deps, func(d string) bool { dk := nameKey(d); return want[dk] && !done[dk] })140- if ready {141- out, done[k], progress = append(out, r.Name), true, true142- }143- }144- if !progress {145- break // a cycle a file made: what's left doesn't run146- }147- }148- return out149-}150-151-// DependsOn returns the regions that read name directly, for saying what152-// deleting or freezing it leaves without input.153-func (w *Workbook) DependsOn(name string) []string {154- var out []string155- for _, r := range w.allRegions() {156- if slices.ContainsFunc(r.Deps, func(d string) bool { return strings.EqualFold(d, name) }) {157- out = append(out, r.Name)158- }159- }160- return out161-}@@ -4,32 +4,11 @@ import "github.com/FelineStateMachine/012/internal/formula"
44 55 // Where regions are (see region.go): the cells they cover and their66 // tables, finding them by cell and by the name formulas use, and keeping7-// them in step with inserted and deleted lines and with each other on a8-// notebook sheet.7+// them in step with inserted and deleted lines.98 109 // noRect contains no cell.1110 var noRect = Rect{From: Addr{Col: -1, Row: -1}, To: Addr{Col: -1, Row: -1}}1211 13-// regionArea is the cells a region's definition reserves: a command14-// region's label line and table as last shown, at least one column15-// wide; a linked file's first cell.16-func (s *Sheet) regionArea(r Region) Rect {17- rows := r.Rows18- if r.Linked() {19- rows = 020- }21- return Rect{From: r.At, To: Addr{Col: min(r.At.Col+max(r.Cols, 1)-1, MaxCols-1), Row: min(r.At.Row+rows, MaxRows-1)}}22-}23-24-// tableOrigin is where a region's table starts: under a command25-// region's label line, at a linked file's anchor.26-func tableOrigin(r Region) Addr {27- if r.labelled() {28- return Addr{Col: r.At.Col, Row: r.At.Row + 1}29- }30- return r.At31-}32-3312 // RegionTable returns the cells of a region's table, header row3413 // included, when it has one.3514 func (s *Sheet) RegionTable(name string) (Rect, bool) {@@ -38,39 +17,31 @@ func (s *Sheet) RegionTable(name string) (Rect, bool) {
3817 return Rect{}, false3918 }4019 r := s.regions.list[i]41- rows, cols := r.Rows, r.Cols42- if r.Linked() {43- me := s.regionMeta[nameKey(r.Name)]44- if me == nil || me.why != "" {45- return Rect{}, false46- }47- rows, cols = me.rows, me.cols48- }49- o := tableOrigin(r)50- if rows == 0 || cols == 0 || o.Row+rows-1 >= MaxRows {20+ me := s.regionMeta[nameKey(r.Name)]21+ if me == nil || me.why != "" || me.rows == 0 || me.cols == 0 {5122 return Rect{}, false5223 }53- return Rect{From: o, To: Addr{Col: min(o.Col+cols-1, MaxCols-1), Row: o.Row + rows - 1}}, true24+ o := r.At25+ return Rect{From: o, To: Addr{Col: min(o.Col+me.cols-1, MaxCols-1), Row: min(o.Row+me.rows-1, MaxRows-1)}}, true5426 }5527 5628 // covered is the cells a region holds: those it wrote, or while it's57-// yet to write them, those its definition reserves.29+// yet to write them, its anchor.5830 func (s *Sheet) covered(r Region) Rect {5931 if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has {6032 return me.written6133 }62- return s.regionArea(r)34+ return Rect{From: r.At, To: r.At}6335 }6436 65-// RegionAt returns the region covering the cell at a, and whether a is on66-// its label line.67-func (s *Sheet) RegionAt(a Addr) (Region, bool, bool) {37+// RegionAt returns the region covering the cell at a.38+func (s *Sheet) RegionAt(a Addr) (Region, bool) {6839 for _, r := range s.regions.list {6940 if s.covered(r).Contains(a) {70- return r, r.labelled() && a.Row == r.At.Row, true41+ return r, true7142 }7243 }73- return Region{}, false, false44+ return Region{}, false7445 }7546 7647 // InRegion returns a cell of r a region covers, and the region.@@ -86,14 +57,13 @@ func (s *Sheet) InRegion(r Rect) (Addr, Region, bool) {
8657 // shiftRegions keeps the sheet's regions in step with rows or columns8758 // inserted or deleted, before the cells move: a region the lines reach8859 // is emptied first, so cells moving where it was aren't taken for its89-// own, and moves and resizes as a range would; one whose anchor is90-// deleted goes. Once the change ends, command regions are written again91-// from their tables and linked files read again.60+// own, and its anchor moves as a cell would; one whose anchor is deleted61+// goes. Once the change ends, the regions emptied are fed again.9262 func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {9363 if len(s.regions.list) == 0 {9464 return9565 }96- cell, rng := formula.AxisMaps(rows, sp)66+ cell, _ := formula.AxisMaps(rows, sp)9767 st := s.regionsCopy()9868 st.list = st.list[:0]9969 for _, r := range s.regions.list {@@ -106,23 +76,14 @@ func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {
10676 st.list = append(st.list, r) // before the lines: untouched10777 continue10878 }109- me := s.meta(nameKey(r.Name))110- for _, c := range s.emptyRegion(r, me) {79+ for _, c := range s.emptyRegion(s.meta(nameKey(r.Name))) {11180 s.wb.markDirty(c)11281 }11382 at, ok := cell(r.At)114- moved, ok2 := rng(s.regionArea(r))115- if !ok || !ok2 {116- delete(st.data, nameKey(r.Name))83+ if !ok {11784 continue11885 }119- r.At, r.Rows = at, moved.To.Row-at.Row120- if r.Cols > 0 {121- r.Cols = moved.To.Col - at.Col + 1122- }123- if r.Linked() {124- r.Rows, r.Cols = 0, 0125- }86+ r.At = at12687 st.list = append(st.list, r)12788 }12889 if !sameRegions(st, s.regions) {@@ -131,38 +92,8 @@ func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {
13192 s.regionsStale = true13293 }13394 134-// RegionLabels returns the label lines of the command regions on row,135-// each as wide as its table, for drawing them.136-func (s *Sheet) RegionLabels(row int) []Rect {137- var out []Rect138- for _, r := range s.regions.list {139- if r.labelled() && r.At.Row == row {140- c := s.covered(r)141- out = append(out, Rect{From: r.At, To: Addr{Col: c.To.Col, Row: row}})142- }143- }144- return out145-}146-147-// CommandTables returns the tables of the command regions that cross148-// row, which are drawn as the sheet's own cells are: their label line149-// says whose they are, where a linked file's values show in italics as150-// an array's spilled values do.151-func (s *Sheet) CommandTables(row int) []Rect {152- var out []Rect153- for _, r := range s.regions.list {154- if t, ok := s.RegionTable(r.Name); ok && r.labelled() && t.From.Row <= row && row <= t.To.Row {155- out = append(out, t)156- }157- }158- return out159-}160-161-// spanOf is n rows inserted at row at, or deleted when n is negative.162-func spanOf(at, n int) formula.Span { return formula.Span{At: at, N: n, Size: MaxRows} }163-164-// regionName resolves a name formulas use, nu.r1, to the region's sheet165-// and table.95+// regionName resolves a name formulas use, nu.sales, to the region's96+// sheet and table.16697 func (w *Workbook) regionName(k string) (*Sheet, Rect, bool) {16798 if len(k) <= len(regionPrefix) || k[:len(regionPrefix)] != nameKey(regionPrefix) {16899 return nil, Rect{}, false@@ -197,41 +128,21 @@ func (w *Workbook) regionsInUse() []namedUsers {
197128 return out198129 }199130 200-// fitRegion makes room under the region with key k for a table of rows201-// rows: inserting rows below its table, or deleting those it no longer202-// needs when nothing else is on them.203-func (s *Sheet) fitRegion(k string, rows int) {204- r := s.regions.list[s.regionIndex(k)]205- end := r.At.Row + r.Rows // the table's last row131+// boundRegion is a name in a formula that isn't a named range: a132+// region's table (nu.r1), #REF! while it has none, or the name as133+// written, which shows #NAME?.134+func (s *Sheet) boundRegion(nn formula.Name) Node {135+ t, r, ok := s.wb.regionName(nameKey(nn.Name))206136 switch {207- case rows > r.Rows:208- below := rowRect(end+1, MaxRows-1)209- for range s.cells.anyKeysIn(below) {210- _ = s.insert(true, end+1, rows-r.Rows) // rows pushed past the edge: the table is cut instead211- return212- }213- case rows < r.Rows:214- from := r.At.Row + rows + 1215- spare := rowRect(from, end)216- area := s.regionArea(r)217- for a := range s.cells.anyKeysIn(spare) {218- if !area.Contains(a) {219- return // something else is on those rows220- }221- }222- s.restructure(true, spanOf(from, -(end-from+1)))137+ case !ok:138+ return nn139+ case r == (Rect{}):140+ return formula.RefErr{}223141 }224-}225-226-// nextRegionRow is where a new region's label goes on a notebook: below227-// the last cell and region, a row left free, or row 1 on an empty sheet.228-func (s *Sheet) nextRegionRow() int {229- next := 0230- if used, ok := s.UsedRange(); ok {231- next = used.To.Row + 2232- }233- for _, r := range s.regions.list {234- next = max(next, r.At.Row+r.Rows+2)142+ sheet := ""143+ if t != s {144+ sheet = t.name235145 }236- return min(next, MaxRows-1)146+ const fixed = formula.AbsCol | formula.AbsRow147+ return formula.Range{Rect: r, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}237148 }@@ -1,68 +0,0 @@
1-package sheet2-3-import "slices"4-5-// Sorting a command region's table: the order is the region's, kept6-// when it runs again (see region.go).7-8-// sortedTable is the order of d's rows: the header first, then the rest9-// by keys.10-func sortedTable(d *RegionData, keys []SortKey) []int {11- order := make([]int, d.Rows)12- for i := range order {13- order[i] = i14- }15- if len(keys) == 0 || d.Rows < 3 {16- return order17- }18- slices.SortStableFunc(order[1:], func(i, j int) int {19- for _, k := range keys {20- a, _ := d.At(i, k.Col)21- b, _ := d.At(j, k.Col)22- if c := compareKey(a, b, k.Desc); c != 0 {23- return c24- }25- }26- return 027- })28- return order29-}30-31-// compareKey orders two values of a sort key, blanks last either way.32-func compareKey(a, b Value, desc bool) int {33- switch {34- case a.Kind == Empty && b.Kind == Empty:35- return 036- case a.Kind == Empty:37- return 138- case b.Kind == Empty:39- return -140- case desc:41- return -sortCompare(a, b)42- }43- return sortCompare(a, b)44-}45-46-// SortRegion sorts a region's table below its header by keys, columns of47-// the sheet, as one undo step: the order is the region's, so the table48-// stays sorted when it runs again.49-func (s *Sheet) SortRegion(name string, keys []SortKey) error {50- i := s.regionIndex(nameKey(name))51- if i < 0 {52- return ErrNoRegion53- }54- r := s.regions.list[i]55- if r.Linked() {56- return errFileRegion57- }58- rel := make([]SortKey, len(keys))59- for j, key := range keys {60- rel[j] = SortKey{Col: key.Col - r.At.Col, Desc: key.Desc}61- }62- s.change("sort "+r.Name, s.regionArea(r), func() {63- st := s.regionsCopy()64- st.list[i].Sort = rel65- s.putRegions(st)66- })67- return nil68-}@@ -3,30 +3,10 @@ package sheet
33 // Writing regions' cells (see region.go). Every region's cells go44 // through writeTable: a header and rows under it, written as spilled55 // cells are, only those that differ, the region's cells it no longer6-// needs cleared. Rows arrive from live operations (live.go); a command7-// region's are its RegionData, written again after any change that8-// touched its sheet's regions (settleRegions). A table that would9-// overwrite other contents isn't shown: a command region's label, or a10-// linked file's first cell, says where it's blocked, and clearing that11-// cell has it shown (a linked file is read again for it).12-13-// SetRegionStatus sets what a region's label says it's doing, such as14-// "Running…", "" for nothing. It isn't an undo step.15-func (s *Sheet) SetRegionStatus(name, status string) {16- k := nameKey(name)17- if s.regionIndex(k) < 0 {18- return19- }20- me := s.meta(k)21- if me.status == status {22- return23- }24- me.status = status25- s.regionsStale = true26- if s.wb.hist.open == nil {27- s.wb.settleRegions()28- }29-}6+// needs cleared. Rows arrive from live operations (live.go). A table7+// that would overwrite other contents isn't shown: the region's first8+// cell says where it's blocked, and clearing that cell has its source9+// send it again.3010 3111 // meta is the region with key k's meta, made when missing.3212 func (s *Sheet) meta(k string) *regionMeta {@@ -42,18 +22,11 @@ func (s *Sheet) meta(k string) *regionMeta {
4222 }4323 4424 // regionTouched notes that the cell at a is being placed: a region45-// blocked by it tries again, a command region from its table, a linked46-// file by being read again.25+// blocked by it tries again, its source sending its rows again.4726 func (s *Sheet) regionTouched(a Addr) {4827 for _, r := range s.regions.list {49- me := s.regionMeta[nameKey(r.Name)]50- if me == nil || me.why == "" || !me.need.Contains(a) {51- continue52- }53- if r.Linked() {28+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.why != "" && me.need.Contains(a) {5429 me.stale = true55- } else {56- s.regionsStale = true5730 }5831 }5932 }@@ -88,10 +61,8 @@ func (w *Workbook) settleRegions() {
8861 }8962 }9063 91-// writeRegions writes every command region's table on s from its data,92-// empties the linked files that are to be read again, and clears the93-// cells of regions gone, returning the cells that changed and the94-// formulas naming a region whose table moved.64+// writeRegions empties the regions that are to be fed again and clears65+// the cells of regions gone, returning the cells that changed.9566 func (s *Sheet) writeRegions() []loc {9667 var changed []loc9768 for k, me := range s.regionMeta {@@ -100,74 +71,21 @@ func (s *Sheet) writeRegions() []loc {
10071 delete(s.regionMeta, k)10172 }10273 }103- // Tables first leave the cells they no longer need, so regions that104- // moved past each other don't find each other in the way.105- type table struct {106- header LiveRow107- rows []LiveRow108- }109- tables := make([]table, len(s.regions.list))110- for i, r := range s.regions.list {111- me := s.meta(nameKey(r.Name))112- switch {113- case !r.Linked():114- header, rows := s.dataRows(r)115- tables[i] = table{header, rows}116- cols, nrows := tableSize(me, header, rows, true)117- keep := s.tableArea(r, cols, nrows)118- changed = append(changed, s.clearOwned(me, keep)...)119- if me.has {120- me.written, me.has = intersectRect(me.written, keep) // what it holds now121- }122- case me.reread:74+ for _, r := range s.regions.list {75+ if me := s.meta(nameKey(r.Name)); me.reread {12376 me.reread = false124- changed = append(changed, s.emptyRegion(r, me)...)125- }126- }127- for i, r := range s.regions.list {128- if r.Linked() {129- continue130- }131- me := s.meta(nameKey(r.Name))132- changed = append(changed, s.writeTable(r, me, tables[i].header, tables[i].rows, true)...)133- if tables[i].header == nil && me.why == "" {134- me.written = s.regionArea(r) // not run: its place is kept77+ changed = append(changed, s.emptyRegion(me)...)13578 }13679 }13780 return changed13881 }13982 140-// dataRows is a command region's table as rows, the header first, the141-// rest in the region's order; nil when it hasn't run.142-func (s *Sheet) dataRows(r Region) (LiveRow, []LiveRow) {143- d := s.regions.data[nameKey(r.Name)]144- if d == nil || d.Rows == 0 {145- return nil, nil146- }147- order := sortedTable(d, r.Sort)148- row := func(i int) LiveRow {149- out := make(LiveRow, d.Cols)150- for c := range d.Cols {151- v, f := d.At(order[i], c)152- out[c] = LiveCell{V: v, F: f}153- }154- return out155- }156- rows := make([]LiveRow, d.Rows-1)157- for i := range rows {158- rows[i] = row(i + 1)159- }160- return row(0), rows161-}162-163-// emptyRegion clears a region's cells, outside the undo history: a164-// linked file's, to be read again whole, which it is marked stale for.165-func (s *Sheet) emptyRegion(r Region, me *regionMeta) []loc {83+// emptyRegion clears a region's cells, outside the undo history, to be84+// fed again whole, which it is marked stale for.85+func (s *Sheet) emptyRegion(me *regionMeta) []loc {16686 changed := s.clearOwned(me, noRect)16787 me.has, me.rows, me.cols, me.why = false, 0, 0, ""168- if r.Linked() {169- me.data, me.stale = 0, true170- }88+ me.data, me.stale = 0, true17189 return changed17290 }17391 @@ -197,12 +115,10 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
197115 }198116 changed = append(changed, s.clearOwned(me, need)...)199117 me.written, me.has = need, true200- if r.labelled() {201- changed = s.writeRegionCell(r.At, LiveCell{V: Value{Kind: Text, Str: s.regionLabel(r)}}, me, changed)202- } else if me.why != "" || nrows == 0 && me.err != "" {118+ if me.why != "" || nrows == 0 && me.err != "" {203119 changed = s.writeRegionCell(r.At, LiveCell{V: ErrRef}, me, changed)204120 }205- o := tableOrigin(r)121+ o := r.At206122 if cols > 0 {207123 changed = s.writeRow(o, header, cols, true, me, changed)208124 }@@ -214,7 +130,7 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
214130 }215131 }216132 me.rows, me.cols = nrows, cols217- if r.Linked() && !me.fitted && nrows > 1 {133+ if !me.fitted && nrows > 1 {218134 me.fitted = true219135 s.fitTable(o, nrows, cols)220136 }@@ -245,16 +161,11 @@ func tableSize(me *regionMeta, header LiveRow, rows []LiveRow, reset bool) (cols
245161 return cols, nrows246162 }247163 248-// tableArea is the cells r takes to show a table of cols by nrows: its249-// label line and the table, or its anchor alone for an empty linked250-// file.164+// tableArea is the cells r takes to show a table of cols by nrows, or165+// its anchor alone for an empty one.251166 func (s *Sheet) tableArea(r Region, cols, nrows int) Rect {252- o := tableOrigin(r)253- end := o.Row + nrows - 1254- if nrows == 0 {255- end = r.At.Row256- }257- return Rect{From: r.At, To: Addr{Col: o.Col + max(cols, 1) - 1, Row: end}}167+ end := r.At.Row + max(nrows, 1) - 1168+ return Rect{From: r.At, To: Addr{Col: r.At.Col + max(cols, 1) - 1, Row: end}}258169 }259170 260171 // writeRow writes a table's row at at, cols cells wide, adding the cells@@ -287,16 +198,6 @@ func (s *Sheet) writeRegionCell(a Addr, c LiveCell, me *regionMeta, changed []lo
287198 return changed288199 }289200 290-// regionLabel is what a command region's label line says: its name and291-// command, and what it's doing.292-func (s *Sheet) regionLabel(r Region) string {293- label := r.Name + " " + r.Command294- if st := s.RegionStatus(r.Name); st != "" {295- label += " " + st296- }297- return label298-}299-300201 // inTheWay returns a cell of need holding something the region with301202 // meta me didn't write. Only what the region didn't hold is looked at:302203 // nothing else can be placed in a region's cells.@@ -113,9 +113,10 @@ type Sheet struct {
113113 rules rulesState // conditional formats and data validation, see rules.go114114 looks looksCache // how the rules draw cells, see looks.go115115 116- // regions are the sheet's regions and the data they show, and117- // regionMeta what isn't undone of them; regionsStale has their cells118- // written again once the change ends. See region.go.116+ // regions are the sheet's regions and, on a notebook tab, its cells;117+ // regionMeta what isn't undone of the regions; regionsStale has their118+ // cells written again once the change ends. See region.go and119+ // notebook.go.119120 regions regionState120121 regionMeta map[string]*regionMeta121122 regionsStale bool@@ -241,11 +242,11 @@ func (s *Sheet) Set(a Addr, input string) error {
241242 if _, ok := s.SpillAnchor(a); ok {242243 return ErrSpillEdit243244 }244- if r, _, ok := s.RegionAt(a); ok {245+ if r, ok := s.RegionAt(a); ok {245246 if r.Linked() {246247 return ErrLinkedEdit247248 }248- return ErrRegionEdit249+ return ErrOutputEdit249250 }250251 var err error251252 s.change("edit "+a.String(), Rect{From: a, To: a}, func() { err = s.put(a, input) })@@ -1,35 +0,0 @@
1-package sheet2-3-import "slices"4-5-// The lines typed at a notebook's prompt, which Up and Down recall. They6-// belong to the workbook and are saved with it ("shellHistory", which7-// needs no version), so each notebook remembers its own. Adding one isn't8-// an undo step.9-10-// maxShellHistory is how many lines the history keeps.11-const maxShellHistory = 10012-13-// ShellHistory returns the lines typed at the prompt, oldest first.14-func (w *Workbook) ShellHistory() []string { return slices.Clone(w.shellHistory) }15-16-// AddShellHistory puts line at the end of the history, moving it there17-// if it's already in it.18-func (w *Workbook) AddShellHistory(line string) {19- if line == "" {20- return21- }22- w.shellHistory = slices.DeleteFunc(w.shellHistory, func(l string) bool { return l == line })23- w.shellHistory = append(w.shellHistory, line)24- if n := len(w.shellHistory); n > maxShellHistory {25- w.shellHistory = slices.Clone(w.shellHistory[n-maxShellHistory:])26- }27-}28-29-// readShellHistory keeps a file's history, its last maxShellHistory30-// lines.31-func (w *Workbook) readShellHistory(lines []string) {32- for _, l := range lines {33- w.AddShellHistory(l)34- }35-}@@ -57,7 +57,7 @@ type Workbook struct {
5757 macros []Macro // see macros.go5858 macroOrigin string5959 60- shellHistory []string // lines typed at a notebook's prompt: shellhistory.go60+ nb notebookState // notebooks' outputs: notebook.go6161 6262 // structural is set when sheets were added, deleted or renamed during6363 // the open change, which then recalculates everything: references by@@ -224,7 +224,7 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
224224 }225225 cp := w.newSheet(w.freeName("Copy of " + s.name))226226 for a, c := range s.cells.all() {227- if _, _, ok := s.RegionAt(a); ok && c.spilled {227+ if _, ok := s.RegionAt(a); ok && c.spilled {228228 c = frozen(c) // a region's table, as values: the copy has no regions229229 }230230 c := c.clone()