Linked files are regions of the notebook's region list: one indexed set per sheet, one write path (writeTable), rows arriving as LiveOps named by region. A shell region's table goes through the same path from its RegionData. Per kind: a shell region's table is in the undo state, as a run was the user's and can't be had again; a linked file's rows aren't, as undo can't bring back a file, so it is read again when undo brings it back or it moves. Linked files are named after their file, read by formulas as nu.name and by commands as $name, never run, and share the workbook's one trust (macroOrigin). The file keeps them in regions with a path; links and linkOrigin go. Regions are emptied before inserted lines move cells, and written again after, so cells moving where a region was aren't taken for its own; a blocked linked file retries by being read again.
- commit
- 5105a813ddd20dad5ba4f4f15186d15a77195ae7
- parent
- c6a7d87a33722d7e5ed59e390173930f7ee4ccb6
- author
- Dami <dami.etoile@gmail.com>
- kind
- refactor risk score 2.7 of 3, Jev's estimate
@@ -215,37 +215,36 @@ style.
215215 format) keeps only its formatting and note. A blocked anchor shows216216 `#REF!` with the reason. `Set` refuses spilled cells; files keep only217217 the anchor.218-- **Linked regions.** A sheet may hold regions whose rows come from219- outside the workbook (`linked.go`, `live.go`): an anchor, the source's220- definition (path, format, table or query, the rows to keep), and the221- cells it has written, spilled cells as an array's are, so they are222- refused by `Set`, constants to formulas, values to copies and exports,223- and never saved (the file keeps the definition, `linkfile.go`). Rows224- arrive as live operations, the change stream below; placing a cell in225- a region (a format, undo) keeps the region's value. The definitions226- are replaced whole on every change, so undo steps keep them; a region227- undo puts back, or one moved by inserted lines, is emptied and marked228- stale, and the UI reads its source again.229218 - **Regions.** A region (`region.go`) is a block of cells whose values230- come from outside the engine: an anchor, its label line, and a231- definition naming what fills it (a notebook's nushell command, opaque232- to the engine), with the data it shows (`RegionData`) handed over by233- the UI (`ShowRegion`). Definitions and data are the sheet's234- `regionState`, replaced whole on every change so undo steps keep them;235- what isn't undone (a label's status, the cells written) is kept236- beside it. After a change touching regions, their cells are written237- again (`regionwrite.go`) as spilled cells are, derived and outside the238- undo history, only those that differ, and cells no region covers any239- more are cleared; a table that would overwrite other contents shows240- only its label, saying where. On a notebook sheet a table that grows241- or shrinks inserts or deletes rows under it inside the same step, so242- the rows below move as they would by hand. Formulas name a region's243- table as `nu.name`, resolved when evaluated as named ranges are; the244- regions' `$name` references make a dependency graph245- (`regiongraph.go`) giving refresh and run orders, and cycles are246- refused. The file keeps the definitions (`regionfile.go`). Anything247- that fills cells from outside (a file followed as it grows) is a248- region with another kind of command.219+ come from outside the engine, of two kinds: a command region, a220+ notebook's nushell command (opaque to the engine) with a label line221+ above its table, and a linked file (`linked.go`), a file the UI222+ follows, its table at its anchor. Both are one list per sheet, the223+ sheet's `regionState`, replaced whole on every change so undo steps224+ keep the definitions; what isn't undone (a label's status, the cells225+ written, how a linked file's reading goes) is kept beside it226+ (`regionMeta`). Their cells have one write path, `writeTable`227+ (`regionwrite.go`): a header and rows, written as spilled cells are,228+ derived and outside the undo history, only those that differ, with229+ the region's cells it no longer needs cleared; a table that would230+ overwrite other contents isn't shown, its label or first cell saying231+ where, and clearing that cell shows it. Rows arrive as live232+ operations, the change stream below. What undo keeps of the rows is a233+ policy per kind: a command region's table (`RegionData`, handed over234+ by `ShowRegion`) is in the undo state, since a run was the user's235+ action and its output can't be had again, and is written again from236+ there whenever its region moves; a linked file's rows are only in its237+ cells, since undo can't bring back what a file held, so a linked file238+ undo brings back or moves is emptied and read again. Placing a cell in239+ a region (a format, undo) keeps the region's value. On a notebook240+ sheet a command region's table that grows or shrinks inserts or241+ deletes rows under it inside the same step, so the rows below move as242+ they would by hand (`regionshape.go`). Formulas name a region's table243+ as `nu.name`, resolved when evaluated as named ranges are; commands244+ read regions as `$name`, which makes a dependency graph245+ (`regiongraph.go`) giving refresh and run orders, cycles refused, and246+ a linked file in it never runs. The file keeps the definitions247+ (`regionfile.go`).249248 - **Rules.** A sheet's conditional formats and data validation250249 (`rules.go`, `condfmt.go`, `validation.go`) are lists of rules on251250 ranges, replaced whole on every change so undo steps keep them as they@@ -266,12 +265,14 @@ style.
266265 267266 ### The change stream268267 269-What reads a linked region's source hands the workbook `sheet.LiveOp`s,270-each applied at once by `Workbook.ApplyLive`:268+Rows reach a region as `sheet.LiveOp`s, each applied at once by269+`Workbook.ApplyLive` through the regions' one write path: what follows270+a linked file sends them, and a command region's table is written the271+same way from its `RegionData`.271272 272273 | Field | Holds |273274 |---|---|274-| `Link` | the region, by ID |275+| `Region` | the region, by name, as the file keeps it |275276 | `At` | when the source changed |276277 | `Reset` | the rows replace every row under the header; otherwise they follow the last |277278 | `Header` | the first row, when it is new or changed |@@ -286,7 +287,8 @@ step (a macro run) they recalculate when it ends. An op carries whole
286287 values and names nothing but its region, so applying the same ops in the287288 same order to the same workbook makes the same cells: it is what a288289 session following another's over `012 serve` would be sent, beside the289-steps `Batch` records. `OnLive` times each op for telemetry.290+steps `Batch` records (a command region's run is one of those, since291+its table is in the undo state). `OnLive` times each op for telemetry.290292 291293 ### Functions292294 @@ -38,7 +38,7 @@ it lags, and past a second it stalls.
3838 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |3939 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |4040 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |41-| Following files | A log growing by 10,000 rows a second, formulas over it: each update (about 2,500 rows, four times a second) applied in 7 to 10 ms keeping every row, 1.6 to 2.2 ms keeping the last 1000, 14 ms the last 10,000; frames 2 to 3 ms at 200 x 60 | | Rows past `max-cells`: left out, with a note | Writing the rows and recalculating what reads them; a window rewrites its rows on every update |41+| Following files | A log growing by 10,000 rows a second, formulas over it: each update (about 2,500 rows, four times a second) applied in 7 ms keeping every row, 1.7 to 2 ms keeping the last 1000, 14 ms the last 10,000; frames 2.5 to 3.6 ms at 200 x 60 | | Rows past `max-cells`: left out, with a note | Writing the rows and recalculating what reads them; a window rewrites its rows on every update |4242 | Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 106 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4343 4444 ## Sheet size@@ -358,9 +358,9 @@ each update:
358358 359359 | Rows kept | Update applied (slowest) | Frame (slowest) | Rows a second taken in |360360 |---|---|---|---|361-| Every row (30,000) | 7.5 to 10.5 ms | 2.3 to 2.7 ms | 8,100 to 8,300 |362-| The last 1000 | 1.6 to 2.2 ms | 2.9 ms | 8,300 to 8,400 |363-| The last 10,000 | 13.7 to 14.2 ms | 2.2 to 2.8 ms | 8,000 to 8,100 |361+| Every row (30,000) | 6.7 to 6.8 ms | 3.2 to 3.6 ms | 8,200 |362+| The last 1000 | 1.7 to 2 ms | 2.8 to 3.2 ms | 8,300 |363+| The last 10,000 | 13.8 to 13.9 ms | 2.6 to 3 ms | 8,000 |364364 365365 (Rows a second counts to the last row read, after the writer stopped;366366 the writer itself manages a little under 10,000 with its pauses.) An@@ -42,12 +42,20 @@ file's rows, typed as an import types them. The region is the file's:
4242 - its values show in italics, as an array's spilled values do, and4343 copying them copies values;4444 - it grows to the right and down as the file does. Where it would write45- over a cell holding something, it stops and says which cell; clear the46- cell and use Data > Linked file > Read again;45+ over a cell holding something, its rows aren't shown, and its first46+ cell (`#REF!`) and the context line say which cell is in the way;47+ clearing that cell reads the file again;4748 - inserting or deleting rows or columns before it moves it, and it reads4849 the file again there; deleting its first cell's row or column unlinks4950 it.5051 52+A linked file is a region, as a [notebook's](../terminal/nushell.md#notebooks)53+shell regions are, named after the file (`app` for `app.csv`, `app_2`54+for a second link to it): formulas read its table, header row included,55+as `nu.app` (`=SUM(nu.app)`, on any sheet), and shell commands read it as56+`$app`. Refreshing a shell region that reads it runs its command on the57+rows the file has now; the linked file itself has no command to run.58+5159 The sheet's tab carries a mark, and the context line says what the5260 region under the pointer is doing:5361 @@ -95,7 +103,7 @@ only one:
95103 ## Saving and opening96104 97105 A spreadsheet keeps what each region reads, never its rows (see98-[The .012 format](format.md#linked-files)): the file's path relative to106+[The .012 format](format.md#regions)): the file's path relative to99107 the spreadsheet's folder (absolute when the file is elsewhere and was100108 linked by an absolute path), its format, the SQLite table or query, and101109 how many rows to keep. Saving in another folder keeps the paths naming@@ -112,11 +120,13 @@ a spreadsheet linking a file outside it shows why in the region.
112120 Following reads files, so a spreadsheet made on another computer asks113121 once before following files outside its own folder (an absolute path,114122 or one through `..`): the context line names them, Enter follows them115-and records this computer as trusted in the spreadsheet (`linkOrigin`,116-saved with it), Esc leaves their regions empty. Files in the spreadsheet's117-folder or below, and spreadsheets linked on this computer, never ask.118-Macros are trusted the same way, separately119-([Macros](../sheets/macros.md#macros-from-other-computers)).123+and records this computer as trusted in the spreadsheet, Esc leaves124+their regions empty. Files in the spreadsheet's folder or below never125+ask. The trust is the spreadsheet's one trust (`macroOrigin`, saved with126+it), which its macros and shell commands need too127+([Macros](../sheets/macros.md#macros-from-other-computers)): a128+spreadsheet made or trusted on this computer follows its files, runs its129+macros and its commands without asking.120130 121131 What following costs, measured, is in122132 [Bounds of support](../contributing/limits.md#following-files).@@ -38,7 +38,8 @@ Only what's typed is saved. What 012 computes is computed again when the
3838 file opens: formula results, [pivot tables](#pivot-tables)' results, and3939 the arrays formulas [spill](../formulas/arrays.md), whose cells are kept4040 only for their formatting and notes, and the rows of41-[linked files](following.md), read from their files again.41+[regions](#regions), made again by their commands or read again from42+their files.4243 4344 ## Reading and writing4445 @@ -85,13 +86,10 @@ save), so they raise no version:
8586 | `charts` | a sheet | One chart per line: `type` (`column`, `bar`, `line`, `pie`, `area`, `scatter`), `data`, `at`, `width`, `height`, `byRow`, `header`, `labels`, `title`, and options left out at their defaults: `stack` (`stacked`, `percent`), `trend`, `min`, `max`, `log`, `gridlines` (only when off), `legend` (`right`, `none`). A build that charts but lacks a chart's type refuses the file |8687 | `protected` | a sheet | [Protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges): `{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}` |8788 | `conditionalFormats`, `validations` | a sheet | [Rules](#conditional-formats-and-data-validation), one per line |88-| `links` | a sheet | [Linked files](#linked-files), one per line; older builds show their cells empty |8989 | `arithmetic` | the workbook | `decimal` for [decimal arithmetic](../formulas/decimal.md) |9090 | `locale` | the workbook | The [locale](../sheets/locale.md) it's typed and shown in (`"de-DE"`), when File > Settings > Locale chose one; without it, the file follows the reader's `locale` setting. Cells are stored the same way in every locale: `input` is always as typed in en-US (`1,234.5`, `9/26/2026`, `=ROUND(A1,2)`) |91-| `linkOrigin` | the workbook | The computer its linked files were linked or trusted on: see [Following files](following.md#files-from-elsewhere) |92-| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |93-| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on (macros' and shell regions' both): see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |94-| `notebook`, `regions` | a sheet | A [notebook sheet](../terminal/nushell.md#notebooks) and its shell regions: see [Regions](#regions). Older builds open the sheet without them |91+| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on (macros', shell regions' and [linked files'](following.md#files-from-elsewhere) alike): see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |92+| `notebook`, `regions` | a sheet | A [notebook sheet](../terminal/nushell.md#notebooks), and its shell regions and [linked files](following.md): see [Regions](#regions). Older builds open the sheet without them |9593 | `shellHistory` | the workbook | The lines typed at its [notebook prompt](../terminal/nushell.md#the-prompt), oldest first, for Up and Down |9694 9795 ## Column and row formats@@ -163,50 +161,38 @@ with its data.
163161 `condition` with its `value`.164162 - `rowTotals` and `columnTotals` are the grand total row and column.165163 166-## Linked files167-168-A sheet's [linked files](following.md) are a list after its cells,169-charts and rules, one per line, holding where each region starts and170-what it reads, never its rows:171-172-```json173- "links": [174- {"at":"A1","path":"logs/app.csv","window":500},175- {"at":"F1","path":"../shop.db","format":"SQLite","query":"SELECT * FROM orders"}176- ]177-```178-179-- `at` is the region's top-left cell.180-- `path` is the file, relative to the workbook's folder when it's saved181- there or below, and otherwise as it was linked.182-- `format` names the file's format (`CSV`, `TSV`, `JSON`, `NUON`,183- `XLSX`, `SQLite`, `Parquet`) when it isn't told by the extension.184-- `table` or `query` say what to read from a SQLite database.185-- `window` keeps the last rows under the header, when set.186-187164 ## Regions188165 189-A [notebook sheet](../terminal/nushell.md#notebooks) has `"notebook":190-true` and its regions in a `regions` list after its cells, charts and191-rules, one per line, in run order. The file keeps each region's192-command, never its table: reopening shows the regions as not run, and193-nothing runs until you run it.166+A sheet's regions, a [notebook's](../terminal/nushell.md#notebooks)167+shell regions and [linked files](following.md), are a `regions` list168+after its cells, charts and rules, one per line, in the order they were169+made; a notebook sheet also has `"notebook": true`. The file keeps what170+each region reads, never its table: reopening shows shell regions as not171+run, and nothing runs until you run it; linked files are read again.194172 195173 ```json196174 "notebook": true,197175 "regions": [198176 {"name":"r1","command":"ls","at":"A1","rows":12,"cols":4},199- {"name":"big","command":"$r1 | where size > 1kb","at":"A16","rows":3,"cols":4,"reads":["r1"],"sort":[{"column":3,"desc":true}]}177+ {"name":"big","command":"$r1 | where size > 1kb","at":"A16","rows":3,"cols":4,"reads":["r1"],"sort":[{"column":3,"desc":true}]},178+ {"name":"app","at":"F1","path":"logs/app.csv","window":500}200179 ]201180 ```202181 203182 - `name` is what commands read it as (`$r1`) and formulas name it by204- (`nu.r1`); `command` is the nushell pipeline.183+ (`nu.r1`); `command` is a shell region's nushell pipeline.205184 - `at` is its label line's cell; `rows` and `cols` the size of its table206185 when saved, header row included, which it keeps until it runs.207186 - `reads` are the regions its command reads, `input` the range it read208187 as `$in` (`Sheet1!A1:C9`), and `sort` the columns its table is sorted209188 by, counting from 1 at its first column, with `desc` for Z to A.189+- A linked file has `path` instead of a command: the file, relative to190+ the workbook's folder when it's saved there or below, and otherwise as191+ it was linked; `at` is its table's first cell. `format` names the192+ file's format (`CSV`, `TSV`, `JSON`, `NUON`, `XLSX`, `SQLite`,193+ `Parquet`) when the extension doesn't tell it, `table` or `query` say194+ what to read from a SQLite database, and `window` keeps the last rows195+ under the header.210196 211197 The table's cells are saved only for their formatting and notes, as a212198 spill's are.@@ -101,6 +101,9 @@ big = $r1 | where size > 1kb | sort-by size --reverse
101101 $in | group-by region | transpose region rows102102 ```103103 104+A [linked file](../files/following.md) is a region too, named after the105+file: `$app` reads the rows `app.csv` has now.106+104107 A region reading another depends on it. Refreshing a region (Enter on105108 its label, **Data > Shell regions > Refresh region**) runs it again and then every region106109 that reads it, directly or through others, each after what it reads.@@ -205,8 +205,8 @@ func TestKindOf(t *testing.T) {
205205 206206 func TestUpdateOp(t *testing.T) {207207 at := time.Now()208- op := Update{Reset: true, Err: "e", Note: "n", At: at}.Op(7)209- if op.Link != 7 || !op.Reset || op.Err != "e" || op.Note != "n" || !op.At.Equal(at) {208+ op := Update{Reset: true, Err: "e", Note: "n", At: at}.Op("app")209+ if op.Region != "app" || !op.Reset || op.Err != "e" || op.Note != "n" || !op.At.Equal(at) {210210 t.Fatalf("%+v", op)211211 }212212 }@@ -45,9 +45,9 @@ type Update struct {
4545 At time.Time4646 }4747 48-// Op is u as the operation that applies it to the region id.49-func (u Update) Op(id int) sheet.LiveOp {50- return sheet.LiveOp{Link: id, At: u.At, Reset: u.Reset, Header: u.Header, Rows: u.Rows, Err: u.Err, Note: u.Note}48+// Op is u as the operation that applies it to the region named name.49+func (u Update) Op(name string) sheet.LiveOp {50+ return sheet.LiveOp{Region: name, At: u.At, Reset: u.Reset, Header: u.Header, Rows: u.Rows, Err: u.Err, Note: u.Note}5151 }5252 5353 // Interval is how often a followed source is polled.@@ -57,9 +57,6 @@ type fileFormat struct {
5757 // Macros and where they were made need no version bump either:5858 // earlier builds ignore them, and the sheets read the same.5959 MacroOrigin string `json:"macroOrigin,omitempty"`60- // LinkOrigin, like MacroOrigin, is where the linked regions were61- // made or trusted (linkfile.go).62- LinkOrigin string `json:"linkOrigin,omitempty"`6360 // ShellHistory needs no version either: see shellhistory.go.6461 ShellHistory []string `json:"shellHistory,omitempty"`6562 Macros []fileMacro `json:"macros,omitempty"`@@ -83,8 +80,6 @@ type fileSheet struct {
8380 // Rules need no version: see rulefile.go.8481 CondFormats []fileCondFormat `json:"conditionalFormats,omitempty"`8582 Validations []fileValidation `json:"validations,omitempty"`86- // Links need no version: see linkfile.go.87- Links []fileLink `json:"links,omitempty"`8883 // A notebook and its regions need no version: see regionfile.go.8984 Notebook bool `json:"notebook,omitempty"`9085 Regions []fileRegion `json:"regions,omitempty"`@@ -175,9 +170,6 @@ func (w *Workbook) headLines() string {
175170 if len(w.macros) > 0 {176171 lines = append(lines, w.macrosLines())177172 }178- if w.linkOrigin != "" && len(w.LinkedRegions()) > 0 {179- lines = append(lines, `"linkOrigin": `+jsonString(w.linkOrigin))180- }181173 if len(w.shellHistory) > 0 {182174 raw, _ := json.Marshal(w.shellHistory)183175 lines = append(lines, `"shellHistory": `+string(raw))@@ -283,10 +275,7 @@ func (s *Sheet) writeObjects(b *bufio.Writer, indent string) error {
283275 fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)284276 }285277 s.writeRules(b, indent)286- if err := s.writeRegionDefs(b, indent); err != nil {287- return err288- }289- return s.writeLinks(b, indent)278+ return s.writeRegionDefs(b, indent)290279 }291280 292281 // Read loads a file written by Write, of this or an earlier version, and@@ -374,9 +363,6 @@ func (s *Sheet) read(f fileSheet) error {
374363 if err := s.readRules(f.CondFormats, f.Validations); err != nil {375364 return err376365 }377- if err := s.readLinks(f.Links); err != nil {378- return err379- }380366 if err := s.readRegions(f.Notebook, f.Regions); err != nil {381367 return err382368 }@@ -109,7 +109,6 @@ func readBook(r io.Reader, trace any) (*Workbook, error) {
109109 return nil, err110110 }111111 w.macroOrigin = f.MacroOrigin112- w.linkOrigin = f.LinkOrigin113112 w.readShellHistory(f.ShellHistory)114113 w.RecalcAll()115114 return w, nil@@ -63,7 +63,7 @@ func readWhole(data []byte) (w *Workbook, ambiguous bool, err error) {
6363 if err := w.readMacros(f.Macros); err != nil {6464 return nil, false, err6565 }66- w.macroOrigin, w.linkOrigin = f.MacroOrigin, f.LinkOrigin66+ w.macroOrigin = f.MacroOrigin6767 w.readShellHistory(f.ShellHistory)6868 w.RecalcAll()6969 return w, ambiguous, nil@@ -234,9 +234,9 @@ func streamSeeds(t testing.TB) [][]byte {
234234 []byte(`{"version": 2, "conditionalFormats": [{"ranges":"A1","iconSet":{"icons":"symbols","points":[{"type":"percent","value":"50"}]}}]}`),235235 []byte(`{"version": 2, "validations": [{"ranges":"A1","criteria":"list","items":["a"],"display":"bubbles"}]}`),236236 []byte(`{"version": 2, "heights": {"0": 2}, "merges": ["A1:"], "cells": {}}`),237- []byte(`{"version": 2, "linkOrigin": "m", "cells": {"C1": "x"}, "links": [{"at": "A1", "path": "logs/app.csv", "window": 3}, {"at": "E5", "path": "/tmp/t.db", "format": "SQLite", "query": "select 1"}]}`),238- []byte(`{"version": 4, "sheets": [{"name": "Log", "cells": {}, "links": [{"at": "B2", "path": "a.nuon"}]}, {"name": "S", "cells": {"A1": "=SUM(Log!B:B)"}}]}`),239- []byte(`{"version": 2, "cells": {}, "links": [{"at": "A1", "path": "a"}, {"at": "A1", "path": "b"}, {"at": "", "window": -2}]}`),237+ []byte(`{"version": 2, "macroOrigin": "m", "cells": {"C1": "x"}, "regions": [{"name": "app", "at": "A1", "path": "logs/app.csv", "window": 3}, {"name": "t", "at": "E5", "path": "/tmp/t.db", "format": "SQLite", "query": "select 1"}]}`),238+ []byte(`{"version": 4, "sheets": [{"name": "Log", "cells": {}, "regions": [{"name": "a", "at": "B2", "path": "a.nuon"}]}, {"name": "S", "cells": {"A1": "=SUM(Log!B:B)+SUM(nu.a)"}}]}`),239+ []byte(`{"version": 2, "cells": {}, "regions": [{"name": "a", "at": "A1", "path": "a", "command": "ls"}, {"name": "b", "at": "A3", "path": "b", "window": -2}]}`),240240 []byte(`{"version": 2, "macroOrigin": "m1", "shellHistory": ["ls", "$r1 | first"], "notebook": true, "cells": {"A3": {"bold": true}}, "regions": [241241 {"name": "r1", "command": "ls", "at": "A1", "rows": 3, "cols": 2},242242 {"name": "big", "command": "$r1 | where size > 1kb", "at": "A6", "rows": 2, "cols": 2, "reads": ["r1"], "input": "Sheet1!D1:E4", "sort": [{"column": 2, "desc": true}]}]}`),@@ -59,8 +59,6 @@ type step struct {
5959 // rules holds each touched sheet's conditional formats and data6060 // validation before the step.6161 rules map[*Sheet]rulesState62- // links holds each touched sheet's linked regions before the step.63- links map[*Sheet][]*linked6462 // regions holds each touched sheet's regions before the step.6563 regions map[*Sheet]regionState6664 // sheets is the sheet list before the step, when it changed.@@ -81,7 +79,7 @@ type colKey struct {
8179 func newStep(label string, s *Sheet, focus Rect) *step {8280 return &step{label: label, sheet: s, focus: focus, cells: map[*Sheet]*image{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},8381 names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{},84- regions: map[*Sheet]regionState{}, links: map[*Sheet][]*linked{}}82+ regions: map[*Sheet]regionState{}}8583 }8684 8785 func (st *step) empty() bool {@@ -91,7 +89,7 @@ func (st *step) empty() bool {
9189 // widthOnly reports whether the step changed nothing but column widths9290 // and row heights.9391 func (st *step) widthOnly() bool {94- return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && len(st.links) == 0 && st.sheets == nil && st.settings == nil &&92+ return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.settings == nil &&9593 st.macros == nil && len(st.regions) == 09694 }9795 @@ -232,11 +230,6 @@ func (w *Workbook) dropUnchanged(st *step) {
232230 delete(st.pivots, s)233231 }234232 }235- for s, l := range st.links {236- if sameLinks(l, s.links) {237- delete(st.links, s)238- }239- }240233 for s, r := range st.regions {241234 if sameRegions(r, s.regions) {242235 delete(st.regions, s)@@ -396,6 +389,6 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
396389 w.pushUndo(inv)397390 }398391 h.mergeWidths = false399- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && len(st.links) == 0 && st.sheets == nil && len(st.regions) == 0392+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && len(st.regions) == 0400393 return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true401394 }@@ -40,10 +40,6 @@ func (w *Workbook) restore(st *step) (inv *step, changed []loc) {
4040 changed = append(changed, w.putName(k, n)...)4141 }4242 w.restoreSheetParts(st, inv)43- for s, links := range st.links {44- inv.links[s] = s.links45- changed = append(changed, s.restoreLinks(links)...)46- }4743 return inv, changed4844 }4945 @@ -60,7 +56,7 @@ func (w *Workbook) restoreSheetParts(st, inv *step) {
6056 }6157 for s, r := range st.regions {6258 inv.regions[s] = s.regions63- s.regions, s.regionsStale = r, true59+ s.putRegionsBack(r)6460 }6561 for s, r := range st.rules {6662 inv.rules[s] = s.rules@@ -155,7 +151,6 @@ func (st *step) join(child *step) {
155151 joinMap(st.charts, child.charts)156152 joinMap(st.pivots, child.pivots)157153 joinMap(st.rules, child.rules)158- joinMap(st.links, child.links)159154 joinMap(st.regions, child.regions)160155 st.sheets = cmpOrPtr(st.sheets, child.sheets)161156 st.settings = cmpOrPtr(st.settings, child.settings)@@ -3,29 +3,22 @@ package sheet
33 import (44 "errors"55 "path/filepath"6- "slices"6+ "strconv"7+ "strings"78 "time"8-9- "github.com/FelineStateMachine/012/internal/formula"109 )1110 12-// Linked regions: a range of a sheet whose cells come from outside the13-// workbook, such as a file followed as it grows or is rewritten. A14-// region is an anchor (its top-left cell) whose rows arrive as live15-// operations (LiveOp, live.go) from whatever reads the source; the16-// engine owns its cells as it owns a spill's: they are derived cells,17-// written outside the undo history, refused by Set, constants to the18-// formulas reading them, values to copies and exports, and never saved.19-// The file keeps the region's source instead (linkfile.go), and the UI20-// reads it again on opening.21-//22-// The first row is the source's header and stays; the rows under it are23-// the data, all of them up to max-cells, or the last Window of them. A24-// region grows right and down as rows arrive; one that would write over25-// cells holding something stops, showing why, until it's read again.26-27-// LinkSource is where a linked region's rows come from, as the file28-// keeps it.11+// 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 operations16+// (live.go) and are never part of the undo state (see region.go for17+// why), so a linked file is read again whenever its cells may no longer18+// be the file's: undo put it back, it moved, or a cell blocking it was19+// cleared.20+21+// LinkSource is what a linked file reads, as the file keeps it.2922 type LinkSource struct {3023 // Path is the file, as the UI resolves it: relative to the3124 // workbook's folder when it can be (see RebaseLinks).@@ -40,12 +33,24 @@ type LinkSource struct {
4033 Window int4134 }4235 43-// LinkedRegion is a linked region as the UI shows it: where it is, what36+// liveMeta is what the rows arriving have made of a linked file, kept37+// beside its region and never undone.38+type liveMeta struct {39+ data, dropped int // rows arrived since the last reset, rows the window dropped40+ updated time.Time41+ err, note string42+ paused bool43+ stale bool // to be read again, whole44+ reread bool // to be emptied and read again once the change ends45+ fitted bool // its columns were widened to its text once46+}47+48+// LinkedRegion is a linked file as the UI shows it: where it is, what4449 // it reads, and how the reading goes.4550 type LinkedRegion struct {46- // ID names the region in the workbook while it's open, in LiveOps47- // and to the UI.48- ID int51+ // Name names the region: in LiveOps, in formulas (nu.name) and to52+ // commands ($name).53+ Name string4954 Sheet *Sheet5055 Anchor Addr5156 // Area is the cells it covers, the anchor alone while it has none.@@ -54,104 +59,64 @@ type LinkedRegion struct {
5459 // Rows counts the data rows it shows, under the header; Dropped the5560 // older rows the window let go.5661 Rows, Dropped int57- // Updated is when the source was last read, zero until it is.62+ // Updated is when the file was last read, zero until it is.5863 Updated time.Time59- // Err says why the source can't be read, or the rows can't be60- // written, "" when all is well; Note says what was left out.64+ // Err says why the file can't be read, or its rows can't be shown,65+ // "" when all is well; Note says what was left out.6166 Err, Note string62- // Paused is set while the region isn't following its source.67+ // Paused is set while the region isn't following its file.6368 Paused bool64- // Stale is set when its cells may no longer be the source's (an undo65- // took them back, or the source changed): the source is to be read66- // again, whole.69+ // Stale is set when its cells may no longer be the file's: the file70+ // is to be read again, whole.6771 Stale bool6872 }6973 70-// ErrLinkedEdit is what typing into a linked region says.74+// ErrLinkedEdit is what typing into a linked file's cell says.7175 var ErrLinkedEdit = errors.New("That cell shows part of a linked file: unlink it (Data > Linked file > Unlink) to edit it")7276 73-// linked is a region's state: its definition, replaced whole when it74-// changes so undo steps keep the old one, and what the rows written75-// have made it.76-type linked struct {77- id int78- anchor Addr79- src LinkSource80- *liveState81-}82-83-// liveState is what the source's rows have made of a region, shared by84-// the copies of its definition that undo steps keep.85-type liveState struct {86- cols, rows int // the region's size, header included; 0 by 0 when empty87- data int // data rows written, before the window88- dropped int89- updated time.Time90- err, note string91- paused bool92- stale bool93- fitted bool // its columns were widened to its text once94-}95-96-// area is the cells the region covers, the anchor alone when empty.97-func (l *linked) area() Rect {98- return Rect{From: l.anchor, To: Addr{Col: l.anchor.Col + max(l.cols, 1) - 1, Row: l.anchor.Row + max(l.rows, 1) - 1}}99-}100-101-// info describes l on s.102-func (l *linked) info(s *Sheet) LinkedRegion {103- return LinkedRegion{ID: l.id, Sheet: s, Anchor: l.anchor, Area: l.area(), Source: l.src,104- Rows: max(l.rows-1, 0), Dropped: l.dropped, Updated: l.updated, Err: l.err, Note: l.note,105- Paused: l.paused, Stale: l.stale}106-}107-108-// linkedAt is the region covering a, or nil.109-func (s *Sheet) linkedAt(a Addr) *linked {110- for _, l := range s.links {111- if l.area().Contains(a) {112- return l113- }77+// linkedInfo describes the linked file r on s.78+func (s *Sheet) linkedInfo(r Region) LinkedRegion {79+ me := s.meta(nameKey(r.Name))80+ err := me.err81+ if me.why != "" {82+ err = me.why11483 }115- return nil84+ return LinkedRegion{Name: r.Name, Sheet: s, Anchor: r.At, Area: s.covered(r), Source: r.File,85+ Rows: max(me.rows-1, 0), Dropped: me.dropped, Updated: me.updated, Err: err, Note: me.note,86+ Paused: me.paused, Stale: me.stale || me.reread}11687 }11788 118-// LinkedAt returns the linked region covering the cell at a.89+// LinkedAt returns the linked file covering the cell at a.11990 func (s *Sheet) LinkedAt(a Addr) (LinkedRegion, bool) {120- if l := s.linkedAt(a); l != nil {121- return l.info(s), true91+ if r, _, ok := s.RegionAt(a); ok && r.Linked() {92+ return s.linkedInfo(r), true12293 }12394 return LinkedRegion{}, false12495 }12596 126-// InLinked returns a cell of r in a linked region, which an edit of r127-// can't change.128-func (s *Sheet) InLinked(r Rect) (Addr, bool) {129- for _, l := range s.links {130- if l.rows == 0 && l.err == "" {131- continue // nothing written yet132- }133- if overlap, ok := intersectRect(l.area(), r); ok {134- return overlap.From, true97+// HasLinked reports whether the sheet holds a linked file.98+func (s *Sheet) HasLinked() bool {99+ for _, r := range s.regions.list {100+ if r.Linked() {101+ return true135102 }136103 }137- return Addr{}, false104+ return false138105 }139106 140-// HasLinked reports whether the sheet holds a linked region.141-func (s *Sheet) HasLinked() bool { return len(s.links) > 0 }142-143-// LinkedRegions returns the sheet's linked regions, in the order they107+// LinkedRegions returns the sheet's linked files, in the order they144108 // were made.145109 func (s *Sheet) LinkedRegions() []LinkedRegion {146- out := make([]LinkedRegion, 0, len(s.links))147- for _, l := range s.links {148- out = append(out, l.info(s))110+ var out []LinkedRegion111+ for _, r := range s.regions.list {112+ if r.Linked() {113+ out = append(out, s.linkedInfo(r))114+ }149115 }150116 return out151117 }152118 153-// LinkedRegions returns every live sheet's linked regions, sheet by154-// sheet.119+// LinkedRegions returns every live sheet's linked files, sheet by sheet.155120 func (w *Workbook) LinkedRegions() []LinkedRegion {156121 var out []LinkedRegion157122 for _, s := range w.sheets {@@ -160,209 +125,145 @@ func (w *Workbook) LinkedRegions() []LinkedRegion {
160125 return out161126 }162127 163-// LinkedRegion returns the region id names, on a live sheet.164-func (w *Workbook) LinkedRegion(id int) (LinkedRegion, bool) {165- if s, l := w.findLinked(id); l != nil {166- return l.info(s), true128+// LinkedRegion returns the linked file name names.129+func (w *Workbook) LinkedRegion(name string) (LinkedRegion, bool) {130+ if s, r, ok := w.Region(name); ok && r.Linked() {131+ return s.linkedInfo(r), true167132 }168133 return LinkedRegion{}, false169134 }170135 171-// findLinked is the region id names and its sheet, or nil.172-func (w *Workbook) findLinked(id int) (*Sheet, *linked) {173- for _, s := range w.sheets {174- for _, l := range s.links {175- if l.id == id {176- return s, l177- }178- }179- }180- return nil, nil181-}182-183-// errLinkOver is why a region can't be made where it's asked.136+// errLinkOver is why a linked file can't start where it's asked.184137 var errLinkOver = errors.New("A linked file needs an empty cell to start in")185138 186-// AddLinked makes a linked region at a reading src, as one undo step,187-// and returns its ID. It starts empty and stale: the UI reads the188-// source and sends its rows.189-func (s *Sheet) AddLinked(a Addr, src LinkSource) (int, error) {190- switch {191- case !a.Valid():192- return 0, errLinkOver193- case s.cells.filledAt(a) || s.linkedAt(a) != nil || s.InPivot(Rect{From: a, To: a}):194- return 0, errLinkOver139+// AddLinked makes a linked file at a reading src, named after the file,140+// 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.143+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 "", errLinkOver195146 }196- s.wb.linkSeq++197- l := &linked{id: s.wb.linkSeq, anchor: a, src: src, liveState: &liveState{stale: true}}198- s.change("link "+filepath.Base(src.Path), Rect{From: a, To: a}, func() {199- s.recordLinks()200- s.links = append(slices.Clone(s.links), l)201- })202- return l.id, nil203-}204-205-// SetLinkSource changes what a region reads (its window, say), as one206-// undo step; the region is read again.207-func (w *Workbook) SetLinkSource(id int, src LinkSource) error {208- s, l := w.findLinked(id)209- if l == nil {210- return ErrNoLinked147+ if _, _, ok := s.RegionAt(a); ok {148+ return "", errLinkOver211149 }212- s.change("change link "+filepath.Base(src.Path), l.area(), func() {213- s.recordLinks()214- nl := *l215- nl.src = src216- nl.stale = true217- s.links = slices.Clone(s.links)218- s.links[slices.Index(s.links, l)] = &nl219- })220- return nil150+ name := s.wb.linkName(src.Path)151+ if err := s.AddRegion(Region{Name: name, At: a, File: src}); err != nil {152+ return "", err153+ }154+ s.meta(nameKey(name)).stale = true155+ return name, nil221156 }222157 223-// ErrNoLinked is returned for a region the workbook doesn't hold:224-// unlinked, undone, or on a sheet deleted.225-var ErrNoLinked = errors.New("That linked file is no longer linked")226-227-// Unlink turns a region into the values it shows, as one undo step:228-// they stay as ordinary cells, keeping their formatting and notes, and229-// nothing reads the source for them.230-func (w *Workbook) Unlink(id int) error {231- s, l := w.findLinked(id)232- if l == nil {233- return ErrNoLinked234- }235- area := l.area()236- var cells []*Cell237- var at []Addr238- for a := range s.cells.anyKeysIn(area) {239- if c := s.cells.get(a); c.Spilled() {240- cells, at = append(cells, c.plain().withNote(c.Note)), append(at, a)158+// linkName is a free region name for the file at path: its name without159+// the extension, made a nushell variable's name (app_log), numbered when160+// taken (app_log_2).161+func (w *Workbook) linkName(path string) string {162+ base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))163+ var b strings.Builder164+ for i := range len(base) {165+ if c := base[i]; isLetter(c) || isDigit(c) || c == '_' {166+ b.WriteByte(c)167+ } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "_") {168+ b.WriteByte('_')241169 }242170 }243- s.change("unlink "+filepath.Base(l.src.Path), area, func() {244- s.recordLinks()245- s.links = slices.DeleteFunc(slices.Clone(s.links), func(x *linked) bool { return x == l })246- for i, a := range at {247- s.place(a, cells[i])248- }249- })250- return nil251-}252-253-// PauseLinked stops a region following its source, or has it follow254-// again. It isn't an edit: what follows the source reads it.255-func (w *Workbook) PauseLinked(id int, paused bool) {256- if _, l := w.findLinked(id); l != nil {257- l.paused = paused171+ name := strings.Trim(b.String(), "_")172+ if len(name) > 48 {173+ name = name[:48]258174 }259-}260-261-// ReloadLinked marks a region stale, so the UI reads its source again,262-// whole.263-func (w *Workbook) ReloadLinked(id int) {264- if _, l := w.findLinked(id); l != nil {265- l.stale = true175+ if ValidRegionName(name) != nil {176+ name = "file_" + name177+ }178+ for n := 2; w.checkRegionName(name) != nil; n++ {179+ name = strings.TrimSuffix(name, "_"+strconv.Itoa(n-1)) + "_" + strconv.Itoa(n)266180 }181+ return name267182 }268183 269-// RebaseLinks rewrites the path of every region with fn, as the UI does270-// when the workbook is saved in another folder. It isn't an edit: the271-// regions read the same files.272-func (w *Workbook) RebaseLinks(fn func(string) string) {273- for _, s := range w.sheets {274- for _, l := range s.links {275- l.src.Path = fn(l.src.Path)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 = true276196 }277197 }198+ s.regions, s.regionsStale = st, true278199 }279200 280-// recordLinks saves the sheet's regions before their first change in281-// the open step.282-func (s *Sheet) recordLinks() {283- if st := s.wb.hist.open; st != nil {284- if _, seen := st.links[s]; !seen {285- st.links[s] = s.links286- }201+// SetLinkSource changes what a linked file reads (its window, say), as202+// one undo step; it is read again.203+func (w *Workbook) SetLinkSource(name string, src LinkSource) error {204+ s, r, ok := w.Region(name)205+ if !ok || !r.Linked() {206+ return ErrNoRegion287207 }208+ k := nameKey(r.Name)209+ s.meta(k).reread = true210+ s.change("change link "+r.Name, s.covered(r), func() {211+ st := s.regionsCopy()212+ st.list[s.regionIndex(k)].File = src213+ s.putRegions(st)214+ })215+ return nil288216 }289217 290-// restoreLinks puts back a sheet's regions from an undo step. The291-// regions there now are emptied first, and those put back read their292-// sources again, as the cells undo put back may not be theirs.293-func (s *Sheet) restoreLinks(links []*linked) []loc {294- changed := s.emptyLinked()295- for _, l := range links {296- l.rows, l.cols, l.data, l.stale = 0, 0, 0, true218+// Unlink turns a linked file into the values it shows, as ordinary219+// cells, as one undo step: FreezeRegion.220+func (w *Workbook) Unlink(name string) error {221+ s, r, ok := w.Region(name)222+ if !ok || !r.Linked() {223+ return ErrNoRegion297224 }298- s.links = links299- return changed225+ return s.FreezeRegion(r.Name)300226 }301227 302-// noRect contains no cell.303-var noRect = Rect{From: Addr{Col: -1, Row: -1}, To: Addr{Col: -1, Row: -1}}304-305-// emptyLinked clears every region's cells (see emptyRegion).306-func (s *Sheet) emptyLinked() []loc {307- var changed []loc308- for _, l := range s.links {309- changed = append(changed, s.emptyRegion(l)...)228+// PauseLinked stops a linked file following its file, or has it follow229+// again. It isn't an edit: what follows the file reads it.230+func (w *Workbook) PauseLinked(name string, paused bool) {231+ if s, r, ok := w.Region(name); ok && r.Linked() {232+ s.meta(nameKey(r.Name)).paused = paused310233 }311- return changed312234 }313235 314-// emptyRegion clears a region's cells, outside the undo history, leaving315-// it empty and stale, and returns the cells that changed.316-func (s *Sheet) emptyRegion(l *linked) []loc {317- changed := s.clearSpilled(l.area(), noRect)318- l.rows, l.cols, l.data, l.stale = 0, 0, 0, true319- return changed236+// ReloadLinked marks a linked file stale, so the UI reads it again,237+// whole.238+func (w *Workbook) ReloadLinked(name string) {239+ if s, r, ok := w.Region(name); ok && r.Linked() {240+ s.meta(nameKey(r.Name)).stale = true241+ }320242 }321243 322-// shiftLinked moves the regions' anchors with inserted or deleted rows323-// or columns (at sp, a span of rows when rows is set, mapped by cell); a324-// region whose anchor is deleted goes. A region the lines reach is325-// emptied first, so cells moving where it was aren't mistaken for its326-// own, and read again, whole, at its new place.327-func (s *Sheet) shiftLinked(rows bool, sp formula.Span, cell func(Addr) (Addr, bool)) {328- if len(s.links) == 0 {329- return330- }331- s.recordLinks()332- var next []*linked333- for _, l := range s.links {334- end := l.area().To.Col335- if rows {336- end = l.area().To.Row337- }338- if end < sp.At {339- next = append(next, l) // before the lines: untouched340- continue341- }342- for _, c := range s.emptyRegion(l) {343- s.wb.markDirty(c)244+// RebaseLinks rewrites the path of every linked file with fn, as the UI245+// does when the workbook is saved in another folder, in the undo246+// history too, so undo puts back regions reading the same files. It247+// isn't an edit: the regions read the same files.248+func (w *Workbook) RebaseLinks(fn func(string) string) {249+ seen := map[*Region]bool{}250+ rebase := func(list []Region) {251+ if len(list) == 0 || seen[&list[0]] {252+ return // lists share their arrays with the steps that kept them344253 }345- to, ok := cell(l.anchor)346- if !ok {347- continue254+ seen[&list[0]] = true255+ for i, r := range list {256+ if r.Linked() {257+ list[i].File.Path = fn(r.File.Path)258+ }348259 }349- nl := *l350- nl.anchor = to351- next = append(next, &nl)352260 }353- s.links = next354-}355-356-// copyLinks gives cp, a copy of s, regions of its own reading the same357-// sources, to be read again.358-func (s *Sheet) copyLinks(cp *Sheet) {359- for _, l := range s.links {360- s.wb.linkSeq++361- cp.links = append(cp.links, &linked{id: s.wb.linkSeq, anchor: l.anchor, src: l.src, liveState: &liveState{stale: true}})261+ for _, s := range w.sheets {262+ rebase(s.regions.list)263+ }264+ for _, st := range append(w.hist.undo, w.hist.redo...) {265+ for _, rs := range st.regions {266+ rebase(rs.list)267+ }362268 }363-}364-365-// sameLinks reports whether two lists of regions define the same ones.366-func sameLinks(a, b []*linked) bool {367- return slices.EqualFunc(a, b, func(x, y *linked) bool { return x.id == y.id && x.anchor == y.anchor && x.src == y.src })368269 }@@ -0,0 +1,73 @@
1+package sheet2+3+import (4+ "slices"5+ "testing"6+)7+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.11+func TestLinkedAmongRegions(t *testing.T) {12+ s := notebook(t)13+ w := s.Book()14+ id, err := s.AddLinked(Addr{}, LinkSource{Path: "logs/app-2026.csv"})15+ if err != nil || id != "app_2026" {16+ t.Fatalf("name %q, %v", id, err)17+ }18+ 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)28+ other.Set(Addr{}, "=SUM(nu.app_2026)")29+ if v := other.Value(Addr{}).Num; v != 5 {30+ t.Fatalf("SUM(nu.app_2026) = %v", other.Value(Addr{}))31+ }32+ apply(t, s, LiveOp{Region: id, Rows: []LiveRow{liveRow("4")}})33+ if v := other.Value(Addr{}).Num; v != 9 {34+ t.Fatalf("after a row arrived, SUM = %v", v)35+ }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+ }48+ if got := w.linkName("logs/app-2026.csv"); got != "app_2026_2" {49+ t.Errorf("a second link to the file is named %q", got)50+ }51+ if got := w.linkName("9.csv"); got != "file_9" {52+ t.Errorf("a file named 9 is %q", got)53+ }54+}55+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.59+func TestRegionKindsUndo(t *testing.T) {60+ s := New()61+ id, _ := s.AddLinked(Addr{}, LinkSource{Path: "log.csv"})62+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("n"), Rows: []LiveRow{liveRow("1")}})63+ if s.Book().UndoLabel() != "add log" {64+ t.Errorf("rows arriving made a step: %q", s.Book().UndoLabel())65+ }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())70+ }71+ s.Book().Undo()72+ wantShown(t, s, map[string]string{"D1": "r1 ls not run", "D3": "", "A2": "1"})73+}@@ -1,77 +0,0 @@
1-package sheet2-3-import (4- "bufio"5- "encoding/json"6- "fmt"7-)8-9-// fileLink is a linked region in the file: its anchor and its source,10-// one per line after the charts, never its rows, which are read from the11-// source again on opening. Older builds ignore the field and show the12-// region's cells empty, so it needs no version:13-//14-// "links": [15-// {"at": "A1", "path": "logs/app.csv", "window": 500}16-// ]17-type fileLink struct {18- At string `json:"at"`19- Path string `json:"path"`20- Format string `json:"format,omitempty"`21- Table string `json:"table,omitempty"`22- Query string `json:"query,omitempty"`23- Window int `json:"window,omitempty"`24-}25-26-// writeLinks writes the sheet's linked regions.27-func (s *Sheet) writeLinks(b *bufio.Writer, indent string) error {28- if len(s.links) == 0 {29- return nil30- }31- b.WriteString(",\n" + indent + `"links": [`)32- for i, l := range s.links {33- raw, err := json.Marshal(fileLink{At: l.anchor.String(), Path: l.src.Path, Format: l.src.Format,34- Table: l.src.Table, Query: l.src.Query, Window: l.src.Window})35- if err != nil {36- return err37- }38- if i > 0 {39- b.WriteByte(',')40- }41- fmt.Fprintf(b, "\n%s %s", indent, raw)42- }43- b.WriteString("\n" + indent + "]")44- return nil45-}46-47-// readLinks makes the file's linked regions, empty and stale for the UI48-// to read.49-func (s *Sheet) readLinks(links []fileLink) error {50- for i, fl := range links {51- a, ok := ParseAddr(fl.At)52- switch {53- case !ok:54- return fmt.Errorf("link %d: invalid cell %q", i+1, fl.At)55- case fl.Path == "":56- return fmt.Errorf("link %d: no path", i+1)57- case fl.Window < 0:58- return fmt.Errorf("link %d: invalid window %d", i+1, fl.Window)59- case s.linkedAt(a) != nil:60- return fmt.Errorf("link %d: %s is linked twice", i+1, fl.At)61- }62- s.wb.linkSeq++63- s.links = append(s.links, &linked{id: s.wb.linkSeq, anchor: a, liveState: &liveState{stale: true},64- src: LinkSource{Path: fl.Path, Format: fl.Format, Table: fl.Table, Query: fl.Query, Window: fl.Window}})65- }66- return nil67-}68-69-// LinkOrigin identifies the computer the workbook's linked regions were70-// made or trusted on, as saved in the file; "" when unknown. The UI asks71-// before following files outside the workbook's folder for a workbook72-// made elsewhere, as it does before running its macros.73-func (w *Workbook) LinkOrigin() string { return w.linkOrigin }74-75-// SetLinkOrigin records where the linked regions were made or trusted.76-// It isn't an edit, and is saved with the next save.77-func (w *Workbook) SetLinkOrigin(origin string) { w.linkOrigin = origin }@@ -9,13 +9,15 @@ import (
99 "github.com/FelineStateMachine/012/internal/value"1010 )1111 12-// The change stream: what reads a linked region's source sends the13-// workbook as LiveOps, each applied at once through ApplyLive, outside14-// the undo history, as a spill is written. An op names the region by15-// ID and carries whole rows of values with the formats they arrived in,16-// so it depends on nothing but the region: the same op applied to the17-// same workbook elsewhere makes the same cells, which is what a session18-// following another's would be sent.12+// The change stream: rows reach a region as LiveOps, each applied at13+// once through ApplyLive, outside the undo history, as a spill is14+// written. An op names its region by name, which the file keeps, and15+// carries whole rows of values with the formats they arrived in, so it16+// depends on nothing but the region: the same op applied to the same17+// workbook elsewhere makes the same cells, which is what a session18+// 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.1921 2022 // LiveCell is a value as a source gave it, with its format (a date's,2123 // a file size's), Automatic for none.@@ -24,25 +26,25 @@ type LiveCell struct {
2426 F Format2527 }2628 27-// LiveRow is a row of a linked region, from its first column.29+// LiveRow is a row of a region's table, from its first column.2830 type LiveRow []LiveCell2931 30-// LiveOp is one change a source makes to its linked region.32+// LiveOp is one change a source makes to its region.3133 type LiveOp struct {32- // Link is the region's ID (LinkedRegion.ID).33- Link int34+ // Region is the region's name (Region.Name).35+ Region string3436 // At is when the source changed.3537 At time.Time3638 // Reset has Rows replace every row under the header; otherwise they3739 // follow the last.3840 Reset bool39- // Header is the region's first row, when it is new or changed: the41+ // Header is the table's first row, when it is new or changed: the4042 // source's column names.4143 Header LiveRow4244 // Rows are data rows, under the header.4345 Rows []LiveRow4446 // Err says why the source can't be read, "" when it can. An op with45- // an error changes no cells: the region keeps what it shows.47+ // an error changes no rows: the region keeps what it shows.4648 Err string4749 // Note says what the source left out, if anything.4850 Note string@@ -61,14 +63,15 @@ type LiveInfo struct {
6163 // (see OnBegin, whose op is "live").6264 var OnLive func(trace any, i LiveInfo)6365 64-// ApplyLive applies op to its region: its rows are written, the window66+// ApplyLive applies op to its region: its rows are written, a window6567 // drops the oldest, and what reads the cells that changed recalculates.66-// It returns the region as the op leaves it, or ErrNoLinked for a67-// region the workbook doesn't hold.68-func (w *Workbook) ApplyLive(op LiveOp) (LinkedRegion, error) {69- s, l := w.findLinked(op.Link)70- if l == nil {71- return LinkedRegion{}, ErrNoLinked68+// 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.71+func (w *Workbook) ApplyLive(op LiveOp) error {72+ s, r, ok := w.Region(op.Region)73+ if !ok {74+ return ErrNoRegion7275 }7376 var start time.Time7477 if OnLive != nil {@@ -77,7 +80,7 @@ func (w *Workbook) ApplyLive(op LiveOp) (LinkedRegion, error) {
7780 }7881 start = time.Now()7982 }80- changed := s.applyLive(l, op)83+ changed := s.applyLive(r, s.meta(nameKey(r.Name)), op)8184 if len(changed) > 0 {8285 if w.hist.depth > 0 {8386 w.hist.dirty = append(w.hist.dirty, changed...) // recalculated as the open step ends@@ -88,90 +91,91 @@ func (w *Workbook) ApplyLive(op LiveOp) (LinkedRegion, error) {
8891 if OnLive != nil {8992 OnLive(w.trace, LiveInfo{Rows: len(op.Rows), Cells: len(changed), Reset: op.Reset, Duration: time.Since(start)})9093 }91- return l.info(s), nil94+ return nil9295 }9396 94-// applyLive writes op's rows into l and returns the cells that changed.95-func (s *Sheet) applyLive(l *linked, op LiveOp) []loc {97+// applyLive writes op's rows into r and returns the cells that changed.98+func (s *Sheet) applyLive(r Region, me *regionMeta, op LiveOp) []loc {9699 if !op.At.IsZero() && op.Err == "" {97- l.updated = op.At100+ me.updated = op.At98101 }99102 if op.Reset {100- l.stale = false103+ me.stale = false101104 }102105 if op.Err != "" {103- l.err = op.Err104- if l.rows == 0 {105- return s.writeLinkedError(l)106+ me.err = op.Err107+ if me.rows == 0 {108+ return s.writeTable(r, me, nil, nil, true)106109 }107110 return nil108111 }109- l.err, l.note = "", op.Note110- rows, header := s.liveRows(l, op)111- return s.writeLinkedRows(l, header, rows, op.Reset)112+ me.err, me.note = "", op.Note113+ rows, header := s.liveRows(r, me, op)114+ return s.writeTable(r, me, header, rows, op.Reset)112115 }113116 114117 // liveRows is the data rows the region is to show after op, with the115-// header: when rows are dropped (by the window, or when the region is116-// full) the rows kept are read back from the region's own cells.117-// Rows that already show and stay are nil, so only new ones are written.118-func (s *Sheet) liveRows(l *linked, op LiveOp) (rows []LiveRow, header LiveRow) {118+// header: when rows are dropped (by the window, or when the table is119+// full) the rows kept are read back from the region's own cells. Rows120+// that already show and stay are nil, so only new ones are written.121+func (s *Sheet) liveRows(r Region, me *regionMeta, op LiveOp) (rows []LiveRow, header LiveRow) {119122 header = op.Header120- if header == nil && l.rows > 0 {121- header = s.readLinkedRow(l, 0)123+ if header == nil && me.rows > 0 {124+ header = s.readRow(r, me, 0)122125 }123- cols := max(len(header), l.cols)124- for _, r := range op.Rows {125- cols = max(cols, len(r))126+ cols := max(len(header), me.cols)127+ for _, row := range op.Rows {128+ cols = max(cols, len(row))126129 }127- old := max(l.rows-1, 0)130+ old := max(me.rows-1, 0)128131 if op.Reset {129132 old = 0130133 }134+ window := r.File.Window131135 total := old + len(op.Rows)132136 keep := total133- if l.src.Window > 0 {134- keep = min(keep, l.src.Window)137+ if window > 0 {138+ keep = min(keep, window)135139 }136140 // Whole rows, as many as fit in max-cells.137141 if fit := MaxCells()/max(cols, 1) - 1; keep > fit {138142 keep = max(fit, 0)139- l.note = fmt.Sprintf("only the last %d rows fit in max-cells (%d cells)", keep, MaxCells())140- if l.src.Window == 0 {141- l.note = fmt.Sprintf("only the first %d rows fit in max-cells (%d cells)", keep, MaxCells())143+ me.note = fmt.Sprintf("only the last %d rows fit in max-cells (%d cells)", keep, MaxCells())144+ if window == 0 {145+ me.note = fmt.Sprintf("only the first %d rows fit in max-cells (%d cells)", keep, MaxCells())142146 }143147 }144148 if op.Reset {145- l.dropped = 0146- l.data = 0149+ me.dropped, me.data = 0, 0147150 }148- l.data += len(op.Rows)149- if keep < total && l.src.Window == 0 {151+ me.data += len(op.Rows)152+ if keep < total && window == 0 {150153 // Keep everything up to the budget: the newest rows are left out.151154 rows = make([]LiveRow, keep)152155 copy(rows[min(old, keep):], op.Rows[:max(keep-old, 0)])153- return rows[:keep], header156+ return rows, header154157 }155158 drop := total - keep156- l.dropped += drop159+ me.dropped += drop157160 rows = make([]LiveRow, keep)158161 for i := range keep {159162 switch j := i + drop; {160163 case j >= old:161164 rows[i] = op.Rows[j-old]162165 case drop > 0:163- rows[i] = s.readLinkedRow(l, j+1)166+ rows[i] = s.readRow(r, me, j+1)164167 }165168 }166169 return rows, header167170 }168171 169-// readLinkedRow reads row r of the region (0 for the header) back from170-// its cells.171-func (s *Sheet) readLinkedRow(l *linked, r int) LiveRow {172- row := make(LiveRow, l.cols)173- for c := range l.cols {174- v, lk, kind := s.cells.derivedOf(Addr{Col: l.anchor.Col + c, Row: l.anchor.Row + r})172+// readRow reads row i of the region's table (0 for the header) back173+// from its cells.174+func (s *Sheet) readRow(r Region, me *regionMeta, i int) LiveRow {175+ o := tableOrigin(r)176+ row := make(LiveRow, me.cols)177+ for c := range me.cols {178+ v, lk, kind := s.cells.derivedOf(Addr{Col: o.Col + c, Row: o.Row + i})175179 if kind == slotSpill {176180 row[c] = LiveCell{V: v, F: lk.auto}177181 }@@ -179,116 +183,7 @@ func (s *Sheet) readLinkedRow(l *linked, r int) LiveRow {
179183 return row180184 }181185 182-// writeLinkedRows makes the region show header and rows, writing the183-// rows that aren't nil (nil ones show already), and returns the cells184-// that changed. When a cell it needs holds something of its own, it185-// writes nothing and says so.186-func (s *Sheet) writeLinkedRows(l *linked, header LiveRow, rows []LiveRow, reset bool) []loc {187- cols, nrows := linkedSize(l, header, rows, reset)188- area := Rect{From: l.anchor, To: Addr{Col: l.anchor.Col + max(cols, 1) - 1, Row: l.anchor.Row + max(nrows, 1) - 1}}189- if at, ok := s.linkedBlocked(l, area); ok {190- l.err = "The linked rows would overwrite data in " + at.String()191- if area.To.Row >= MaxRows || area.To.Col >= MaxCols {192- l.err = "The linked rows would go past the edge of the sheet"193- }194- return nil195- }196- keep := area197- if nrows == 0 {198- keep = noRect199- }200- changed := s.clearSpilled(l.area(), keep)201- if cols > 0 {202- changed = s.writeLinkedRow(l, 0, header, cols, true, changed)203- }204- for i, row := range rows {205- // A row written over one that showed clears what it doesn't206- // fill; one below the old region has nothing to clear.207- if row != nil {208- changed = s.writeLinkedRow(l, i+1, row, cols, i+1 < l.rows || reset, changed)209- }210- }211- l.cols, l.rows = cols, nrows212- if !l.fitted && nrows > 1 {213- l.fitted = true214- s.fitLinked(l)215- }216- for _, c := range changed {217- s.freedFor(c.a)218- }219- return changed220-}221-222-// linkedSize is how many columns and rows the region takes to show223-// header and rows: none when there's nothing to show.224-func linkedSize(l *linked, header LiveRow, rows []LiveRow, reset bool) (cols, nrows int) {225- cols = len(header)226- for _, r := range rows {227- cols = max(cols, len(r))228- }229- if !reset {230- cols = max(cols, l.cols)231- }232- if cols > 0 {233- nrows = 1 + len(rows)234- }235- return cols, nrows236-}237-238-// writeLinkedRow writes row r of the region (0 for the header), cols239-// cells wide, adding the cells that changed to changed. With all, the240-// cells the row leaves blank are cleared; otherwise they're blank241-// already.242-func (s *Sheet) writeLinkedRow(l *linked, r int, row LiveRow, cols int, all bool, changed []loc) []loc {243- for c := range cols {244- var lc LiveCell245- if c < len(row) {246- lc = row[c]247- }248- if lc.V.Kind == Empty && !all {249- continue250- }251- a := Addr{Col: l.anchor.Col + c, Row: l.anchor.Row + r}252- if s.writeSpilled(a, lc.V, lc.F) {253- changed = append(changed, loc{s, a})254- }255- }256- return changed257-}258-259-// linkedBlocked returns a cell of area the region can't write: one260-// holding something that isn't the region's, or past the sheet's edge.261-func (s *Sheet) linkedBlocked(l *linked, area Rect) (Addr, bool) {262- if area.To.Row >= MaxRows || area.To.Col >= MaxCols {263- return area.To, true264- }265- own := l.area()266- for a := range s.cells.anyKeysIn(area) {267- if !s.cells.filledAt(a) || own.Contains(a) && s.cells.derivedAt(a) == slotSpill {268- continue269- }270- return a, true271- }272- if ms := s.MergesIn(area); len(ms) > 0 {273- return ms[0].From, true274- }275- return Addr{}, false276-}277-278-// writeLinkedError shows the region's error in its anchor, as #REF!,279-// while it has nothing else to show.280-func (s *Sheet) writeLinkedError(l *linked) []loc {281- if s.cells.filledAt(l.anchor) && s.cells.derivedAt(l.anchor) != slotSpill {282- return nil // never over what the user typed283- }284- l.cols, l.rows = 0, 0285- if s.writeSpilled(l.anchor, ErrRef, Format{}) {286- return []loc{{s, l.anchor}}287- }288- return nil289-}290-291-// freedFor has a spill waiting for the cell at a try again, now that the186+// freedFor has a spill waiting for the cell at a try again, now that a292187 // region wrote or cleared it.293188 func (s *Sheet) freedFor(a Addr) {294189 if s.spills == nil {@@ -302,25 +197,25 @@ func (s *Sheet) freedFor(a Addr) {
302197 })303198 }304199 305-// maxLinkedWidth caps how wide fitting makes a region's column.306-const maxLinkedWidth = 30200+// maxFitWidth caps how wide fitting makes a table's column.201+const maxFitWidth = 30307202 308-// fitLinked widens the region's columns, the first time rows arrive, to309-// their widest text in the first rows, never below the default width,310-// outside the undo history as a pivot's are.311-func (s *Sheet) fitLinked(l *linked) {203+// fitTable widens the columns of a table at o, the first time rows204+// arrive, to their widest text in the first rows, never below the205+// default width, outside the undo history as a pivot's are.206+func (s *Sheet) fitTable(o Addr, rows, cols int) {312207 const sample = 200313- for c := range l.cols {314- col := l.anchor.Col + c208+ for c := range cols {209+ col := o.Col + c315210 if s.widths[col] != 0 {316211 continue // set by hand, or by the file317212 }318213 widest := 0319- for r := range min(l.rows, sample) {320- widest = max(widest, len([]rune(s.ShownText(Addr{Col: col, Row: l.anchor.Row + r}))))214+ for r := range min(rows, sample) {215+ widest = max(widest, len([]rune(s.ShownText(Addr{Col: col, Row: o.Row + r}))))321216 }322217 if widest+2 > DefaultWidth {323- s.setWidth(col, min(widest+2, maxLinkedWidth))218+ s.setWidth(col, min(widest+2, maxFitWidth))324219 }325220 }326221 }@@ -22,7 +22,7 @@ func liveRow(fields ...string) LiveRow {
2222 2323 // linkedSheet is a sheet with a region at A1 showing a header and n rows2424 // of numbers 1..n in column B.25-func linkedSheet(t *testing.T, window int) (*Sheet, int) {25+func linkedSheet(t *testing.T, window int) (*Sheet, string) {2626 t.Helper()2727 s := New()2828 id, err := s.AddLinked(Addr{}, LinkSource{Path: "log.csv", Window: window})@@ -34,10 +34,10 @@ func linkedSheet(t *testing.T, window int) (*Sheet, int) {
3434 3535 func apply(t *testing.T, s *Sheet, op LiveOp) LinkedRegion {3636 t.Helper()37- r, err := s.Book().ApplyLive(op)38- if err != nil {37+ if err := s.Book().ApplyLive(op); err != nil {3938 t.Fatal(err)4039 }40+ r, _ := s.Book().LinkedRegion(op.Region)4141 return r4242 }4343 @@ -45,7 +45,7 @@ func TestLiveResetAndAppend(t *testing.T) {
4545 s, id := linkedSheet(t, 0)4646 s.Set(Addr{Col: 4}, "=SUM(B:B)")4747 s.Set(Addr{Col: 5}, "=COUNTA(A2:A100)")48- r := apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("a", "1"), liveRow("b", "2")}})48+ r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("a", "1"), liveRow("b", "2")}})4949 if r.Rows != 2 || r.Area != NewRect(Addr{}, Addr{Col: 1, Row: 2}) || r.Stale {5050 t.Fatalf("after reset: %+v", r)5151 }@@ -53,7 +53,7 @@ func TestLiveResetAndAppend(t *testing.T) {
5353 t.Fatalf("SUM = %v, want 3", got)5454 }5555 at := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)56- r = apply(t, s, LiveOp{Link: id, At: at, Rows: []LiveRow{liveRow("c", "3", "extra")}})56+ r = apply(t, s, LiveOp{Region: id, At: at, Rows: []LiveRow{liveRow("c", "3", "extra")}})5757 if r.Rows != 3 || r.Area.To != (Addr{Col: 2, Row: 3}) || !r.Updated.Equal(at) {5858 t.Fatalf("after append: %+v", r)5959 }@@ -70,7 +70,7 @@ func TestLiveResetAndAppend(t *testing.T) {
7070 t.Fatalf("rows arriving made an undo step: %q", s.Book().UndoLabel())7171 }7272 // A reset with fewer rows clears the rest.73- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("z", "10")}})73+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("z", "10")}})7474 if s.Filled(Addr{Row: 2}) || s.Filled(Addr{Col: 2, Row: 3}) {7575 t.Fatal("a reset left old rows")7676 }@@ -86,11 +86,11 @@ func TestLiveWindow(t *testing.T) {
8686 for _, n := range []string{"1", "2", "3", "4", "5"} {8787 rows = append(rows, liveRow("r"+n, n))8888 }89- r := apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("name", "n"), Rows: rows[:2]})89+ r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: rows[:2]})9090 if r.Rows != 2 || r.Dropped != 0 {9191 t.Fatalf("%+v", r)9292 }93- r = apply(t, s, LiveOp{Link: id, Rows: rows[2:]})93+ r = apply(t, s, LiveOp{Region: id, Rows: rows[2:]})9494 if r.Rows != 3 || r.Dropped != 2 {9595 t.Fatalf("after the window filled: %+v", r)9696 }@@ -108,7 +108,7 @@ func TestLiveWindow(t *testing.T) {
108108 t.Fatalf("SUM = %v, want 12", v)109109 }110110 // One more row drops one more.111- r = apply(t, s, LiveOp{Link: id, Rows: []LiveRow{liveRow("r6", "6")}})111+ r = apply(t, s, LiveOp{Region: id, Rows: []LiveRow{liveRow("r6", "6")}})112112 if r.Dropped != 3 || s.Value(Addr{Row: 1}).Str != "r4" || s.Value(Addr{Row: 3}).Str != "r6" {113113 t.Fatalf("%+v: %v..%v", r, s.Value(Addr{Row: 1}), s.Value(Addr{Row: 3}))114114 }@@ -122,7 +122,7 @@ func TestLiveMaxCells(t *testing.T) {
122122 for range 5 {123123 rows = append(rows, liveRow("x", "1"))124124 }125- r := apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a", "b"), Rows: rows})125+ r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a", "b"), Rows: rows})126126 if r.Rows != 3 || !strings.Contains(r.Note, "only the first 3 rows") {127127 t.Fatalf("%+v", r)128128 }@@ -130,38 +130,44 @@ func TestLiveMaxCells(t *testing.T) {
130130 131131 func TestLiveRefusesEditsAndBlocks(t *testing.T) {132132 s, id := linkedSheet(t, 0)133- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})133+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})134134 if err := s.Set(Addr{Row: 1}, "5"); !errors.Is(err, ErrLinkedEdit) {135135 t.Fatalf("Set in a region: %v", err)136136 }137- if _, ok := s.InLinked(NewRect(Addr{Row: 1}, Addr{Col: 3, Row: 5})); !ok {138- t.Fatal("InLinked missed the region")139- }140- // Rows that would overwrite a typed cell stop the region, saying so.141- s.Set(Addr{Row: 3}, "mine")142- r := apply(t, s, LiveOp{Link: id, Rows: []LiveRow{liveRow("2"), liveRow("3")}})143- if !strings.Contains(r.Err, "overwrite data in A4") || r.Rows != 1 || s.Value(Addr{Row: 3}).Str != "mine" {144- t.Fatalf("%+v", r)137+ if _, r, ok := s.InRegion(NewRect(Addr{Row: 1}, Addr{Col: 3, Row: 5})); !ok || r.Name != id {138+ t.Fatal("InRegion missed the linked file")145139 }146140 // Formatting a linked cell keeps its value.147141 s.SetStyle(NewRect(Addr{Row: 1}, Addr{Row: 1}), func(st *Style) { st.Bold = true })148142 if c := s.Cell(Addr{Row: 1}); c.Value.Num != 1 || !c.Style.Bold || !c.Spilled() {149143 t.Fatalf("formatted linked cell: %+v", c)150144 }145+ // Rows that would overwrite a typed cell aren't shown, and the first146+ // cell says why; clearing the cell has the file read again.147+ s.Set(Addr{Row: 3}, "mine")148+ r := apply(t, s, LiveOp{Region: id, Rows: []LiveRow{liveRow("2"), liveRow("3")}})149+ if !strings.Contains(r.Err, "overwrite data in A4") || r.Rows != 0 || s.Value(Addr{Row: 3}).Str != "mine" ||150+ s.Value(Addr{}) != value.ErrRef || s.Filled(Addr{Row: 1}) {151+ t.Fatalf("%+v", r)152+ }153+ s.Set(Addr{Row: 3}, "")154+ if r, _ := s.Book().LinkedRegion(id); !r.Stale {155+ t.Fatalf("clearing the cell in the way: %+v", r)156+ }151157 }152158 153159 func TestLiveErrorShowsInAnchor(t *testing.T) {154160 s, id := linkedSheet(t, 0)155- r := apply(t, s, LiveOp{Link: id, Err: "no such file"})161+ r := apply(t, s, LiveOp{Region: id, Err: "no such file"})156162 if r.Err != "no such file" || s.Value(Addr{}) != value.ErrRef {157163 t.Fatalf("%+v, anchor %v", r, s.Value(Addr{}))158164 }159- r = apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})165+ r = apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})160166 if r.Err != "" || s.Value(Addr{}).Str != "a" {161167 t.Fatalf("%+v, anchor %v", r, s.Value(Addr{}))162168 }163169 // An error once rows show keeps them.164- r = apply(t, s, LiveOp{Link: id, Err: "gone"})170+ r = apply(t, s, LiveOp{Region: id, Err: "gone"})165171 if r.Err != "gone" || s.Value(Addr{Row: 1}).Num != 1 {166172 t.Fatalf("%+v", r)167173 }@@ -169,7 +175,7 @@ func TestLiveErrorShowsInAnchor(t *testing.T) {
169175 170176 func TestUnlinkAndUndo(t *testing.T) {171177 s, id := linkedSheet(t, 0)172- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})178+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})173179 if err := s.Book().Unlink(id); err != nil {174180 t.Fatal(err)175181 }@@ -185,12 +191,12 @@ func TestUnlinkAndUndo(t *testing.T) {
185191 if !ok || !r.Stale || s.Filled(Addr{Row: 1}) {186192 t.Fatalf("undo of unlink: %+v %v, A2 %v", r, ok, s.Value(Addr{Row: 1}))187193 }188- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("2")}})194+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("2")}})189195 s.Undo() // making the region190196 if s.HasLinked() || s.Filled(Addr{}) || s.Filled(Addr{Row: 1}) {191197 t.Fatal("undoing the region left its cells")192198 }193- if _, err := s.Book().ApplyLive(LiveOp{Link: id}); !errors.Is(err, ErrNoLinked) {199+ if err := s.Book().ApplyLive(LiveOp{Region: id}); !errors.Is(err, ErrNoRegion) {194200 t.Fatalf("op for a region undone: %v", err)195201 }196202 s.Redo()@@ -206,7 +212,7 @@ func TestLinkedShiftsWithRows(t *testing.T) {
206212 if err != nil {207213 t.Fatal(err)208214 }209- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})215+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})210216 s.Set(Addr{Col: 3, Row: 1}, "x")211217 if err := s.InsertRows(1, 2); err != nil {212218 t.Fatal(err)@@ -218,7 +224,7 @@ func TestLinkedShiftsWithRows(t *testing.T) {
218224 if s.Value(Addr{Col: 3, Row: 3}).Str != "x" {219225 t.Fatal("a cell moving past the region was lost")220226 }221- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})227+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})222228 if s.Value(Addr{Row: 5}).Num != 1 {223229 t.Fatal("the region wasn't written at its new place")224230 }@@ -235,15 +241,14 @@ func TestLinkedShiftsWithRows(t *testing.T) {
235241 236242 func TestLinkedFileRoundTrip(t *testing.T) {237243 s, id := linkedSheet(t, 500)238- s.Book().SetLinkOrigin("here")239- apply(t, s, LiveOp{Link: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})244+ apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})240245 var b bytes.Buffer241246 if err := s.Write(&b); err != nil {242247 t.Fatal(err)243248 }244249 text := b.String()245- if !strings.Contains(text, `"links": [`) || !strings.Contains(text, `{"at":"A1","path":"log.csv","window":500}`) ||246- !strings.Contains(text, `"linkOrigin": "here"`) || strings.Contains(text, `"A2"`) {250+ if !strings.Contains(text, `"regions": [`) || !strings.Contains(text, `{"name":"log","at":"A1","path":"log.csv","window":500}`) ||251+ strings.Contains(text, `"A2"`) || strings.Contains(text, `"notebook"`) {247252 t.Fatalf("written:\n%s", text)248253 }249254 got, err := Read(strings.NewReader(text))@@ -251,12 +256,12 @@ func TestLinkedFileRoundTrip(t *testing.T) {
251256 t.Fatal(err)252257 }253258 rs := got.LinkedRegions()254- if len(rs) != 1 || rs[0].Source != (LinkSource{Path: "log.csv", Window: 500}) || !rs[0].Stale || got.Book().LinkOrigin() != "here" {259+ if len(rs) != 1 || rs[0].Name != "log" || rs[0].Source != (LinkSource{Path: "log.csv", Window: 500}) || !rs[0].Stale {255260 t.Fatalf("read: %+v", rs)256261 }257- for _, bad := range []string{`[{"at": "ZZZZ9", "path": "a"}]`, `[{"at": "A1"}]`, `[{"at": "A1", "path": "a", "window": -1}]`,258- `[{"at": "A1", "path": "a"}, {"at": "A1", "path": "b"}]`} {259- if _, err := Read(strings.NewReader(`{"version": 2, "cells": {}, "links": ` + bad + `}`)); err == nil {262+ for _, bad := range []string{`[{"name": "a", "at": "ZZZZ9", "path": "a"}]`, `[{"name": "a", "at": "A1", "path": "a", "window": -1}]`,263+ `[{"name": "a", "at": "A1", "path": "a"}, {"name": "a", "at": "A3", "path": "b"}]`, `[{"name": "a", "at": "A1", "path": "a", "command": "ls"}]`} {264+ if _, err := Read(strings.NewReader(`{"version": 2, "cells": {}, "regions": ` + bad + `}`)); err == nil {260265 t.Errorf("read links %s", bad)261266 }262267 }@@ -138,7 +138,7 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
138138 label += "s"139139 }140140 s.change(label, focus, func() {141- s.shiftLinked(rows, sp, cell) // first, so remap doesn't take cells moving into regions for theirs141+ s.shiftRegions(rows, sp) // first, so remap doesn't take cells moving where regions were for theirs142142 s.remap(label, focus, cell, rng)143143 s.remapNames(rng)144144 s.shiftCharts(cell, rng)@@ -151,7 +151,6 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
151151 s.shiftView(rows, sp)152152 s.shiftPivots(rows, sp)153153 s.shiftRules(rows, sp)154- s.shiftRegions(rows, sp)155154 })156155 }157156 @@ -10,15 +10,31 @@ import (
1010 )1111 1212 // Regions are blocks of cells whose values come from outside the13-// engine: a nushell command's output on a notebook sheet, and in time a14-// file followed as it grows. A region is an anchor (its label line, At)15-// and a definition saying where its table comes from (Command, opaque to16-// the engine); what fills it arrives as a RegionData the UI hands over17-// (ShowRegion). Like a spill, its cells are derived: written by the18-// engine outside the undo history, refused by Set, values to formulas,19-// copies and exports, formatting kept, never saved. The definitions and20-// the data shown are undo steps, so undoing a run shows the table it21-// replaced; the file keeps the definitions only.13+// 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.21+//22+// Both kinds are one indexed set, the sheet's regionState, and their23+// 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.2238 //2339 // On a notebook sheet regions stack in run order, a gap row between2440 // them: a new one goes below everything on the sheet, and a table that@@ -50,8 +66,18 @@ type Region struct {
5066 // Sort orders the table's rows below its header, by columns counted5167 // from the region's first.5268 Sort []SortKey69+ // File is the file a linked file follows; zero for a command region.70+ File LinkSource5371 }5472 73+// Linked reports whether the region is a linked file rather than a74+// command region.75+func (r Region) Linked() bool { return r.File.Path != "" }76+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+5581 // regionState is what a sheet keeps of its regions in the undo history:5682 // the definitions in run order and the data each shows, by name key.5783 // Both are replaced whole, never changed in place.@@ -62,19 +88,26 @@ type regionState struct {
6288 }6389 6490 // regionMeta is what isn't undone: what the region's label says it's65-// doing, and the cells written last.91+// doing, the cells written last and what the rows written made of it.6692 type regionMeta struct {6793 status string6894 written Rect6995 has bool // written holds something7096 why string // why the table isn't shown, or ""97+ need Rect // the cells the table needs while it isn't shown98+ // rows and cols are the table's size as written, header included.99+ rows, cols int100+ liveMeta // a linked file's: see linked.go71101 }72102 73103 // Errors of regions.74104 var (75105 // ErrRegionEdit is what typing into a region's cell says.76106 ErrRegionEdit = errors.New("That cell is part of a shell region: change its command, or Freeze it to edit its values")77- errNoRegion = errors.New("There's no region by that name")107+ // ErrNoRegion is returned for a region the workbook doesn't hold:108+ // 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")78111 )79112 80113 // regionPrefix starts a region's name in formulas.@@ -256,25 +289,15 @@ func (s *Sheet) AddRegion(r Region) error {
256289 return nil257290 }258291 259-// nextRegionRow is where a new region's label goes on a notebook: below260-// the last cell and region, a row left free, or row 1 on an empty sheet.261-func (s *Sheet) nextRegionRow() int {262- next := 0263- if used, ok := s.UsedRange(); ok {264- next = used.To.Row + 2265- }266- for _, r := range s.regions.list {267- next = max(next, r.At.Row+r.Rows+2)268- }269- return min(next, MaxRows-1)270-}271-272292 // EditRegion changes a region's command, what it reads and its input,273293 // as one undo step. The table stays until the command runs again.274294 func (s *Sheet) EditRegion(name, command string, deps []string, input string) error {275295 i := s.regionIndex(nameKey(name))276296 if i < 0 {277- return errNoRegion297+ return ErrNoRegion298+ }299+ if s.regions.list[i].Linked() {300+ return errFileRegion278301 }279302 if err := s.wb.checkDeps(s.regions.list[i].Name, deps); err != nil {280303 return err@@ -292,7 +315,7 @@ func (s *Sheet) DeleteRegion(name string) error {
292315 k := nameKey(name)293316 i := s.regionIndex(k)294317 if i < 0 {295- return errNoRegion318+ return ErrNoRegion296319 }297320 r := s.regions.list[i]298321 s.change("delete "+r.Name, s.regionArea(r), func() {@@ -310,22 +333,28 @@ func (s *Sheet) FreezeRegion(name string) error {
310333 k := nameKey(name)311334 i := s.regionIndex(k)312335 if i < 0 {313- return errNoRegion336+ return ErrNoRegion314337 }315338 r := s.regions.list[i]316339 table, ok := s.RegionTable(r.Name)317- s.change("freeze "+r.Name, s.regionArea(r), func() {318- if ok {319- for a := range s.cells.anyKeysIn(table) {320- if c := s.cells.get(a); c.Spilled() {321- s.place(a, frozen(c))322- }340+ var cells []*Cell341+ var at []Addr342+ if ok {343+ for a := range s.cells.anyKeysIn(table) {344+ if c := s.cells.get(a); c.Spilled() {345+ cells, at = append(cells, frozen(c)), append(at, a)323346 }324347 }348+ }349+ s.change("freeze "+r.Name, s.covered(r), func() {350+ // The region goes first, so its cells take the values placed.325351 st := s.regionsCopy()326352 st.list = slices.Delete(st.list, i, i+1)327353 delete(st.data, k)328354 s.putRegions(st)355+ for j, a := range at {356+ s.place(a, cells[j])357+ }329358 })330359 return nil331360 }@@ -340,39 +369,21 @@ func frozen(c *Cell) *Cell {
340369 return p.plain().withNote(c.Note)341370 }342371 343-// SortRegion sorts a region's table below its header by keys, columns of344-// the sheet, as one undo step: the order is the region's, so the table345-// stays sorted when it runs again.346-func (s *Sheet) SortRegion(name string, keys []SortKey) error {347- i := s.regionIndex(nameKey(name))348- if i < 0 {349- return errNoRegion350- }351- r := s.regions.list[i]352- rel := make([]SortKey, len(keys))353- for j, key := range keys {354- rel[j] = SortKey{Col: key.Col - r.At.Col, Desc: key.Desc}355- }356- s.change("sort "+r.Name, s.regionArea(r), func() {357- st := s.regionsCopy()358- st.list[i].Sort = rel359- s.putRegions(st)360- })361- return nil362-}363-364372 // ShowRegion shows d in the region, as one undo step: the table grows or365373 // shrinks, and on a notebook the rows under it move with it.366374 func (s *Sheet) ShowRegion(name string, d *RegionData) error {367375 k := nameKey(name)368376 i := s.regionIndex(k)369377 if i < 0 {370- return errNoRegion378+ return ErrNoRegion371379 }372380 if d == nil {373381 d = &RegionData{}374382 }375383 r := s.regions.list[i]384+ if r.Linked() {385+ return errFileRegion386+ }376387 s.change("run "+r.Name, s.regionArea(r), func() {377388 if s.regions.notebook {378389 s.fitRegion(k, d.Rows)@@ -389,32 +400,6 @@ func (s *Sheet) ShowRegion(name string, d *RegionData) error {
389400 return nil390401 }391402 392-// fitRegion makes room under the region with key k for a table of rows393-// rows: inserting rows below its table, or deleting those it no longer394-// needs when nothing else is on them.395-func (s *Sheet) fitRegion(k string, rows int) {396- r := s.regions.list[s.regionIndex(k)]397- end := r.At.Row + r.Rows // the table's last row398- switch {399- case rows > r.Rows:400- below := rowRect(end+1, MaxRows-1)401- for range s.cells.anyKeysIn(below) {402- _ = s.insert(true, end+1, rows-r.Rows) // rows pushed past the edge: the table is cut instead403- return404- }405- case rows < r.Rows:406- from := r.At.Row + rows + 1407- spare := rowRect(from, end)408- area := s.regionArea(r)409- for a := range s.cells.anyKeysIn(spare) {410- if !area.Contains(a) {411- return // something else is on those rows412- }413- }414- s.restructure(true, spanOf(from, -(end-from+1)))415- }416-}417-418403 // regionsCopy is a copy of the sheet's regions to change and put back.419404 func (s *Sheet) regionsCopy() regionState {420405 return regionState{notebook: s.regions.notebook, list: slices.Clone(s.regions.list), data: maps.Clone(s.regions.data)}@@ -450,7 +435,7 @@ func sameRegions(a, b regionState) bool {
450435 for i := range a.list {451436 x, y := a.list[i], b.list[i]452437 if x.Name != y.Name || x.Command != y.Command || x.At != y.At || x.Rows != y.Rows || x.Cols != y.Cols ||453- x.Input != y.Input || !slices.Equal(x.Deps, y.Deps) || !slices.Equal(x.Sort, y.Sort) {438+ x.Input != y.Input || x.File != y.File || !slices.Equal(x.Deps, y.Deps) || !slices.Equal(x.Sort, y.Sort) {454439 return false455440 }456441 }@@ -8,23 +8,31 @@ 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. They need no version: earlier builds12-// ignore them and open the sheet without its regions.11+// 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.1314 //1415 // "notebook": true,1516 // "regions": [1617 // {"name": "r1", "command": "ls", "at": "A1", "rows": 12, "cols": 4},17-// {"name": "big", "command": "$r1 | where size > 1kb", "at": "A16", "rows": 3, "cols": 4, "reads": ["r1"]}18+// {"name": "big", "command": "$r1 | where size > 1kb", "at": "A16", "rows": 3, "cols": 4, "reads": ["r1"]},19+// {"name": "app", "at": "F1", "path": "logs/app.csv", "window": 500}1820 // ]1921 type fileRegion struct {2022 Name string `json:"name"`21- Command string `json:"command"`23+ Command string `json:"command,omitempty"`2224 At string `json:"at"`2325 Rows int `json:"rows,omitempty"`2426 Cols int `json:"cols,omitempty"`2527 Reads []string `json:"reads,omitempty"`2628 Input string `json:"input,omitempty"`2729 Sort []fileRegionSort `json:"sort,omitempty"`30+ // A linked file's: see linked.go.31+ Path string `json:"path,omitempty"`32+ Format string `json:"format,omitempty"`33+ Table string `json:"table,omitempty"`34+ Query string `json:"query,omitempty"`35+ Window int `json:"window,omitempty"`2836 }2937 3038 // fileRegionSort is a column a region's table is sorted by, counted from@@ -44,7 +52,8 @@ func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {
4452 }4553 b.WriteString(",\n" + indent + `"regions": [`)4654 for i, r := range s.regions.list {47- 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, Command: r.Command, At: r.At.String(), Rows: r.Rows, Cols: r.Cols, Reads: r.Deps, Input: r.Input,56+ Path: r.File.Path, Format: r.File.Format, Table: r.File.Table, Query: r.File.Query, Window: r.File.Window}4857 for _, k := range r.Sort {4958 fr.Sort = append(fr.Sort, fileRegionSort{Column: k.Col + 1, Desc: k.Desc})5059 }@@ -78,6 +87,11 @@ func (s *Sheet) readRegions(notebook bool, frs []fileRegion) error {
7887 s.regions.data = map[string]*RegionData{}7988 s.regionsStale = true8089 }90+ for _, r := range s.regions.list {91+ if r.Linked() {92+ s.meta(nameKey(r.Name)).stale = true93+ }94+ }8195 return nil8296 }8397 @@ -92,7 +106,16 @@ func decodeRegion(fr fileRegion) (Region, error) {
92106 if fr.Rows < 0 || fr.Cols < 0 || at.Row+fr.Rows >= MaxRows || at.Col+fr.Cols > MaxCols {93107 return Region{}, fmt.Errorf("region %s: invalid size %d by %d", fr.Name, fr.Rows, fr.Cols)94108 }95- r := Region{Name: fr.Name, Command: fr.Command, At: at, Rows: fr.Rows, Cols: fr.Cols, Input: fr.Input}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+ }96119 for _, d := range fr.Reads {97120 if err := ValidRegionName(d); err != nil {98121 return Region{}, fmt.Errorf("region %s reads %q: %w", fr.Name, d, err)@@ -83,7 +83,8 @@ func (w *Workbook) checkDeps(name string, deps []string) error {
8383 return nil8484 }8585 86-// RefreshOrder returns the regions to run when name is refreshed: name,86+// RefreshOrder returns the regions to run when name is refreshed: name87+// (unless it's a linked file, which doesn't run),8788 // then every region that reads it, directly or through others, each8889 // after the regions it reads.8990 func (w *Workbook) RefreshOrder(name string) []string {@@ -98,9 +99,19 @@ func (w *Workbook) RefreshOrder(name string) []string {
9899 }99100 }100101 }102+ commands(regions, want)101103 return w.ordered(regions, want)102104 }103105 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+104115 // RunOrder returns every region, each after the regions it reads, in run105116 // order otherwise: what Run all runs.106117 func (w *Workbook) RunOrder() []string {@@ -109,6 +120,7 @@ func (w *Workbook) RunOrder() []string {
109120 for _, r := range regions {110121 want[nameKey(r.Name)] = true111122 }123+ commands(regions, want)112124 return w.ordered(regions, want)113125 }114126 @@ -0,0 +1,237 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/formula"4+5+// Where regions are (see region.go): the cells they cover and their6+// 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.9+10+// noRect contains no cell.11+var noRect = Rect{From: Addr{Col: -1, Row: -1}, To: Addr{Col: -1, Row: -1}}12+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+33+// RegionTable returns the cells of a region's table, header row34+// included, when it has one.35+func (s *Sheet) RegionTable(name string) (Rect, bool) {36+ i := s.regionIndex(nameKey(name))37+ if i < 0 {38+ return Rect{}, false39+ }40+ 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 {51+ return Rect{}, false52+ }53+ return Rect{From: o, To: Addr{Col: min(o.Col+cols-1, MaxCols-1), Row: o.Row + rows - 1}}, true54+}55+56+// covered is the cells a region holds: those it wrote, or while it's57+// yet to write them, those its definition reserves.58+func (s *Sheet) covered(r Region) Rect {59+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has {60+ return me.written61+ }62+ return s.regionArea(r)63+}64+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) {68+ for _, r := range s.regions.list {69+ if s.covered(r).Contains(a) {70+ return r, r.labelled() && a.Row == r.At.Row, true71+ }72+ }73+ return Region{}, false, false74+}75+76+// InRegion returns a cell of r a region covers, and the region.77+func (s *Sheet) InRegion(r Rect) (Addr, Region, bool) {78+ for _, reg := range s.regions.list {79+ if overlap, ok := intersectRect(s.covered(reg), r); ok {80+ return overlap.From, reg, true81+ }82+ }83+ return Addr{}, Region{}, false84+}85+86+// shiftRegions keeps the sheet's regions in step with rows or columns87+// inserted or deleted, before the cells move: a region the lines reach88+// 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.92+func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {93+ if len(s.regions.list) == 0 {94+ return95+ }96+ cell, rng := formula.AxisMaps(rows, sp)97+ st := s.regionsCopy()98+ st.list = st.list[:0]99+ for _, r := range s.regions.list {100+ area := s.covered(r)101+ end := area.To.Col102+ if rows {103+ end = area.To.Row104+ }105+ if end < sp.At {106+ st.list = append(st.list, r) // before the lines: untouched107+ continue108+ }109+ me := s.meta(nameKey(r.Name))110+ for _, c := range s.emptyRegion(r, me) {111+ s.wb.markDirty(c)112+ }113+ at, ok := cell(r.At)114+ moved, ok2 := rng(s.regionArea(r))115+ if !ok || !ok2 {116+ delete(st.data, nameKey(r.Name))117+ continue118+ }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+ }126+ st.list = append(st.list, r)127+ }128+ if !sameRegions(st, s.regions) {129+ s.putRegions(st)130+ }131+ s.regionsStale = true132+}133+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.166+func (w *Workbook) regionName(k string) (*Sheet, Rect, bool) {167+ if len(k) <= len(regionPrefix) || k[:len(regionPrefix)] != nameKey(regionPrefix) {168+ return nil, Rect{}, false169+ }170+ for _, s := range w.sheets {171+ if i := s.regionIndex(k[len(regionPrefix):]); i >= 0 {172+ r, ok := s.RegionTable(s.regions.list[i].Name)173+ if !ok {174+ return s, Rect{}, true175+ }176+ return s, r, true177+ }178+ }179+ return nil, Rect{}, false180+}181+182+// regionsInUse are the regions formulas name, with their tables, for183+// finding what reads a changed cell.184+func (w *Workbook) regionsInUse() []namedUsers {185+ var out []namedUsers186+ for _, s := range w.sheets {187+ for _, r := range s.regions.list {188+ users := w.nameUsers[nameKey(r.FormulaName())]189+ if len(users) == 0 {190+ continue191+ }192+ if t, ok := s.RegionTable(r.Name); ok {193+ out = append(out, namedUsers{s, t, users})194+ }195+ }196+ }197+ return out198+}199+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 row206+ 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)))223+ }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)235+ }236+ return min(next, MaxRows-1)237+}@@ -0,0 +1,68 @@
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+}@@ -1,68 +1,14 @@
11 package sheet22 3-import (4- "slices"5-6- "github.com/FelineStateMachine/012/internal/formula"7-)8-9-// Writing regions' cells (see region.go): after a change that touched10-// regions, each stale sheet's labels and tables are written again, only11-// the cells that differ, and cells no region covers any more are12-// cleared. A table that would overwrite other contents isn't shown; its13-// label says where it's blocked, and clearing that cell shows it.14-15-// regionArea is the cells a region covers: its label line and table, at16-// least one column wide.17-func (s *Sheet) regionArea(r Region) Rect {18- return Rect{From: r.At, To: Addr{Col: min(r.At.Col+max(r.Cols, 1)-1, MaxCols-1), Row: min(r.At.Row+r.Rows, MaxRows-1)}}19-}20-21-// RegionTable returns the cells of a region's table, header row22-// included, when it has one.23-func (s *Sheet) RegionTable(name string) (Rect, bool) {24- i := s.regionIndex(nameKey(name))25- if i < 0 {26- return Rect{}, false27- }28- r := s.regions.list[i]29- if r.Rows == 0 || r.Cols == 0 || r.At.Row+1 >= MaxRows {30- return Rect{}, false31- }32- area := s.regionArea(r)33- area.From.Row++34- return area, true35-}36-37-// covered is the cells a region holds: those it wrote, or while it's38-// yet to write them, those its definition reserves.39-func (s *Sheet) covered(r Region) Rect {40- if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has {41- return me.written42- }43- return s.regionArea(r)44-}45-46-// RegionAt returns the region covering the cell at a, and whether a is on47-// its label line.48-func (s *Sheet) RegionAt(a Addr) (Region, bool, bool) {49- for _, r := range s.regions.list {50- if s.covered(r).Contains(a) {51- return r, a.Row == r.At.Row, true52- }53- }54- return Region{}, false, false55-}56-57-// InRegion returns a cell of r a region covers, and the region.58-func (s *Sheet) InRegion(r Rect) (Addr, Region, bool) {59- for _, reg := range s.regions.list {60- if overlap, ok := intersectRect(s.covered(reg), r); ok {61- return overlap.From, reg, true62- }63- }64- return Addr{}, Region{}, false65-}3+// Writing regions' cells (see region.go). Every region's cells go4+// through writeTable: a header and rows under it, written as spilled5+// 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).6612 6713 // SetRegionStatus sets what a region's label says it's doing, such as6814 // "Running…", "" for nothing. It isn't an undo step.@@ -95,35 +41,31 @@ func (s *Sheet) meta(k string) *regionMeta {
9541 return me9642 }9743 98-// regionTouched has the regions write their cells again after the cell99-// at a is placed in their way or over their cells.44+// 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.10047 func (s *Sheet) regionTouched(a Addr) {10148 for _, r := range s.regions.list {102- if s.regionArea(r).Contains(a) || s.blockedAt(nameKey(r.Name), a) {49+ me := s.regionMeta[nameKey(r.Name)]50+ if me == nil || me.why == "" || !me.need.Contains(a) {51+ continue52+ }53+ if r.Linked() {54+ me.stale = true55+ } else {10356 s.regionsStale = true104- return10557 }10658 }10759 }10860 109-// blockedAt reports whether the region with key k isn't shown because of110-// the cell at a's row and columns.111-func (s *Sheet) blockedAt(k string, a Addr) bool {112- me := s.regionMeta[k]113- if me == nil || me.why == "" {114- return false115- }116- i := s.regionIndex(k)117- if i < 0 {118- return false119- }120- r := s.regions.list[i]121- d := s.regions.data[k]122- if d == nil {123- return false61+// ownerOf is the region whose cell the cell at a is, and its meta.62+func (s *Sheet) ownerOf(a Addr) (Region, *regionMeta, bool) {63+ for _, r := range s.regions.list {64+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && s.regionOwns(a, me) {65+ return r, me, true66+ }12467 }125- need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}126- return need.Contains(a)68+ return Region{}, nil, false12769 }12870 12971 // settleRegions writes the cells of the sheets whose regions changed,@@ -146,91 +88,207 @@ func (w *Workbook) settleRegions() {
14688 }14789 }14890 149-// regionCell is a cell a region writes: its value and the format it150-// comes in.151-type regionCell struct {152- v Value153- f Format154-}155-156-// writeRegions writes every region's cells on s and clears those no157-// region covers any more, returning the cells that changed and the91+// 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 the15894 // formulas naming a region whose table moved.15995 func (s *Sheet) writeRegions() []loc {160- var old []Rect96+ var changed []loc16197 for k, me := range s.regionMeta {162- if me.has {163- old = append(old, me.written)164- }16598 if s.regionIndex(k) < 0 {99+ changed = append(changed, s.clearOwned(me, noRect)...)166100 delete(s.regionMeta, k)167101 }168102 }169- want := map[Addr]regionCell{}170- var changed []loc171- for _, r := range s.regions.list {172- changed = append(changed, s.planRegion(r, want)...)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:123+ me.reread = false124+ changed = append(changed, s.emptyRegion(r, me)...)125+ }173126 }174- for _, area := range old {175- changed = append(changed, s.clearRegion(area, want)...)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 kept135+ }176136 }177- for a, c := range want {178- if s.writeRegionCell(a, c) {179- changed = append(changed, loc{s, a})137+ return changed138+}139+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}180153 }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 {166+ changed := s.clearOwned(me, noRect)167+ me.has, me.rows, me.cols, me.why = false, 0, 0, ""168+ if r.Linked() {169+ me.data, me.stale = 0, true181170 }182171 return changed183172 }184173 185-// planRegion adds the cells r writes to want, recording where they are,186-// and returns the formulas to recalculate because its table moved.187-func (s *Sheet) planRegion(r Region, want map[Addr]regionCell) []loc {188- k := nameKey(r.Name)189- me := s.meta(k)190- d := s.regions.data[k]174+// writeTable makes the region r show header and rows under it: with175+// reset, rows replace every row; otherwise they follow those shown, and176+// nil rows show already. It writes only the cells that differ and177+// clears the region's cells it no longer needs; when a cell it needs178+// holds something else, it shows only why. It returns the cells that179+// changed, with the formulas naming the region when its table moved.180+func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []LiveRow, reset bool) []loc {181+ cols, nrows := tableSize(me, header, rows, reset)182+ was, had := me.written, me.has183+ need := s.tableArea(r, cols, nrows)191184 me.why = ""192- area := s.regionArea(r)193- if d != nil {194- need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}195- if need.To.Row >= MaxRows || need.To.Col >= MaxCols {196- me.why = "can't show: it would go past the edge of the sheet"197- } else if at, ok := s.inTheWay(need, me); ok {185+ switch {186+ case need.To.Row >= MaxRows || need.To.Col >= MaxCols:187+ me.why = "can't show: it would go past the edge of the sheet"188+ default:189+ if at, ok := s.inTheWay(need, me); ok {198190 me.why = "can't show: it would overwrite data in " + at.String()199- } else {200- area = need201191 }202192 }193+ var changed []loc203194 if me.why != "" {204- area = Rect{From: r.At, To: r.At} // the label says why195+ me.need, me.rows, me.cols = need, 0, 0196+ need, header, rows, cols, nrows = Rect{From: r.At, To: r.At}, nil, nil, 0, 0197+ }198+ changed = append(changed, s.clearOwned(me, need)...)199+ 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 != "" {203+ changed = s.writeRegionCell(r.At, LiveCell{V: ErrRef}, me, changed)204+ }205+ o := tableOrigin(r)206+ if cols > 0 {207+ changed = s.writeRow(o, header, cols, true, me, changed)208+ }209+ for i, row := range rows {210+ // A row written over one that showed clears what it doesn't211+ // fill; one below the rows shown has nothing to clear.212+ if row != nil {213+ changed = s.writeRow(Addr{Col: o.Col, Row: o.Row + 1 + i}, row, cols, i+1 < me.rows || reset, me, changed)214+ }205215 }206- var moved []loc207- if !me.has || me.written != area {216+ me.rows, me.cols = nrows, cols217+ if r.Linked() && !me.fitted && nrows > 1 {218+ me.fitted = true219+ s.fitTable(o, nrows, cols)220+ }221+ for _, c := range changed {222+ s.freedFor(c.a)223+ }224+ if !had || was != me.written {208225 for u := range s.wb.nameUsers[nameKey(r.FormulaName())] {209- moved = append(moved, u)226+ changed = append(changed, u)210227 }211228 }212- me.written, me.has = area, true213- if !s.writable(r.At, me) {214- return moved229+ return changed230+}231+232+// tableSize is how many columns and rows, header included, a table233+// takes to show header and rows: none when there's nothing to show.234+func tableSize(me *regionMeta, header LiveRow, rows []LiveRow, reset bool) (cols, nrows int) {235+ cols = len(header)236+ for _, r := range rows {237+ cols = max(cols, len(r))238+ }239+ if !reset {240+ cols = max(cols, me.cols)215241 }216- want[r.At] = regionCell{v: Value{Kind: Text, Str: s.regionLabel(r)}}217- if d == nil || me.why != "" {218- return moved242+ if cols > 0 {243+ nrows = 1 + len(rows)219244 }220- order := sortedTable(d, r.Sort)221- for row := range d.Rows {222- for col := range d.Cols {223- v, f := d.At(order[row], col)224- if v.Kind != Empty {225- want[Addr{Col: r.At.Col + col, Row: r.At.Row + 1 + row}] = regionCell{v, f}226- }245+ return cols, nrows246+}247+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.251+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}}258+}259+260+// writeRow writes a table's row at at, cols cells wide, adding the cells261+// that changed to changed. With all, the cells the row leaves blank are262+// cleared; otherwise they're blank already.263+func (s *Sheet) writeRow(at Addr, row LiveRow, cols int, all bool, me *regionMeta, changed []loc) []loc {264+ for c := range cols {265+ var lc LiveCell266+ if c < len(row) {267+ lc = row[c]227268 }269+ if lc.V.Kind == Empty && !all {270+ continue271+ }272+ changed = s.writeRegionCell(Addr{Col: at.Col + c, Row: at.Row}, lc, me, changed)228273 }229- return moved274+ return changed230275 }231276 232-// regionLabel is what a region's label line says: its name and command,233-// and what it's doing.277+// writeRegionCell makes the cell at a show c as the region's, keeping278+// its formatting and note, when it's free for the region to write,279+// adding it to changed if it changed.280+func (s *Sheet) writeRegionCell(a Addr, c LiveCell, me *regionMeta, changed []loc) []loc {281+ if !s.writable(a, me) {282+ return changed283+ }284+ if s.writeSpilled(a, c.V, c.F) {285+ changed = append(changed, loc{s, a})286+ }287+ return changed288+}289+290+// regionLabel is what a command region's label line says: its name and291+// command, and what it's doing.234292 func (s *Sheet) regionLabel(r Region) string {235293 label := r.Name + " " + r.Command236294 if st := s.RegionStatus(r.Name); st != "" {@@ -240,16 +298,37 @@ func (s *Sheet) regionLabel(r Region) string {
240298 }241299 242300 // inTheWay returns a cell of need holding something the region with243-// meta me didn't write.301+// meta me didn't write. Only what the region didn't hold is looked at:302+// nothing else can be placed in a region's cells.244303 func (s *Sheet) inTheWay(need Rect, me *regionMeta) (Addr, bool) {245- for a := range s.cells.anyKeysIn(need) {246- if s.cells.filledAt(a) && !s.regionOwns(a, me) {247- return a, true304+ for _, part := range fresh(need, me) {305+ for a := range s.cells.anyKeysIn(part) {306+ if s.cells.filledAt(a) && !s.regionOwns(a, me) {307+ return a, true308+ }248309 }249310 }250311 return Addr{}, false251312 }252313 314+// fresh is the parts of need the region with meta me doesn't hold: all315+// of it, or when need grows from what it holds, the strips to its right316+// and below, so rows arriving cost what they add.317+func fresh(need Rect, me *regionMeta) []Rect {318+ w := me.written319+ if !me.has || w.From != need.From {320+ return []Rect{need}321+ }322+ var out []Rect323+ if need.To.Col > w.To.Col {324+ out = append(out, Rect{From: Addr{Col: w.To.Col + 1, Row: need.From.Row}, To: need.To})325+ }326+ if need.To.Row > w.To.Row {327+ out = append(out, Rect{From: Addr{Col: need.From.Col, Row: w.To.Row + 1}, To: Addr{Col: min(need.To.Col, w.To.Col), Row: need.To.Row}})328+ }329+ return out330+}331+253332 // writable reports whether the cell at a is free for the region with254333 // meta me to write.255334 func (s *Sheet) writable(a Addr, me *regionMeta) bool {@@ -267,15 +346,16 @@ func (s *Sheet) regionOwns(a Addr, me *regionMeta) bool {
267346 return !spilled268347 }269348 270-// clearRegion clears the cells a region wrote in area that want doesn't271-// cover.272-func (s *Sheet) clearRegion(area Rect, want map[Addr]regionCell) []loc {349+// clearOwned clears the cells the region with meta me wrote outside350+// keep, turning them back into what they were: blank, or formatting351+// only.352+func (s *Sheet) clearOwned(me *regionMeta, keep Rect) []loc {353+ if !me.has || keep.Contains(me.written.From) && keep.Contains(me.written.To) {354+ return nil355+ }273356 var gone []Addr274- for a := range s.cells.anyKeysIn(area) {275- if _, ok := want[a]; ok || s.cells.derivedAt(a) != slotSpill {276- continue277- }278- if _, spilled := s.SpillAnchor(a); !spilled {357+ for a := range s.cells.anyKeysIn(me.written) {358+ if !keep.Contains(a) && s.regionOwns(a, me) {279359 gone = append(gone, a)280360 }281361 }@@ -286,137 +366,3 @@ func (s *Sheet) clearRegion(area Rect, want map[Addr]regionCell) []loc {
286366 }287367 return changed288368 }289-290-// writeRegionCell makes the cell at a show c as a region's, keeping its291-// formatting and note, and reports whether it changed.292-func (s *Sheet) writeRegionCell(a Addr, c regionCell) bool {293- was, l, kind := s.cells.derivedOf(a)294- if kind == slotSpill && was == c.v && l.auto == c.f {295- return false296- }297- return s.writeSpilled(a, c.v, c.f)298-}299-300-// sortedTable is the order of d's rows: the header first, then the rest301-// by keys.302-func sortedTable(d *RegionData, keys []SortKey) []int {303- order := make([]int, d.Rows)304- for i := range order {305- order[i] = i306- }307- if len(keys) == 0 || d.Rows < 3 {308- return order309- }310- slices.SortStableFunc(order[1:], func(i, j int) int {311- for _, k := range keys {312- a, _ := d.At(i, k.Col)313- b, _ := d.At(j, k.Col)314- if c := compareKey(a, b, k.Desc); c != 0 {315- return c316- }317- }318- return 0319- })320- return order321-}322-323-// compareKey orders two values of a sort key, blanks last either way.324-func compareKey(a, b Value, desc bool) int {325- switch {326- case a.Kind == Empty && b.Kind == Empty:327- return 0328- case a.Kind == Empty:329- return 1330- case b.Kind == Empty:331- return -1332- case desc:333- return -sortCompare(a, b)334- }335- return sortCompare(a, b)336-}337-338-// shiftRegions keeps the sheet's regions in step with rows or columns339-// inserted or deleted: a region moves and resizes as a range would, and340-// one whose label line was deleted goes.341-func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {342- if len(s.regions.list) == 0 {343- return344- }345- cell, rng := formula.AxisMaps(rows, sp)346- st := s.regionsCopy()347- st.list = st.list[:0]348- for _, r := range s.regions.list {349- at, ok := cell(r.At)350- area, ok2 := rng(s.regionArea(r))351- if !ok || !ok2 {352- delete(st.data, nameKey(r.Name))353- continue354- }355- r.At, r.Rows = at, area.To.Row-at.Row356- if r.Cols > 0 {357- r.Cols = area.To.Col - at.Col + 1358- }359- st.list = append(st.list, r)360- }361- for _, me := range s.regionMeta {362- if w, ok := rng(me.written); ok && me.has {363- me.written = w364- }365- }366- if !sameRegions(st, s.regions) {367- s.putRegions(st)368- }369- s.regionsStale = true370-}371-372-// RegionLabels returns the label lines of the regions on row, each as373-// wide as its table, for drawing them.374-func (s *Sheet) RegionLabels(row int) []Rect {375- var out []Rect376- for _, r := range s.regions.list {377- if r.At.Row == row {378- c := s.covered(r)379- out = append(out, Rect{From: r.At, To: Addr{Col: c.To.Col, Row: row}})380- }381- }382- return out383-}384-385-// spanOf is n rows inserted at row at, or deleted when n is negative.386-func spanOf(at, n int) formula.Span { return formula.Span{At: at, N: n, Size: MaxRows} }387-388-// regionName resolves a name formulas use, nu.r1, to the region's sheet389-// and table.390-func (w *Workbook) regionName(k string) (*Sheet, Rect, bool) {391- if len(k) <= len(regionPrefix) || k[:len(regionPrefix)] != nameKey(regionPrefix) {392- return nil, Rect{}, false393- }394- for _, s := range w.sheets {395- if i := s.regionIndex(k[len(regionPrefix):]); i >= 0 {396- r, ok := s.RegionTable(s.regions.list[i].Name)397- if !ok {398- return s, Rect{}, true399- }400- return s, r, true401- }402- }403- return nil, Rect{}, false404-}405-406-// regionsInUse are the regions formulas name, with their tables, for407-// finding what reads a changed cell.408-func (w *Workbook) regionsInUse() []namedUsers {409- var out []namedUsers410- for _, s := range w.sheets {411- for _, r := range s.regions.list {412- users := w.nameUsers[nameKey(r.FormulaName())]413- if len(users) == 0 {414- continue415- }416- if t, ok := s.RegionTable(r.Name); ok {417- out = append(out, namedUsers{s, t, users})418- }419- }420- }421- return out422-}@@ -105,9 +105,6 @@ type Sheet struct {
105105 // spillAt the anchors by the cells they cover; see spill.go.106106 spills map[Addr]*spill107107 spillAt rangeIndex108- // links are the linked regions, in the order they were made; see109- // linked.go. The list is replaced whole on every change.110- links []*linked111108 112109 view viewState // frozen panes, the filter and merges, see view.go113110 mergeIdx mergeIndex // the merges by the cells they cover, see merge.go@@ -244,10 +241,10 @@ func (s *Sheet) Set(a Addr, input string) error {
244241 if _, ok := s.SpillAnchor(a); ok {245242 return ErrSpillEdit246243 }247- if s.linkedAt(a) != nil {248- return ErrLinkedEdit249- }250- if _, _, ok := s.RegionAt(a); ok {244+ if r, _, ok := s.RegionAt(a); ok {245+ if r.Linked() {246+ return ErrLinkedEdit247+ }251248 return ErrRegionEdit252249 }253250 var err error@@ -370,21 +367,20 @@ func (s *Sheet) place(a Addr, c *Cell) {
370367 if c.Spilled() {371368 c = c.leftover()372369 }373- if len(s.links) > 0 {374- if v, lk, kind := s.cells.derivedOf(a); kind == slotSpill && s.linkedAt(a) != nil {375- // A linked region's cell keeps its value: only its376- // formatting and note are placed.370+ if len(s.regions.list) > 0 {371+ if _, _, ok := s.ownerOf(a); ok {372+ // A region's cell keeps its value: only its formatting and373+ // note are placed.374+ v, lk, _ := s.cells.derivedOf(a)377375 defer s.writeSpilled(a, v, lk.auto)378376 if c != nil {379377 c = c.leftover()380378 }381379 }380+ s.regionTouched(a)382381 }383382 s.trackShape(a, c)384383 s.spillTouched(a, c)385- if len(s.regions.list) > 0 {386- s.regionTouched(a)387- }388384 if c == nil {389385 s.unlink(a)390386 return@@ -332,9 +332,9 @@ func (s *Sheet) SpillAnchor(a Addr) (Addr, bool) {
332332 }333333 334334 // HasSpills reports whether a formula on the sheet spills or would, or a335-// linked region shows rows (whose cells are spilled cells too), so what335+// region shows a table (whose cells are spilled cells too), so what336336 // draws the sheet can skip looking for spilled cells.337-func (s *Sheet) HasSpills() bool { return len(s.spills) > 0 || len(s.links) > 0 }337+func (s *Sheet) HasSpills() bool { return len(s.spills) > 0 || len(s.regions.list) > 0 }338338 339339 // SpillArea returns the cells the formula at a spills into, the anchor340340 // first, when it spills.@@ -56,7 +56,6 @@ type Workbook struct {
5656 5757 macros []Macro // see macros.go5858 macroOrigin string59- linkOrigin string // see linkfile.go6059 6160 shellHistory []string // lines typed at a notebook's prompt: shellhistory.go6261 @@ -75,8 +74,6 @@ type Workbook struct {
7574 // spillWork is the arrays an evaluation pass computed, to spill once7675 // it's done; see spill.go.7776 spillWork spillWork78- // linkSeq is the last linked region's ID; see linked.go.79- linkSeq int8077 // settling is set while regions write their cells; see regionwrite.go.8178 settling bool8279 }@@ -244,7 +241,6 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
244241 cp.charts = slices.Clone(s.charts)245242 cp.rules = s.rules // never changed in place246243 cp.pivot = pivotState{def: s.pivot.def.clone(), stale: s.pivot.def != nil, out: s.pivot.out, fit: s.pivot.fit}247- s.copyLinks(cp)248244 w.change(cp, "duplicate "+s.name, Rect{}, func() {249245 w.recordSheets()250246 w.insert(cp, w.Index(s)+1)@@ -26,18 +26,17 @@ import (
2626 2727 // followState is the linked regions' part of the model.2828 type followState struct {29- by map[int]*follower // by region ID30- // trusted is set once the user agreed to follow the files a31- // workbook from elsewhere links outside its folder; asked once the32- // question was put.33- trusted, asked bool29+ by map[string]*follower // by region name30+ // asked is set once the user was asked whether to follow the files31+ // a workbook from elsewhere links outside its folder.32+ asked bool3433 }3534 3635 // follower is a region's source and where its polling is.3736 type follower struct {38- id int39- key string // the source it was made for, to notice a change40- src live.Source37+ name string38+ key string // the source it was made for, to notice a change39+ src live.Source4140 // busy is set while a poll runs, waiting while one is scheduled;4241 // gone once the region is gone, closing the source when its poll4342 // returns. reload is set from asking the source to read whole until@@ -73,18 +72,18 @@ func (m *Model) syncFollowers() tea.Cmd {
7372 return nil7473 }7574 if m.follow.by == nil {76- m.follow.by = map[int]*follower{}75+ m.follow.by = map[string]*follower{}7776 }78- seen := map[int]bool{}77+ seen := map[string]bool{}7978 var cmds []tea.Cmd8079 for _, r := range regions {81- seen[r.ID] = true80+ seen[r.Name] = true8281 if cmd := m.syncFollower(r); cmd != nil {8382 cmds = append(cmds, cmd)8483 }8584 }86- for id, f := range m.follow.by {87- if !seen[id] {85+ for name, f := range m.follow.by {86+ if !seen[name] {8887 m.dropFollower(f)8988 }9089 }@@ -93,7 +92,7 @@ func (m *Model) syncFollowers() tea.Cmd {
9392 9493 // syncFollower keeps r's follower in step with r.9594 func (m *Model) syncFollower(r sheet.LinkedRegion) tea.Cmd {96- f := m.follow.by[r.ID]95+ f := m.follow.by[r.Name]9796 if f != nil && f.key != m.sourceKey(r.Source) {9897 m.dropFollower(f)9998 f = nil@@ -130,31 +129,31 @@ func (m *Model) newFollower(r sheet.LinkedRegion) *follower {
130129 name := m.linkName(r.Source.Path)131130 k, ok := live.KindOf(name, r.Source.Format)132131 if !ok {133- m.linkFailed(r.ID, "012 doesn't read "+filepath.Ext(name)+" files")132+ m.linkFailed(r.Name, "012 doesn't read "+filepath.Ext(name)+" files")134133 return nil135134 }136135 path, err := m.root.Resolve(name)137136 if err != nil {138- m.linkFailed(r.ID, m.root.Scrub(err.Error()))137+ m.linkFailed(r.Name, m.root.Scrub(err.Error()))139138 return nil140139 }141140 src := live.NewFile(filepath.Base(name), path, k, fileio.Options{Table: r.Source.Table, Query: r.Source.Query, Locale: m.locale()})142- f := &follower{id: r.ID, key: m.sourceKey(r.Source), src: src, reload: true}143- m.follow.by[r.ID] = f141+ f := &follower{name: r.Name, key: m.sourceKey(r.Source), src: src, reload: true}142+ m.follow.by[r.Name] = f144143 return f145144 }146145 147146 // linkFailed shows err in the region id, which can't be followed.148-func (m *Model) linkFailed(id int, err string) {149- if r, ok := m.book().LinkedRegion(id); ok && r.Err == err {147+func (m *Model) linkFailed(name, err string) {148+ if r, ok := m.book().LinkedRegion(name); ok && r.Err == err {150149 return151150 }152- m.book().ApplyLive(sheet.LiveOp{Link: id, Err: err})151+ m.book().ApplyLive(sheet.LiveOp{Region: name, Err: err})153152 }154153 155154 // dropFollower lets a follower go, now or when its poll returns.156155 func (m *Model) dropFollower(f *follower) {157- delete(m.follow.by, f.id)156+ delete(m.follow.by, f.name)158157 f.gone = true159158 if !f.busy {160159 f.src.Close()@@ -176,11 +175,11 @@ func (m *Model) handleFollow(msg tea.Msg) tea.Cmd {
176175 case followTickMsg:177176 f := msg.f178177 f.waiting = false179- if m.follow.by[f.id] != f {178+ if m.follow.by[f.name] != f {180179 f.src.Close() // of a workbook no longer open181180 return nil182181 }183- if r, ok := m.book().LinkedRegion(f.id); !ok || r.Paused {182+ if r, ok := m.book().LinkedRegion(f.name); !ok || r.Paused {184183 return nil185184 }186185 return pollFollower(f)@@ -188,7 +187,7 @@ func (m *Model) handleFollow(msg tea.Msg) tea.Cmd {
188187 f := msg.f189188 f.busy = false190189 switch {191- case f.gone || m.follow.by[f.id] != f:190+ case f.gone || m.follow.by[f.name] != f:192191 f.src.Close()193192 return nil194193 case f.pending:@@ -211,8 +210,7 @@ func (m *Model) applyUpdate(f *follower, u live.Update) {
211210 if u.Reset {212211 f.reload = false213212 }214- _, err := m.book().ApplyLive(u.Op(f.id))215- if errors.Is(err, sheet.ErrNoLinked) {213+ if err := m.book().ApplyLive(u.Op(f.name)); errors.Is(err, sheet.ErrNoRegion) {216214 m.dropFollower(f)217215 }218216 }@@ -268,11 +266,11 @@ func outside(path string) bool {
268266 }269267 270268 // linkTrusted reports whether r may be followed without asking: its271-// file is in the workbook's folder or below, or the workbook's links272-// were made or trusted on this computer, or the user said so.269+// file is in the workbook's folder or below, or the workbook's macros270+// and regions were made or trusted on this computer (macroTrusted: one271+// trust for all a file can make 012 do), or the user said so.273272 func (m *Model) linkTrusted(r sheet.LinkedRegion) bool {274- o := m.book().LinkOrigin()275- return !outside(r.Source.Path) || m.follow.trusted || o != "" && o == m.macros.machine273+ return !outside(r.Source.Path) || m.macroTrusted()276274 }277275 278276 // askLinkTrust asks once whether to follow the files a workbook from@@ -293,11 +291,11 @@ func (m *Model) askLinkTrust() {
293291 warn: true,294292 desc: "Following reads those files as they change; not following leaves their regions empty",295293 choices: []choice{296- {key: "enter", label: "Follow them", run: func(m *Model) tea.Cmd { m.trustLinks(); return nil }},294+ {key: "enter", label: "Follow them", run: func(m *Model) tea.Cmd { m.trustHere(); return nil }},297295 {key: "esc", label: "Don't follow", run: func(m *Model) tea.Cmd {298296 for _, r := range m.book().LinkedRegions() {299297 if !m.linkTrusted(r) {300- m.linkFailed(r.ID, "Not followed: the spreadsheet was made on another computer")298+ m.linkFailed(r.Name, "Not followed: the spreadsheet was made on another computer")301299 }302300 }303301 return nil@@ -305,11 +303,3 @@ func (m *Model) askLinkTrust() {
305303 },306304 })307305 }308-309-// trustLinks trusts the workbook's links from now on, on this computer.310-func (m *Model) trustLinks() {311- m.follow.trusted = true312- if m.macros.machine != "" {313- m.book().SetLinkOrigin(m.macros.machine)314- }315-}@@ -179,7 +179,7 @@ func TestLinksFromElsewhereAskOnce(t *testing.T) {
179179 t.Chdir(dir)180180 outside := filepath.Join(t.TempDir(), "far.csv")181181 os.WriteFile(outside, []byte("a\n1\n"), 0o644)182- book := `{"version": 2, "linkOrigin": "another", "cells": {}, "links": [{"at": "A1", "path": ` + jsonQuote(outside) + `}]}`182+ book := `{"version": 2, "macroOrigin": "another", "cells": {}, "regions": [{"name": "far", "at": "A1", "path": ` + jsonQuote(outside) + `}]}`183183 writeFile(t, "b.012", book)184184 for _, answer := range []string{"<esc>", "<enter>"} {185185 m := newModel()@@ -194,13 +194,13 @@ func TestLinksFromElsewhereAskOnce(t *testing.T) {
194194 if followed != (answer == "<enter>") {195195 t.Errorf("%s: followed %v", answer, followed)196196 }197- if answer == "<enter>" && m.book().LinkOrigin() != "this-one" {198- t.Errorf("trusting didn't record this computer: %q", m.book().LinkOrigin())197+ if answer == "<enter>" && m.book().MacroOrigin() != "this-one" {198+ t.Errorf("trusting didn't record this computer: %q", m.book().MacroOrigin())199199 }200200 }201201 // A file in the workbook's own folder needs no question.202202 writeFile(t, "near.csv", "a\n1\n")203- writeFile(t, "c.012", `{"version": 2, "linkOrigin": "another", "cells": {}, "links": [{"at": "A1", "path": "near.csv"}]}`)203+ writeFile(t, "c.012", `{"version": 2, "macroOrigin": "another", "cells": {}, "regions": [{"name": "near", "at": "A1", "path": "near.csv"}]}`)204204 m := newModel()205205 m.SetMachine("this-one")206206 press(t, m, "<ctrl+o>", "c", "<enter>")@@ -218,7 +218,7 @@ func TestLinkServedConfined(t *testing.T) {
218218 m := newModel()219219 m.root, _ = confine.New(dir)220220 m.SetMachine("here")221- m.book().SetLinkOrigin("here")221+ m.book().SetMacroOrigin("here")222222 out, _ := m.sheet.AddLinked(addr("A1"), sheet.LinkSource{Path: "../out.csv"})223223 m.sheet.AddLinked(addr("C1"), sheet.LinkSource{Path: "in.csv"})224224 pump(t, m)@@ -84,7 +84,7 @@ func followRun(b *testing.B, window int) followResult {
8484 u, ok := src.Poll(context.Background())8585 if ok {8686 at := time.Now()87- if _, err := m.book().ApplyLive(u.Op(id)); err != nil {87+ if err := m.book().ApplyLive(u.Op(id)); err != nil {8888 b.Fatal(err)8989 }9090 applies = append(applies, ms(time.Since(at)))@@ -33,6 +33,7 @@ type lineCtx struct {
3333 shaped bool // the sheet wraps, draws borders or merges3434 merges []sheet.Rect // merges crossing the row3535 labels labelSpans // regions' label lines on the row36+ tables labelSpans // command regions' tables on the row, drawn plain3637 }3738 3839 // labelSpans are the label lines of regions on a row.@@ -53,6 +54,7 @@ func (m *Model) lineContext(row int, ln rowtext.Line) lineCtx {
5354 lc.sel, lc.selecting = m.highlight()5455 if m.sheet.HasRegions() {5556 lc.labels = m.sheet.RegionLabels(row)57+ lc.tables = m.sheet.CommandTables(row)5658 }5759 if lc.shaped {5860 lc.merges = m.sheet.MergesIn(sheet.Rect{From: sheet.Addr{Row: row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: row}})@@ -33,7 +33,7 @@ func init() {
3333 enabled: hasLinked, checked: func(m *Model) bool { r, ok := m.currentLinked(); return ok && !r.Paused },3434 run: func(m *Model) tea.Cmd {3535 r, _ := m.currentLinked()36- m.book().PauseLinked(r.ID, !r.Paused)36+ m.book().PauseLinked(r.Name, !r.Paused)3737 m.note = "Following " + filepath.Base(r.Source.Path)3838 if !r.Paused {3939 m.note = "Paused following " + filepath.Base(r.Source.Path)@@ -44,7 +44,7 @@ func init() {
4444 desc: "Read the linked file under the pointer again, whole", enabled: hasLinked,4545 run: func(m *Model) tea.Cmd {4646 r, _ := m.currentLinked()47- m.book().ReloadLinked(r.ID)47+ m.book().ReloadLinked(r.Name)4848 return nil4949 }})5050 register(&command{id: "data.link_rows", macro: macroNever, title: "Rows to keep",@@ -54,7 +54,7 @@ func init() {
5454 m.askFollowRows(filepath.Base(r.Source.Path), func(m *Model, window int) tea.Cmd {5555 src := r.Source5656 src.Window = window57- if err := m.book().SetLinkSource(r.ID, src); err != nil {57+ if err := m.book().SetLinkSource(r.Name, src); err != nil {5858 m.fail(err.Error())5959 }6060 return nil@@ -65,7 +65,7 @@ func init() {
6565 desc: "Keep the linked file's rows as values and stop following it", enabled: hasLinked,6666 run: func(m *Model) tea.Cmd {6767 r, _ := m.currentLinked()68- if err := m.book().Unlink(r.ID); err != nil {68+ if err := m.book().Unlink(r.Name); err != nil {6969 m.fail(err.Error())7070 return nil7171 }@@ -232,11 +232,12 @@ func (m *Model) followInNewSheet(src sheet.LinkSource) {
232232 m.note = "Following " + filepath.Base(src.Path) + " in " + s.Name()233233 }234234 235-// linkedHere trusts the workbook's links on this computer once the user236-// links a file, unless others came from elsewhere untrusted.235+// linkedHere trusts the workbook on this computer once the user links a236+// file, unless regions or macros came from elsewhere untrusted.237237 func (m *Model) linkedHere(before int) {238- if before == 0 || m.follow.trusted || m.book().LinkOrigin() == m.macros.machine {239- m.trustLinks()238+ w := m.book()239+ if m.macroTrusted() || before == 0 && len(w.Macros()) == 0 && len(w.RunOrder()) == 0 {240+ m.trustHere()240241 }241242 }242243 @@ -256,7 +256,7 @@ func TestMonochromeSpill(t *testing.T) {
256256 func TestMonochromeLinked(t *testing.T) {257257 m := newModel()258258 id, _ := m.sheet.AddLinked(addr("B1"), sheet.LinkSource{Path: "log.csv"})259- m.book().ApplyLive(sheet.LiveOp{Link: id, Reset: true, Header: sheet.LiveRow{{V: sheet.Value{Kind: sheet.Text, Str: "n"}}},259+ m.book().ApplyLive(sheet.LiveOp{Region: id, Reset: true, Header: sheet.LiveRow{{V: sheet.Value{Kind: sheet.Text, Str: "n"}}},260260 Rows: []sheet.LiveRow{{{V: sheet.Value{Kind: sheet.Number, Num: 2}}}}})261261 m.cur = addr("D1")262262 c := cellAt(m, addr("B2"))@@ -274,7 +274,7 @@ func TestMonochromeLinked(t *testing.T) {
274274 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "‖ Paused log.csv") {275275 t.Errorf("context line: %q", l)276276 }277- m.book().ApplyLive(sheet.LiveOp{Link: id, Err: "the file isn't there"})277+ m.book().ApplyLive(sheet.LiveOp{Region: id, Err: "the file isn't there"})278278 if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "! log.csv: the file isn't there") {279279 t.Errorf("context line: %q", l)280280 }@@ -19,7 +19,7 @@ import (
1919 // the prompt, the commands and the trust a file's commands need.2020 2121 func init() {22- onRegion := func(m *Model) bool { _, _, ok := m.sheet.RegionAt(m.cur); return ok }22+ onRegion := func(m *Model) bool { r, _, ok := m.sheet.RegionAt(m.cur); return ok && !r.Linked() }2323 register(2424 &command{id: "nu.prompt", macro: macroNever, title: "Shell",2525 desc: "Run a nushell pipeline; its table becomes a live region of the notebook sheet",@@ -254,6 +254,10 @@ func (m *Model) trustShell(run func(*Model) tea.Cmd) tea.Cmd {
254254 // its columns in keys: the rows below its header, whatever else r255255 // holds. The order is the region's, kept when it runs again.256256 func (m *Model) sortRegion(reg sheet.Region, r sheet.Rect, keys []sheet.SortKey) tea.Cmd {257+ if reg.Linked() {258+ m.note = "A linked file's rows keep the file's order: unlink it to sort them"259+ return nil260+ }257261 t, ok := m.sheet.RegionTable(reg.Name)258262 inTable := func(k sheet.SortKey) bool { return k.Col >= t.From.Col && k.Col <= t.To.Col }259263 if !ok || len(keys) == 0 || !slices.ContainsFunc(keys, inTable) || r.To.Row <= t.From.Row {@@ -335,6 +339,9 @@ func (m *Model) regionLine() string {
335339 }336340 return ""337341 }342+ if r.Linked() {343+ return m.linkedLine() // linked.go344+ }338345 if f := m.shell.failed[strings.ToUpper(r.Name)]; f != "" && label {339346 return m.th.Warning.Render(r.Name+" failed: "+f) + " " + m.th.KeyHints("F2", "edit")340347 }@@ -353,7 +360,7 @@ func (m *Model) regionLine() string {
353360 func (m *Model) notRun() int {354361 n := 0355362 for _, r := range m.sheet.Regions() {356- if !m.sheet.RegionShown(r.Name) {363+ if !r.Linked() && !m.sheet.RegionShown(r.Name) {357364 n++358365 }359366 }@@ -366,7 +373,7 @@ func (m *Model) regionEntry() (string, bool) {
366373 return "", false367374 }368375 r, _, ok := m.sheet.RegionAt(m.cur)369- if !ok {376+ if !ok || r.Linked() {370377 return "", false371378 }372379 return m.th.Muted.Render(nuprompt.Mark + r.Command), true@@ -239,9 +239,6 @@ func (m *Model) contextLineText() string {
239239 if left == "" {240240 left = m.errorLine()241241 }242- if left == "" {243- left = m.linkedLine() // linked.go244- }245242 if left == "" {246243 left = m.validationLine() // looks.go247244 }@@ -64,11 +64,11 @@ func (m *Model) refuseEdit(r sheet.Rect, keepsSpills bool) bool {
6464 if keepsSpills {6565 return false6666 }67- if _, ok := m.sheet.InLinked(r); ok {68- m.note = sheet.ErrLinkedEdit.Error()69- return true70- }7167 if a, reg, ok := m.sheet.InRegion(r); ok {68+ if reg.Linked() {69+ m.note = sheet.ErrLinkedEdit.Error()70+ return true71+ }7272 m.note = a.String() + " is part of region " + reg.Name + ", which its command fills: change the command, or Freeze the region"7373 return true7474 }@@ -249,7 +249,7 @@ func (m *Model) cellRole(lc *lineCtx, a sheet.Addr, sp *rowtext.Span, spills boo
249249 return &m.th.Muted, true250250 case v.Kind == sheet.Error:251251 return &m.th.ErrorCell, true252- case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: lc.row}).Spilled():252+ case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: lc.row}).Spilled() && !lc.tables.Contains(sheet.Addr{Col: sp.Owner, Row: lc.row}):253253 return &m.th.Spilled, true254254 }255255 return &m.th.Cell, false