cat / 012
clone

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

Notebooks: a nushell prompt on the formula bar, regions run in the background

Dami committed 281252f
! (Data > Shell, the palette, or 012 nu) opens the nu❯ prompt: NU in
the mode indicator, the workbook's history on Up and Down (saved with
it), completions of regions and nu's commands. Each line becomes a
region of the notebook sheet (the one shown, the first, or a new Shell
1), named r1 or by name = pipeline; it runs in the background, Esc
stops it, and refreshing a region (Enter on its label, Data > Refresh
region) runs what reads it after it. F9 runs all. A file's commands
never run on open, and a file from another computer asks once (the
macros' trust); shell = off|ask|on, nu-timeout, nu-config and
serve-shell are options.
commit
281252f8e0d716553539b0bf144d589613193fee
parent
2231a9a85d79578b5342efaa3a87815b5bc444da
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.0 of 3, Jev's estimate
29 files changed, +1538 -17
Mcmd/012/main.go9+
Acmd/012/nu_test.go32+++
Mdocs/files/format.md1+
Mdocs/reference/config.md49++++
Mdocs/reference/keys.md10+
Minternal/config/registry.go16+-
Minternal/sheet/file.go14+-
Minternal/sheet/fileread.go1+
Minternal/sheet/filestream_test.go3+-
Minternal/sheet/region.go4+-
Minternal/sheet/regionwrite.go13+
Ainternal/sheet/shellhistory.go35+++
Minternal/sheet/workbook.go2+
Minternal/ui/file.go5+
Minternal/ui/gridlines.go17+
Minternal/ui/hosts.go16+
Minternal/ui/menubar.go3+
Minternal/ui/model.go16+-
Minternal/ui/model_test.go2+-
Ainternal/ui/notebook.go369++++++++++++++++++++++++++++++
Ainternal/ui/notebook_test.go244++++++++++++++++++++
Ainternal/ui/nuprompt/nuprompt.go331+++++++++++++++++++++++++++
Ainternal/ui/nurun.go325++++++++++++++++++++++++++
Minternal/ui/overlay/overlay.go10+
Minternal/ui/panel.go9+
Minternal/ui/pivot.go4+
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go10+-
Minternal/ui/view.go3+
M cmd/012/main.go +9 -0
A cmd/012/nu_test.go +32 -0
M docs/files/format.md +1 -0
M docs/reference/config.md +49 -0
@@ -74,6 +74,9 @@ start.
7474 | [`keymap`](#keymap) | `default` | `O12_KEYMAP` |7575 | [`locale`](#locale) | `en-US` | `O12_LOCALE` |7676 | [`max-cells`](#max-cells) | `10000000` | `O12_MAX_CELLS` |77+| [`shell`](#shell) | `ask` | `O12_SHELL` |78+| [`nu-timeout`](#nu-timeout) | `30s` | `O12_NU_TIMEOUT` |79+| [`nu-config`](#nu-config) | `false` | `O12_NU_CONFIG` |7780 | [`jev-api-key-command`](#jev-api-key-command) | | |7881 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |7982 | [`jev-base-url`](#jev-base-url) | | `TYPESAFE_BASE_URL` |
@@ -86,6 +89,7 @@ start.
8689 | [`serve-host-key`](#serve-host-key) | | |8790 | [`serve-idle-timeout`](#serve-idle-timeout) | `30m` | |8891 | [`serve-max-sessions`](#serve-max-sessions) | `8` | |92+| [`serve-shell`](#serve-shell) | `false` | |8993 | [`config-file`](#config-file) | | |9094 9195 ### Appearance
@@ -160,6 +164,41 @@ The most cells an import keeps, and a paste or fill writes at once. Numbers and
160164 | Environment | `O12_MAX_CELLS` |161165 | Applies | File > Settings > Reload config |162166 167+### Nushell notebooks168+169+#### `shell`170+171+Whether notebook sheets run nushell commands (docs/terminal/nushell.md). `ask` runs what you type and asks once before running the commands of a file made on another computer; `on` never asks; `off` runs none. Opening a file never runs its commands.172+173+| | |174+|---|---|175+| Type | one of `off`, `ask`, `on` |176+| Default | `ask` |177+| Environment | `O12_SHELL` |178+| Applies | File > Settings > Reload config |179+180+#### `nu-timeout`181+182+Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it.183+184+| | |185+|---|---|186+| Type | duration |187+| Default | `30s` |188+| Environment | `O12_NU_TIMEOUT` |189+| Applies | File > Settings > Reload config |190+191+#### `nu-config`192+193+Run notebook commands with your nushell config files (config.nu, env.nu) rather than `nu --no-config-file`, for your own commands and aliases.194+195+| | |196+|---|---|197+| Type | true or false |198+| Default | `false` |199+| Environment | `O12_NU_CONFIG` |200+| Applies | File > Settings > Reload config |201+163202 ### JEV functions164203 165204 #### `jev-api-key-command`
@@ -294,6 +333,16 @@ How many 012 serve sessions may run at once; more are turned away.
294333 | Default | `8` |295334 | Applies | restart 012 |296335 336+#### `serve-shell`337+338+Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, following the shell option. Off, served notebooks show their regions but run nothing.339+340+| | |341+|---|---|342+| Type | true or false |343+| Default | `false` |344+| Applies | restart 012 |345+297346 ### Config files298347 299348 #### `config-file`
M docs/reference/keys.md +10 -0
M internal/config/registry.go +15 -1
@@ -75,11 +75,12 @@ const (
7575 GroupTelemetry = "Telemetry"7676 GroupServe = "012 serve"7777 GroupData = "Data"78+ GroupShell = "Nushell notebooks"7879 GroupFiles = "Config files"7980 )8081 8182 // Groups is the order groups are listed in.82-var Groups = []string{GroupAppearance, GroupData, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}83+var Groups = []string{GroupAppearance, GroupData, GroupShell, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}8384 8485 // Options is every setting. Add an option here and read it with8586 // Config.String, Bool or List; parsing, `012 config`, docs/reference/config.md
@@ -117,6 +118,16 @@ var Options = []Option{
117118 "up to the budget and say how many they left out; larger pastes and fills are refused. " +118119 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},119120 121+ {Name: "shell", Kind: Enum, Group: GroupShell, Default: "ask", Values: []string{"off", "ask", "on"}, Env: []string{"O12_SHELL"}, Live: true,122+ Desc: "Whether notebook sheets run nushell commands (docs/terminal/nushell.md). `ask` runs what you type " +123+ "and asks once before running the commands of a file made on another computer; `on` never asks; " +124+ "`off` runs none. Opening a file never runs its commands."},125+ {Name: "nu-timeout", Kind: Duration, Group: GroupShell, Default: "30s", Env: []string{"O12_NU_TIMEOUT"}, Live: true,126+ Desc: "Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it."},127+ {Name: "nu-config", Kind: Bool, Group: GroupShell, Default: "false", Env: []string{"O12_NU_CONFIG"}, Live: true,128+ Desc: "Run notebook commands with your nushell config files (config.nu, env.nu) rather than " +129+ "`nu --no-config-file`, for your own commands and aliases."},130+120131 {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,121132 Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +122133 "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +
@@ -153,6 +164,9 @@ var Options = []Option{
153164 Desc: "End a 012 serve session that has had no input for this long; 0 never does."},154165 {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",155166 Desc: "How many 012 serve sessions may run at once; more are turned away."},167+ {Name: "serve-shell", Kind: Bool, Group: GroupServe, Default: "false",168+ Desc: "Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, " +169+ "following the shell option. Off, served notebooks show their regions but run nothing."},156170 157171 {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,158172 Desc: "Read another config file after this one, relative to this file's directory. " +
M internal/sheet/file.go +10 -4
M internal/sheet/fileread.go +1 -0
M internal/sheet/filestream_test.go +2 -1
M internal/sheet/region.go +2 -2
M internal/sheet/regionwrite.go +13 -0
A internal/sheet/shellhistory.go +35 -0
M internal/sheet/workbook.go +2 -0
M internal/ui/file.go +5 -0
M internal/ui/gridlines.go +17 -0
M internal/ui/hosts.go +16 -0
@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/012/internal/ui/filterpick"1212 "github.com/FelineStateMachine/012/internal/ui/findbar"1313 "github.com/FelineStateMachine/012/internal/ui/lineedit"14+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"1415 "github.com/FelineStateMachine/012/internal/ui/picker"1516 "github.com/FelineStateMachine/012/internal/ui/rules"1617 "github.com/FelineStateMachine/012/internal/ui/shortcuts"
@@ -31,6 +32,7 @@ func (m *Model) host() host { return host{m} }
3132 var (3233 _ picker.Host = host{}3334 _ cmdline.Host = host{}35+ _ nuprompt.Host = host{}3436 _ findbar.Host = host{}3537 _ themepicker.Host = host{}3638 _ rules.Host = host{}
@@ -129,6 +131,20 @@ func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }
129131 func (h host) Fail(msg string) { h.m.fail(msg) }130132 func (h host) History() *cmdline.History { return &h.m.session.history }131133 134+// The shell prompt's host: nuprompt.Host.135+136+func (h host) Submit(line string) tea.Cmd { return h.m.submitShell(line) }137+func (h host) Words() []nuprompt.Word { return h.m.shellWords() }138+func (h host) ShellHistory() []string { return h.m.book().ShellHistory() }139+func (h host) Stop() { h.m.stopShell() }140+func (h host) Said() string { return h.m.shell.said }141+func (h host) Running() string {142+ if j := h.m.shell.running; j != nil {143+ return j.name144+ }145+ return ""146+}147+132148 // The theme picker.133149 134150 func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }
M internal/ui/menubar.go +3 -0
M internal/ui/model.go +12 -4
@@ -43,13 +43,13 @@ func (m mode) String() string {
4343 // line), the column header, the grid, and a status line.4444 const (4545 menuLine = overlay.MenuLine // menu bar on the left, mode indicator on the right46- formulaLine = 1 // name box, then the cell's contents or the entry46+ formulaLine = overlay.FormulaLine // name box, then the cell's contents or the entry4747 contextLine = overlay.ContextLine // prompts, key hints and formula errors4848 panelLines = 34949 headerLine = panelLines5050 gridTop = overlay.GridTop51- minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW52- nameBoxW = 11 // fits most ranges, e.g. "AA100:AB200", without jumping51+ minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW52+ nameBoxW = overlay.FormulaBarX - 1 // fits most ranges, e.g. "AA100:AB200", without jumping5353 )5454 5555 // doubleClick is the longest gap between two clicks that edits a cell.
@@ -130,6 +130,7 @@ type Model struct {
130130 vim vimState // a vim key sequence in progress: vim.go131131 rec *recorder // a macro being recorded: macrorec.go132132 macros macroState // a macro running, and trust in the file's macros: macrorun.go133+ shell shellState // a notebook's commands running, and what they said: nurun.go133134 134135 keyAt time.Time // when the key the next frame answers was pressed, for telemetry135136 spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go
@@ -156,7 +157,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
156157 // so the theme can adapt to light terminals.157158 func (m *Model) Init() tea.Cmd {158159 // jev.send starts any questions queued while loading the file.159- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd())160+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startShell())160161 }161162 162163 // Update implements tea.Model.
@@ -229,6 +230,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
229230 cmd = m.finishMacro(msg.r, msg.done)230231 case macroEditedMsg:231232 m.macroEdited(msg)233+ case nuDoneMsg:234+ cmd = m.finishRegion(msg)235+ case nuWordsMsg:236+ m.shell.words = msg.words232237 case tea.KeyReleaseMsg:233238 cmd = m.keyReleased(msg)234239 case tea.KeyboardEnhancementsMsg:
@@ -325,6 +330,9 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
325330 }326331 }327332 key := k.String()333+ if cmd, ok := m.notebookKey(key); ok {334+ return cmd335+ }328336 if m.recordMove(key) {329337 m.entry.tabbing = false330338 return nil
M internal/ui/model_test.go +1 -1
A internal/ui/notebook.go +369 -0
@@ -0,0 +1,369 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"11+)12+13+// Notebooks: ! (or Data > Shell, or 012 nu) opens a nushell prompt on14+// the formula bar. Each line run becomes a region of the notebook sheet15+// (the one shown, or the first, or a new Shell 1): a live table named16+// r1, r2 and so on, or name = pipeline to name it, which later commands17+// read as $name. Refreshing a region runs it again, then what reads it18+// (nurun.go). The engine keeps the regions (sheet/region.go); this is19+// the prompt, the commands and the trust a file's commands need.20+21+func init() {22+ onRegion := func(m *Model) bool { _, _, ok := m.sheet.RegionAt(m.cur); return ok }23+ register(24+ &command{id: "nu.prompt", macro: macroNever, title: "Shell",25+ desc: "Run a nushell pipeline; its table becomes a live region of the notebook sheet",26+ run: func(m *Model) tea.Cmd { return m.openShell("") }},27+ &command{id: "nu.refresh", macro: macroNever, title: "Refresh region",28+ desc: "Run the region's command again, then every region that reads it",29+ enabled: onRegion,30+ run: func(m *Model) tea.Cmd { return m.refreshRegion(m.regionHere()) }},31+ &command{id: "nu.run_all", macro: macroNever, title: "Run all regions",32+ desc: "Run every region's command, each after the regions it reads",33+ enabled: func(m *Model) bool { return len(m.book().RunOrder()) > 0 },34+ run: (*Model).runAllRegions},35+ &command{id: "nu.edit", macro: macroNever, title: "Edit region's command",36+ desc: "Open the region's command at the prompt, to change it and run it again",37+ enabled: onRegion,38+ run: func(m *Model) tea.Cmd {39+ r := m.regionHere()40+ return m.openShell(r.Name + " = " + r.Command)41+ }},42+ &command{id: "nu.freeze", macro: macroNever, title: "Freeze region",43+ desc: "Turn the region's table into plain values you can edit, and stop running its command",44+ enabled: onRegion,45+ run: func(m *Model) tea.Cmd { return m.freezeRegion(m.regionHere()) }},46+ &command{id: "nu.delete", macro: macroNever, title: "Delete region",47+ desc: "Remove the region, its command and its table",48+ enabled: onRegion,49+ run: func(m *Model) tea.Cmd { return m.deleteRegion(m.regionHere()) }},50+ &command{id: "nu.stop", macro: macroNever, title: "Stop shell command",51+ desc: "Stop the region's command that's running, and those waiting after it",52+ enabled: func(m *Model) bool { return m.shell.running != nil },53+ run: func(m *Model) tea.Cmd { m.stopShell(); return nil }},54+ )55+ keymap["!"] = "nu.prompt"56+ keymap["f9"] = "nu.run_all"57+}58+59+// regionHere is the region at the active cell.60+func (m *Model) regionHere() sheet.Region {61+ r, _, _ := m.sheet.RegionAt(m.cur)62+ return r63+}64+65+// openShell opens the prompt with text on it. What's selected is what66+// the command reads as $in.67+func (m *Model) openShell(text string) tea.Cmd {68+ m.shell.input = ""69+ if m.hasRange() && !m.sheet.Notebook() {70+ m.shell.input = sheet.Qualified(m.sheet.Name(), m.selection())71+ }72+ m.shell.said = ""73+ m.openOverlay(nuprompt.New(m.host(), text))74+ return m.loadWords()75+}76+77+// OpenShell starts on a notebook sheet with the prompt open, as 012 nu78+// does: the workbook's first notebook, or a new sheet made one. A new79+// file's empty sheet becomes the notebook.80+func (m *Model) OpenShell() {81+ s := m.notebookSheet(false)82+ if s == nil {83+ w := m.book()84+ if first := w.Sheet(0); w.Len() == 1 && first.Len() == 0 && !w.CanUndo() {85+ if w.RenameSheet(first, w.NextNotebookName()) == nil {86+ first.MakeNotebook()87+ w.ClearHistory()88+ s = first89+ }90+ }91+ }92+ if s != nil {93+ m.showSheet(s)94+ }95+ m.shell.startup = true96+}97+98+// notebookSheet is where a command's region goes: the sheet shown when99+// it's a notebook, or the first notebook, or with create a new Shell N100+// after the sheet shown.101+func (m *Model) notebookSheet(create bool) *sheet.Sheet {102+ if m.sheet.Notebook() {103+ return m.sheet104+ }105+ for _, s := range m.book().Sheets() {106+ if s.Notebook() {107+ return s108+ }109+ }110+ if !create {111+ return nil112+ }113+ s, err := m.book().AddNotebook("", m.sheet)114+ if err != nil {115+ m.shell.said = m.th.Warning.Render(err.Error())116+ return nil117+ }118+ return s119+}120+121+// splitName splits "name = pipeline" into the name and the pipeline; a122+// line that doesn't start so has no name.123+func splitName(line string) (string, string) {124+ name, rest, ok := strings.Cut(line, "=")125+ name = strings.TrimSpace(name)126+ if !ok || strings.HasPrefix(rest, "=") || sheet.ValidRegionName(name) != nil {127+ return "", line128+ }129+ return name, strings.TrimSpace(rest)130+}131+132+// submitShell runs a line typed at the prompt: a new region, or a named133+// region's new command.134+func (m *Model) submitShell(line string) tea.Cmd {135+ w := m.book()136+ w.AddShellHistory(line)137+ if why := m.shellOff(); why != "" {138+ m.shell.said = m.th.Warning.Render(why)139+ return nil140+ }141+ name, command := splitName(line)142+ if command == "" {143+ m.shell.said = m.th.Warning.Render("Type a pipeline after " + name + " =")144+ return nil145+ }146+ input := ""147+ if strings.Contains(command, "$in") {148+ input = m.shell.input149+ }150+ deps := w.RegionDeps(command)151+ trusted := m.macroTrusted() || len(w.Macros()) == 0 && len(w.RunOrder()) == 0152+ var s *sheet.Sheet153+ var err error154+ if t, r, ok := w.Region(name); ok && name != "" {155+ s, name = t, r.Name156+ err = s.EditRegion(name, command, deps, input)157+ } else {158+ if s = m.notebookSheet(true); s == nil {159+ return nil160+ }161+ if name == "" {162+ name = w.NextRegionName()163+ }164+ err = s.AddRegion(sheet.Region{Name: name, Command: command, Deps: deps, Input: input})165+ }166+ if err != nil {167+ m.shell.said = m.th.Warning.Render(err.Error())168+ return nil169+ }170+ if trusted {171+ m.trustHere() // what you type is yours172+ }173+ m.showSheet(s)174+ if _, r, ok := w.Region(name); ok {175+ m.clearSelection()176+ m.cur = r.At177+ }178+ return m.runRegions(m.withInputs(w.RefreshOrder(name)))179+}180+181+// shellOff says why commands can't run here, or "".182+func (m *Model) shellOff() string {183+ switch {184+ case m.shell.served && !m.configBool("serve-shell", false):185+ return "Shell commands are off in 012 serve (serve-shell in the server's config)"186+ case m.configString("shell", "ask") == "off":187+ return "Shell commands are off (shell = off in your config)"188+ }189+ return ""190+}191+192+// refreshRegion runs a region's command again and then what reads it,193+// asking first when the file's commands came from elsewhere.194+func (m *Model) refreshRegion(r sheet.Region) tea.Cmd {195+ order := m.withInputs(m.book().RefreshOrder(r.Name))196+ return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })197+}198+199+// runAllRegions runs every region, each after those it reads.200+func (m *Model) runAllRegions() tea.Cmd {201+ order := m.book().RunOrder()202+ return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })203+}204+205+// withInputs adds before order the regions its commands read that206+// haven't run yet, so they have tables to read.207+func (m *Model) withInputs(order []string) []string {208+ w := m.book()209+ var before []string210+ for _, name := range w.RunOrder() {211+ if slices.Contains(order, name) {212+ continue213+ }214+ if s, _, ok := w.Region(name); ok && !s.RegionShown(name) && m.readBy(name, order) {215+ before = append(before, name)216+ }217+ }218+ return append(before, order...)219+}220+221+// readBy reports whether a region of names reads name, directly or222+// through others.223+func (m *Model) readBy(name string, names []string) bool {224+ for _, n := range names {225+ if slices.Contains(m.book().RefreshOrder(name), n) {226+ return true227+ }228+ }229+ return false230+}231+232+// trustShell runs run once commands may run: shell = on, or the file's233+// commands were made or trusted here, or the user agrees now.234+func (m *Model) trustShell(run func(*Model) tea.Cmd) tea.Cmd {235+ if why := m.shellOff(); why != "" {236+ m.fail(why)237+ return nil238+ }239+ if m.configString("shell", "ask") == "on" || m.macroTrusted() {240+ return run(m)241+ }242+ m.ask(question{243+ msg: "Run this file's shell commands?",244+ desc: "It was saved on another computer: its commands run as you, with your files",245+ choices: []choice{246+ {key: "enter", label: "Run", run: func(m *Model) tea.Cmd { m.trustHere(); return run(m) }},247+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},248+ },249+ })250+ return nil251+}252+253+// freezeRegion turns a region's table into plain values.254+func (m *Model) freezeRegion(r sheet.Region) tea.Cmd {255+ if m.shell.running != nil && m.shell.running.name == r.Name {256+ m.stopShell()257+ }258+ if err := m.sheet.FreezeRegion(r.Name); err != nil {259+ m.fail(err.Error())260+ return nil261+ }262+ m.note = "Froze " + r.Name + " into values" + m.orphans(r.Name)263+ return nil264+}265+266+// deleteRegion removes a region.267+func (m *Model) deleteRegion(r sheet.Region) tea.Cmd {268+ if m.shell.running != nil && m.shell.running.name == r.Name {269+ m.stopShell()270+ }271+ if err := m.sheet.DeleteRegion(r.Name); err != nil {272+ m.fail(err.Error())273+ return nil274+ }275+ m.note = "Deleted " + r.Name + m.orphans(r.Name)276+ return nil277+}278+279+// orphans says which regions read one that's gone.280+func (m *Model) orphans(name string) string {281+ if by := m.book().DependsOn(name); len(by) > 0 {282+ return "; " + strings.Join(by, ", ") + " read it"283+ }284+ return ""285+}286+287+// notebookKey is Enter and F2 on a region's label (refresh it, edit its288+// command) and Esc while a command runs (stop it), in READY.289+func (m *Model) notebookKey(key string) (tea.Cmd, bool) {290+ switch key {291+ case "esc":292+ if m.shell.running != nil {293+ return m.runCommand("nu.stop"), true294+ }295+ case "enter", "f2":296+ if !m.sheet.HasRegions() || m.hasRange() {297+ return nil, false298+ }299+ if _, label, ok := m.sheet.RegionAt(m.cur); ok && label {300+ if key == "f2" {301+ return m.runCommand("nu.edit"), true302+ }303+ return m.runCommand("nu.refresh"), true304+ }305+ }306+ return nil, false307+}308+309+// regionLine is the context line on a region: what it is and the keys310+// for it, or, on a notebook with regions not run, the key that runs them.311+func (m *Model) regionLine() string {312+ r, label, ok := m.sheet.RegionAt(m.cur)313+ if !ok {314+ if m.sheet.Notebook() && m.notRun() > 0 {315+ return m.th.KeyHints(m.shortcut("nu.run_all"), "run all regions", m.shortcut("nu.prompt"), "shell")316+ }317+ return ""318+ }319+ if f := m.shell.failed[strings.ToUpper(r.Name)]; f != "" && label {320+ return m.th.Warning.Render(r.Name+" failed: "+f) + " " + m.th.KeyHints("F2", "edit")321+ }322+ what := r.Name323+ if n := m.sheet.RegionNote(r.Name); n != "" {324+ what += ": " + n325+ }326+ if label {327+ return m.th.Muted.Render(what+" ") + m.th.KeyHints("Enter", "refresh", "F2", "edit", m.shortcut("nu.run_all"), "run all")328+ }329+ return m.th.Muted.Render("Region " + what + " of " + r.Command)330+}331+332+// notRun counts the sheet's regions that haven't run since the file333+// opened.334+func (m *Model) notRun() int {335+ n := 0336+ for _, r := range m.sheet.Regions() {337+ if !m.sheet.RegionShown(r.Name) {338+ n++339+ }340+ }341+ return n342+}343+344+// regionEntry is what the formula bar shows on a region: its command.345+func (m *Model) regionEntry() (string, bool) {346+ if !m.sheet.HasRegions() {347+ return "", false348+ }349+ r, _, ok := m.sheet.RegionAt(m.cur)350+ if !ok {351+ return "", false352+ }353+ return m.th.Muted.Render(nuprompt.Mark + r.Command), true354+}355+356+// shellWords are the prompt's completions: the regions, and nu's357+// commands once they're known.358+func (m *Model) shellWords() []nuprompt.Word {359+ var out []nuprompt.Word360+ for _, s := range m.book().Sheets() {361+ for _, r := range s.Regions() {362+ out = append(out, nuprompt.Word{Text: "$" + r.Name, Desc: r.Command})363+ }364+ }365+ for _, c := range m.shell.words {366+ out = append(out, nuprompt.Word{Text: c, Desc: "command"})367+ }368+ return out369+}
A internal/ui/notebook_test.go +244 -0
@@ -0,0 +1,244 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/nushell"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/nuprompt"12+)13+14+// fakeNu answers commands from a table of outputs, failing those it15+// lacks, and keeps the jobs it ran.16+type fakeNu struct {17+ out map[string]string18+ jobs []nushell.Job19+}20+21+func (f *fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {22+ if job.Command == "help commands | get name" {23+ _, err := io.WriteString(stdout, `["ls", "where", "sort-by", "str join"]`)24+ return err25+ }26+ f.jobs = append(f.jobs, job)27+ out, ok := f.out[job.Command]28+ if !ok {29+ return &nushell.Error{Msg: "Command `" + job.Command + "` not found"}30+ }31+ _, err := io.WriteString(stdout, out)32+ return err33+}34+35+func notebookModel(t *testing.T, out map[string]string) (*Model, *fakeNu) {36+ t.Helper()37+ m := newModel()38+ nu := &fakeNu{out: out}39+ m.SetShellRunner(nu)40+ m.SetMachine("here")41+ return m, nu42+}43+44+const lsOut = "[[name, size]; [a.txt, 2kb], [b.txt, 10b], [c.txt, 5kb]]"45+46+func TestShellMakesRegions(t *testing.T) {47+ m, nu := notebookModel(t, map[string]string{48+ "ls": lsOut,49+ "$r1 | where size > 1kb": "[[name, size]; [a.txt, 2kb], [c.txt, 5kb]]",50+ })51+ press(t, m, "!")52+ if _, ok := m.overlay.(*nuprompt.Prompt); !ok || m.indicator() != "NU" {53+ t.Fatalf("no prompt: %T %q", m.overlay, m.indicator())54+ }55+ if got := bar(m); got != nuprompt.Mark[:len(nuprompt.Mark)-1] {56+ t.Errorf("formula bar %q", got)57+ }58+ press(t, m, "ls", "<enter>")59+ if m.sheet.Name() != "Shell 1" || !m.sheet.Notebook() {60+ t.Fatalf("on %q, notebook %v", m.sheet.Name(), m.sheet.Notebook())61+ }62+ if got := input(m, "A1"); got != "r1 ls" {63+ t.Errorf("label %q", got)64+ }65+ if got := m.sheet.ShownText(addr("B3")); got != "2.0 kB" {66+ t.Errorf("B3 shows %q", got)67+ }68+ if !strings.Contains(m.shell.said, "r1: 4 rows") {69+ t.Errorf("said %q", m.shell.said)70+ }71+ press(t, m, "$r1 | where size > 1kb", "<enter>")72+ if got := input(m, "A7"); got != "r2 $r1 | where size > 1kb" {73+ t.Errorf("r2's label %q", got)74+ }75+ if len(nu.jobs) != 2 || !strings.Contains(string(nu.jobs[1].Tables["r1"]), "a.txt") {76+ t.Fatalf("r2 read r1 as %q", nu.jobs[len(nu.jobs)-1].Tables)77+ }78+ // Refreshing r1 runs r2 after it.79+ press(t, m, "<esc>")80+ m.cur = addr("A1")81+ nu.out["ls"] = "[[name, size]; [d.txt, 9kb]]"82+ press(t, m, "<enter>")83+ if got := input(m, "A3"); got != "d.txt" {84+ t.Errorf("r1 refreshed to %q", got)85+ }86+ if got := input(m, "A5"); !strings.HasPrefix(got, "r2 ") {87+ t.Errorf("r2 didn't move up: A5 %q", got)88+ }89+ if last := nu.jobs[len(nu.jobs)-1]; last.Command != "$r1 | where size > 1kb" || !strings.Contains(string(last.Tables["r1"]), "d.txt") {90+ t.Errorf("r2 wasn't run after r1: %+v", last)91+ }92+ // Undo takes the refresh back, one region at a time.93+ press(t, m, "<ctrl+z>")94+ press(t, m, "<ctrl+z>")95+ if got := input(m, "A3"); got != "a.txt" {96+ t.Errorf("after undo A3 %q", got)97+ }98+}99+100+func TestShellFails(t *testing.T) {101+ m, _ := notebookModel(t, map[string]string{})102+ press(t, m, "!", "lss", "<enter>")103+ if got := input(m, "A1"); got != "r1 lss failed" {104+ t.Errorf("label %q", got)105+ }106+ if !strings.Contains(line(m, contextLine), "r1 failed: Command `lss` not found") {107+ t.Errorf("context line %q", line(m, contextLine))108+ }109+ press(t, m, "<esc>")110+ m.cur = addr("A1")111+ if !strings.Contains(line(m, contextLine), "r1 failed") {112+ t.Errorf("on the label: %q", line(m, contextLine))113+ }114+ // Named: name = pipeline, and redefining it.115+ m.shell.runner.(*fakeNu).out["ls"] = lsOut116+ press(t, m, "!", "files = ls", "<enter>")117+ if _, r, ok := m.book().Region("files"); !ok || r.Command != "ls" {118+ t.Fatalf("no region files: %+v", r)119+ }120+ press(t, m, "r1 = ls", "<enter>")121+ if got := input(m, "A1"); got != "r1 ls" {122+ t.Errorf("r1 redefined: %q", got)123+ }124+ if got := m.book().Sheet(1).Value(addr("A3")).String(); got != "a.txt" {125+ t.Errorf("r1's table %q", got)126+ }127+}128+129+func TestShellCompletes(t *testing.T) {130+ m, _ := notebookModel(t, map[string]string{"ls": lsOut})131+ press(t, m, "!", "ls", "<enter>", "$")132+ p := m.overlay.(*nuprompt.Prompt)133+ if got := p.Shown(); len(got) != 1 || got[0].Text != "$r1" {134+ t.Fatalf("completions of $: %+v", got)135+ }136+ press(t, m, "<tab>")137+ if m.line.Text() != "$r1" {138+ t.Errorf("tab put %q", m.line.Text())139+ }140+ press(t, m, " | so")141+ if got := p.Shown(); len(got) != 1 || got[0].Text != "sort-by" {142+ t.Errorf("completions of so: %+v", got)143+ }144+ if !strings.Contains(screen(m), "Completions") {145+ t.Error("no completions box")146+ }147+ // History: Up recalls the lines run.148+ m.line.Clear()149+ p.Changed()150+ press(t, m, "<up>")151+ if m.line.Text() != "ls" {152+ t.Errorf("up recalled %q", m.line.Text())153+ }154+ if got := m.book().ShellHistory(); len(got) != 1 || got[0] != "ls" {155+ t.Errorf("history %q", got)156+ }157+}158+159+func TestShellTrust(t *testing.T) {160+ m, nu := notebookModel(t, map[string]string{"ls": lsOut})161+ press(t, m, "!", "ls", "<enter>", "<esc>")162+ if m.book().MacroOrigin() != "here" {163+ t.Errorf("origin %q", m.book().MacroOrigin())164+ }165+ // A file from elsewhere asks before its commands run.166+ m.book().SetMacroOrigin("elsewhere")167+ m.macros.trusted = false168+ m.cur = addr("A1")169+ n := len(nu.jobs)170+ press(t, m, "<enter>")171+ if len(nu.jobs) != n || !strings.Contains(line(m, contextLine), "Run this file's shell commands?") {172+ t.Fatalf("didn't ask: %q", line(m, contextLine))173+ }174+ press(t, m, "<esc>")175+ if len(nu.jobs) != n {176+ t.Error("ran after Esc")177+ }178+ press(t, m, "<f9>", "<enter>")179+ if len(nu.jobs) != n+1 || m.book().MacroOrigin() != "here" {180+ t.Errorf("run all after trusting: %d jobs, origin %q", len(nu.jobs)-n, m.book().MacroOrigin())181+ }182+ // Served, commands are off unless the server allows them.183+ m.shell.served = true184+ press(t, m, "<f9>")185+ if !strings.Contains(m.errMsg, "off in 012 serve") {186+ t.Errorf("served: %q", m.errMsg)187+ }188+}189+190+func TestShellGuardsAndFreeze(t *testing.T) {191+ m, _ := notebookModel(t, map[string]string{"ls": lsOut})192+ press(t, m, "!", "ls", "<enter>", "<esc>")193+ m.cur = addr("A3")194+ press(t, m, "x", "<enter>")195+ if got := input(m, "A3"); got != "a.txt" {196+ t.Errorf("typed over a region: %q", got)197+ }198+ if !strings.Contains(m.note, "region r1") {199+ t.Errorf("note %q", m.note)200+ }201+ if got := bar(m); got != "nu❯ ls" {202+ t.Errorf("formula bar on a region %q", got)203+ }204+ run(m, m.runCommand("nu.freeze"))205+ press(t, m, "x", "<enter>")206+ if got := input(m, "A3"); got != "x" {207+ t.Errorf("frozen A3 %q", got)208+ }209+ if got := input(m, "B4"); got != "10" || m.sheet.ShownText(addr("B4")) != "10 B" {210+ t.Errorf("frozen B4 %q shows %q", got, m.sheet.ShownText(addr("B4")))211+ }212+}213+214+// Opening a saved notebook shows its regions not run, and F9 runs them.215+func TestShellReopened(t *testing.T) {216+ m, _ := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})217+ press(t, m, "!", "ls", "<enter>", "$r1 | first", "<enter>", "<esc>")218+ s := roundTripSheet(t, m.sheet)219+ m2, nu := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})220+ m2.reset(s, "nb.012")221+ if got := input(m2, "A1"); got != "r1 ls not run" {222+ t.Errorf("label %q", got)223+ }224+ if !strings.Contains(line(m2, contextLine), "run all") {225+ t.Errorf("context line %q", line(m2, contextLine))226+ }227+ press(t, m2, "<f9>")228+ if len(nu.jobs) != 2 || input(m2, "A1") != "r1 ls" {229+ t.Errorf("run all: %d jobs, A1 %q", len(nu.jobs), input(m2, "A1"))230+ }231+}232+233+func roundTripSheet(t *testing.T, s *sheet.Sheet) *sheet.Sheet {234+ t.Helper()235+ var b strings.Builder236+ if err := s.Write(&b); err != nil {237+ t.Fatal(err)238+ }239+ got, err := sheet.Read(strings.NewReader(b.String()))240+ if err != nil {241+ t.Fatal(err)242+ }243+ return got244+}
A internal/ui/nuprompt/nuprompt.go +331 -0
@@ -0,0 +1,331 @@
1+// Package nuprompt is the nushell prompt of a notebook: a pipeline typed2+// on the formula bar after nu❯, with the workbook's history on Up and3+// Down and completions of regions ($r1) and nu's commands in a box4+// under the context line. It knows the UI only through Host: running a5+// line, and what's running, are the host's.6+package nuprompt7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/ui/lineedit"16+ "github.com/FelineStateMachine/012/internal/ui/overlay"17+ "github.com/FelineStateMachine/012/internal/ui/theme"18+)19+20+// Host is what the prompt needs of the UI.21+type Host interface {22+ Theme() *theme.Theme23+ Size() (width, height int)24+ // Line is the shared edit line the command is typed in.25+ Line() *lineedit.Line26+ // Close closes the prompt.27+ Close()28+ // Submit runs a line; the prompt stays open for the next.29+ Submit(line string) tea.Cmd30+ // Words are what can be completed: the regions and nu's commands.31+ Words() []Word32+ // ShellHistory is the workbook's lines, oldest first.33+ ShellHistory() []string34+ // Running names the region running, or "" when none is.35+ Running() string36+ // Stop stops what's running.37+ Stop()38+ // Said is what the last run said: how many rows, or why it failed.39+ Said() string40+}41+42+// Word is a completion.43+type Word struct {44+ Text string // what Tab puts on the line: $r1, or a command45+ Desc string // what it is: a region's command, or "command"46+}47+48+// ID identifies the completions' box in mouse events.49+const ID = "nuprompt"50+51+// Mark starts the prompt on the formula bar.52+const Mark = "nu❯ "53+54+// Prompt is the open prompt.55+type Prompt struct {56+ h Host57+ overlay.List58+ shown []Word59+ tabbed bool // the word is a completion Tab put there; Tab again moves on60+ hist browse61+}62+63+// New returns a prompt with the edit line holding text.64+func New(h Host, text string) *Prompt {65+ p := &Prompt{h: h}66+ h.Line().Set(text)67+ p.Changed()68+ return p69+}70+71+// Shown are the completions of the word at the caret.72+func (p *Prompt) Shown() []Word { return p.shown }73+74+func (p *Prompt) Indicator() string { return "NU" }75+76+// word is the word before the caret, and where it starts: a $name, or a77+// command where one goes (at the start, or after |, ( or ;).78+func (p *Prompt) word() (string, int, bool) {79+ head := []rune(p.h.Line().Head())80+ i := len(head)81+ for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {82+ i--83+ }84+ w := string(head[i:])85+ if strings.HasPrefix(w, "$") {86+ return w, i, true87+ }88+ before := strings.TrimRight(string(head[:i]), " \t")89+ command := before == "" || strings.ContainsAny(before[len(before)-1:], "|(;{") ||90+ strings.HasSuffix(before, "=") && !strings.HasSuffix(before, "==")91+ return w, i, command && w != ""92+}93+94+// Changed completes the word at the caret.95+func (p *Prompt) Changed() {96+ p.Sel, p.Top, p.tabbed = 0, 0, false97+ p.shown = p.shown[:0]98+ w, _, ok := p.word()99+ if !ok {100+ return101+ }102+ lw := strings.ToLower(w)103+ var contains []Word104+ for _, it := range p.h.Words() {105+ if strings.HasPrefix(it.Text, "$") != strings.HasPrefix(w, "$") {106+ continue107+ }108+ switch t := strings.ToLower(it.Text); {109+ case t == lw:110+ case strings.HasPrefix(t, lw):111+ p.shown = append(p.shown, it)112+ case len(lw) > 1 && strings.Contains(t, lw):113+ contains = append(contains, it)114+ }115+ }116+ p.shown = append(p.shown, contains...)117+}118+119+func (p *Prompt) Key(k tea.KeyPressMsg) tea.Cmd {120+ line := p.h.Line()121+ switch key := k.String(); key {122+ case "esc":123+ if p.h.Running() != "" {124+ p.h.Stop()125+ return nil126+ }127+ p.h.Close()128+ case "enter":129+ text := strings.TrimSpace(line.Text())130+ if text == "" {131+ return nil132+ }133+ line.Clear()134+ p.hist = browse{}135+ p.Changed()136+ return p.h.Submit(text)137+ case "tab":138+ p.complete(1)139+ case "shift+tab":140+ p.complete(-1)141+ case "up", "down":142+ p.recall(key)143+ case "ctrl+p":144+ p.Move(-1, len(p.shown))145+ case "ctrl+n":146+ p.Move(1, len(p.shown))147+ default:148+ before := line.Text()149+ line.Key(k)150+ if line.Text() != before {151+ p.hist = browse{}152+ p.Changed()153+ }154+ }155+ return nil156+}157+158+// recall is Up and Down: an older or newer line of the history, among159+// those starting with what was typed.160+func (p *Prompt) recall(key string) {161+ d := -1162+ if key == "down" {163+ d = 1164+ }165+ text, ok := p.hist.step(p.h.ShellHistory(), p.h.Line().Text(), d)166+ if !ok {167+ return168+ }169+ p.h.Line().Set(text)170+ p.Changed()171+ p.shown = p.shown[:0] // a line recalled is complete172+}173+174+// complete puts the highlighted completion in place of the word at the175+// caret; pressed again, it moves on to the next (d = 1) or the previous.176+func (p *Prompt) complete(d int) {177+ if len(p.shown) == 0 {178+ return179+ }180+ if p.tabbed {181+ p.Move(d, len(p.shown))182+ }183+ p.put(p.shown[p.Sel].Text)184+ p.tabbed = true185+}186+187+// put replaces the word at the caret with text.188+func (p *Prompt) put(text string) {189+ line := p.h.Line()190+ _, start, _ := p.completionStart()191+ tail := line.Tail()192+ head := string(line.Buf[:start]) + text193+ line.Set(head + tail)194+ line.Pos = len([]rune(head))195+}196+197+// completionStart is where the word Tab replaces starts: the word at the198+// caret, or where it was before the last Tab.199+func (p *Prompt) completionStart() (string, int, bool) {200+ head := []rune(p.h.Line().Head())201+ i := len(head)202+ for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {203+ i--204+ }205+ if p.tabbed { // the word Tab put may hold spaces: str join206+ for _, it := range p.shown {207+ if n := len([]rune(it.Text)); n <= len(head) && string(head[len(head)-n:]) == it.Text {208+ return it.Text, len(head) - n, true209+ }210+ }211+ }212+ return string(head[i:]), i, true213+}214+215+// FormulaBar is what the formula bar shows after the name box.216+func (p *Prompt) FormulaBar() string {217+ return p.h.Theme().Title.Render(Mark) + p.h.Line().Text()218+}219+220+// Cursor is on the formula bar, after the prompt's mark.221+func (p *Prompt) Cursor() (int, int) {222+ return overlay.FormulaBarX + ansi.StringWidth(Mark) + ansi.StringWidth(p.h.Line().Head()), overlay.FormulaLine223+}224+225+// ContextLine says what's running, or what the last run said.226+func (p *Prompt) ContextLine() (string, string) {227+ th := p.h.Theme()228+ if r := p.h.Running(); r != "" {229+ return th.Hint.Render("Running " + r + "…"), th.KeyHints("Esc", "stop")230+ }231+ if said := p.h.Said(); said != "" {232+ return said, th.KeyHints("Esc", "back to the grid")233+ }234+ return th.KeyHints("Enter", "run", "name = ", "name it", "$r1", "a region", "$in", "the selection"), ""235+}236+237+func (p *Prompt) Status() (string, string) {238+ if p.Sel >= len(p.shown) {239+ return "", "" // the sheet's tabs, with the notebook240+ }241+ return p.shown[p.Sel].Desc, p.h.Theme().KeyHints("Tab", "complete", "Ctrl+N", "next", "Enter", "run", "Esc", "grid")242+}243+244+// rows is how many completions show.245+func (p *Prompt) rows() int {246+ _, height := p.h.Size()247+ return max(min(len(p.shown), 8, height-overlay.ContextLine-4), 0)248+}249+250+// Layout draws the completions in a box under the context line.251+func (p *Prompt) Layout() []overlay.Box {252+ rows := p.rows()253+ if rows == 0 {254+ return nil255+ }256+ p.Show(rows)257+ th := p.h.Theme()258+ width, _ := p.h.Size()259+ tw, dw := 0, 0260+ for _, it := range p.shown {261+ tw = max(tw, ansi.StringWidth(it.Text))262+ dw = max(dw, ansi.StringWidth(it.Desc))263+ }264+ inner := min(max(1+tw+2+dw+1, 30), 72, width-2)265+ tw = min(tw, inner-2)266+ dw = max(inner-1-tw-2-1, 0)267+ lines := make([]string, rows)268+ for r := range rows {269+ i := p.Top + r270+ it := p.shown[i]271+ base, dim := th.MenuBar, th.Muted272+ if i == p.Sel {273+ base, dim = th.MenuSelected, th.MenuSelected274+ }275+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Text, tw, "…"), tw)+" ") +276+ dim.Render(theme.PadRight(ansi.Truncate(it.Desc, dw, "…"), dw)) + base.Render(" ")277+ lines[r] = row278+ }279+ footer := strconv.Itoa(p.Sel+1) + " of " + strconv.Itoa(len(p.shown))280+ x := min(overlay.FormulaBarX, max(width-inner-2, 0))281+ return []overlay.Box{{ID: ID, X: x, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Completions", footer, lines)}}282+}283+284+func (p *Prompt) Mouse(e overlay.MouseEvent) tea.Cmd {285+ if e.Box != ID {286+ return nil // the grid stays where it is while typing287+ }288+ i := p.Top + e.Row - 1 // under the top border289+ switch {290+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:291+ p.Move(-1, len(p.shown))292+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:293+ p.Move(1, len(p.shown))294+ case e.Row < 1 || i >= len(p.shown) || i >= p.Top+p.rows():295+ case e.Kind == overlay.MouseMotion:296+ p.Sel = i297+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:298+ p.Sel = i299+ p.put(p.shown[i].Text)300+ p.Changed()301+ }302+ return nil303+}304+305+// browse is a walk through the history with Up and Down: only lines306+// starting with what was typed before the first Up.307+type browse struct {308+ on bool309+ at int // the line shown, len(lines) for what was typed310+ prefix string // what was typed311+}312+313+// step moves d lines through lines (-1 older, 1 newer) from what's314+// shown, skipping lines that don't start with the prefix, and returns315+// the text to show and whether there was a line to move to.316+func (b *browse) step(lines []string, typed string, d int) (string, bool) {317+ if !b.on {318+ *b = browse{on: true, at: len(lines), prefix: typed}319+ }320+ for i := b.at + d; i >= 0 && i <= len(lines); i += d {321+ if i == len(lines) {322+ b.at = i323+ return b.prefix, true324+ }325+ if strings.HasPrefix(lines[i], b.prefix) {326+ b.at = i327+ return lines[i], true328+ }329+ }330+ return "", false331+}
A internal/ui/nurun.go +325 -0
@@ -0,0 +1,325 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "log/slog"9+ "strings"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+14+ "github.com/FelineStateMachine/012/internal/fileio"15+ "github.com/FelineStateMachine/012/internal/nushell"16+ "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/ui/transfer"18+)19+20+// Running regions' commands. One runs at a time, in the background, so21+// the screen stays live and Esc stops it: a region and those waiting22+// after it (what reads it) are a queue, each run when the one before23+// has shown its table. The tables a command reads are taken when it24+// starts; its own table is shown as one undo step when it ends.25+26+// shellState is the notebook's part of the model.27+type shellState struct {28+ runner nushell.Runner // nu, or a fake in tests29+ queue []string // regions waiting to run, in order30+ running *nuJob31+ gen int32+ // input is the range the prompt was opened on, which commands read33+ // as $in; said is what the last run said, for the prompt's context34+ // line; failed is why each region's last run failed, by name key.35+ input string36+ said string37+ failed map[string]string38+ // words are nu's command names, asked for once (asked).39+ words []string40+ asked bool41+ // served is set in a session of 012 serve, where commands run only42+ // when the server's config allows them.43+ served bool44+ // startup opens the prompt once the program starts (012 nu).45+ startup bool46+}47+48+// nuJob is a command running.49+type nuJob struct {50+ name string51+ gen int52+ cancel context.CancelFunc53+ start time.Time54+}55+56+type (57+ // nuDoneMsg is a command finished: its table, or why it has none.58+ nuDoneMsg struct {59+ job *nuJob60+ data *sheet.RegionData61+ err error62+ }63+ // nuWordsMsg brings nu's command names.64+ nuWordsMsg struct{ words []string }65+)66+67+// SetShellRunner makes commands run with r rather than nu, for tests.68+func (m *Model) SetShellRunner(r nushell.Runner) { m.shell.runner = r }69+70+func (m *Model) runner() nushell.Runner {71+ if m.shell.runner == nil {72+ return nushell.Nu{}73+ }74+ return m.shell.runner75+}76+77+// runRegions queues names to run, after what's queued, and starts the78+// first unless one is running.79+func (m *Model) runRegions(names []string) tea.Cmd {80+ for _, name := range names {81+ if !containsFold(m.shell.queue, name) && (m.shell.running == nil || !strings.EqualFold(m.shell.running.name, name)) {82+ m.shell.queue = append(m.shell.queue, name)83+ m.setStatus(name, "waiting")84+ }85+ }86+ if m.shell.running != nil {87+ return nil88+ }89+ return m.nextRegion()90+}91+92+func containsFold(list []string, s string) bool {93+ for _, x := range list {94+ if strings.EqualFold(x, s) {95+ return true96+ }97+ }98+ return false99+}100+101+// setStatus sets what a region's label says.102+func (m *Model) setStatus(name, status string) {103+ if s, _, ok := m.book().Region(name); ok {104+ s.SetRegionStatus(name, status)105+ }106+}107+108+// nextRegion starts the first region queued.109+func (m *Model) nextRegion() tea.Cmd {110+ for len(m.shell.queue) > 0 {111+ name := m.shell.queue[0]112+ m.shell.queue = m.shell.queue[1:]113+ s, r, ok := m.book().Region(name)114+ if !ok {115+ continue116+ }117+ job, err := m.jobFor(r)118+ if err != nil {119+ m.failRegion(r.Name, err.Error())120+ continue121+ }122+ s.SetRegionStatus(r.Name, "Running…")123+ return m.startJob(r.Name, job)124+ }125+ return nil126+}127+128+// jobFor is what running r takes: the tables it reads, as NUON.129+func (m *Model) jobFor(r sheet.Region) (nushell.Job, error) {130+ job := nushell.Job{Command: r.Command, Tables: map[string][]byte{}, Config: m.configBool("nu-config", false)}131+ w := m.book()132+ for _, d := range r.Deps {133+ s, dr, ok := w.Region(d)134+ if !ok {135+ return job, fmt.Errorf("it reads $%s, which isn't a region any more", d)136+ }137+ t, ok := s.RegionTable(dr.Name)138+ if !ok {139+ t = sheet.Rect{From: dr.At, To: dr.At} // nothing to read: an empty table140+ }141+ snap := fileio.Snap(s, t, dr.Name)142+ snap.HiddenRows = nil // a region reads another whole, whatever its filter shows143+ if !ok {144+ snap.Cells = nil145+ }146+ data, err := encodeNUON(snap)147+ if err != nil {148+ return job, err149+ }150+ job.Tables[d] = data151+ }152+ if r.Input != "" {153+ name, ref := sheet.SplitSheet(r.Input)154+ s, rng, ok := w.Lookup(name), sheet.Rect{}, false155+ if s != nil {156+ rng, ok = sheet.ParseRange(ref)157+ }158+ if !ok {159+ return job, fmt.Errorf("its input %s is gone", r.Input)160+ }161+ data, err := encodeNUON(fileio.Snap(s, rng, s.Name()))162+ if err != nil {163+ return job, err164+ }165+ job.Input = data166+ }167+ return job, nil168+}169+170+func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {171+ var b bytes.Buffer172+ if _, err := fileio.Encode(&b, fileio.NUON, snap); err != nil {173+ return nil, err174+ }175+ return b.Bytes(), nil176+}177+178+// startJob runs a job in the background.179+func (m *Model) startJob(name string, job nushell.Job) tea.Cmd {180+ m.shell.gen++181+ ctx, cancel := context.WithCancel(context.Background())182+ j := &nuJob{name: name, gen: m.shell.gen, cancel: cancel, start: time.Now()}183+ m.shell.running = j184+ runner, timeout, parent := m.runner(), m.configDuration("nu-timeout", 30*time.Second), m.spans.Parent()185+ return func() tea.Msg {186+ defer cancel()187+ span := parent.Start("nu", slog.Int("tables", len(job.Tables)))188+ data, err := nushell.Exec(ctx, runner, job, timeout, 0)189+ if err != nil {190+ span.Fail(err)191+ } else {192+ span.End(slog.Int("rows", data.Rows))193+ }194+ return nuDoneMsg{job: j, data: data, err: err}195+ }196+}197+198+// finishRegion shows what a command made, or why it failed, and starts199+// the next region queued.200+func (m *Model) finishRegion(msg nuDoneMsg) tea.Cmd {201+ if m.shell.running != msg.job {202+ return nil // stopped, and another started since203+ }204+ m.shell.running = nil205+ name := msg.job.name206+ s, r, ok := m.book().Region(name)207+ switch {208+ case !ok:209+ case msg.err != nil:210+ m.failRegion(r.Name, runError(msg.err))211+ m.clearQueue()212+ return nil213+ default:214+ delete(m.shell.failed, strings.ToUpper(r.Name))215+ s.SetRegionStatus(r.Name, "")216+ if err := s.ShowRegion(r.Name, msg.data); err != nil {217+ m.failRegion(r.Name, err.Error())218+ break219+ }220+ m.shell.said = m.th.Hint.Render(r.Name + ": " + transfer.Rows(msg.data.Rows) + noteSuffix(msg.data.Note))221+ }222+ if len(m.shell.queue) == 0 && time.Since(msg.job.start) > 5*time.Second {223+ return tea.Batch(m.nextRegion(), m.term.notify("Shell commands finished in "+m.displayName()))224+ }225+ return m.nextRegion()226+}227+228+func noteSuffix(note string) string {229+ if note == "" {230+ return ""231+ }232+ return "; " + note233+}234+235+// runError is what a failed run says.236+func runError(err error) string {237+ var nuErr *nushell.Error238+ switch {239+ case errors.Is(err, context.Canceled):240+ return "stopped"241+ case errors.As(err, &nuErr):242+ return nuErr.Msg243+ }244+ return err.Error()245+}246+247+// failRegion marks a region failed, saying why on its label and the248+// context line.249+func (m *Model) failRegion(name, why string) {250+ if m.shell.failed == nil {251+ m.shell.failed = map[string]string{}252+ }253+ m.shell.failed[strings.ToUpper(name)] = why254+ m.setStatus(name, "failed")255+ if why == "stopped" {256+ m.setStatus(name, "stopped")257+ }258+ m.shell.said = m.th.Warning.Render(name + " failed: " + why)259+ if why == "stopped" {260+ m.shell.said = m.th.Hint.Render("Stopped " + name)261+ }262+ m.warn = m.shell.said263+}264+265+// clearQueue forgets the regions waiting to run.266+func (m *Model) clearQueue() {267+ for _, name := range m.shell.queue {268+ m.setStatus(name, "")269+ }270+ m.shell.queue = nil271+}272+273+// stopShell stops the command running and those waiting.274+func (m *Model) stopShell() {275+ if j := m.shell.running; j != nil {276+ j.cancel()277+ }278+ m.clearQueue()279+}280+281+// loadWords asks nu for its command names, once a session, for the282+// prompt's completions.283+func (m *Model) loadWords() tea.Cmd {284+ if m.shell.asked || m.shellOff() != "" {285+ return nil286+ }287+ m.shell.asked = true288+ runner := m.runner()289+ return func() tea.Msg {290+ words, _ := nushell.Commands(context.Background(), runner, 30*time.Second)291+ return nuWordsMsg{words}292+ }293+}294+295+// Config helpers that work without a config, as in tests.296+297+func (m *Model) configString(name, def string) string {298+ if c := m.prefs.Config; c != nil {299+ return c.String(name)300+ }301+ return def302+}303+304+func (m *Model) configBool(name string, def bool) bool {305+ if c := m.prefs.Config; c != nil {306+ return c.Bool(name)307+ }308+ return def309+}310+311+func (m *Model) configDuration(name string, def time.Duration) time.Duration {312+ if c := m.prefs.Config; c != nil {313+ return c.Duration(name)314+ }315+ return def316+}317+318+// startShell opens the prompt as the program starts, when 012 nu asked.319+func (m *Model) startShell() tea.Cmd {320+ if !m.shell.startup {321+ return nil322+ }323+ m.shell.startup = false324+ return m.openShell("")325+}
M internal/ui/overlay/overlay.go +10 -0
M internal/ui/panel.go +9 -0
M internal/ui/pivot.go +4 -0
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +7 -3
@@ -41,9 +41,12 @@ type Theme struct {
4141 // ErrorMark is layered on an error's text, so errors show beyond4242 // color: a curly underline, in the error color where the terminal4343 // supports colored underlines.44- ErrorMark lipgloss.Style45- Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role46- Spilled lipgloss.Style // values an array formula spilled into the cells below and right of it44+ ErrorMark lipgloss.Style45+ Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role46+ Spilled lipgloss.Style // values an array formula spilled into the cells below and right of it47+ // Region is a notebook region's label line, its name and command,48+ // dimmed and underlined as wide as its table: the top of its frame.49+ Region lipgloss.Style4750 Found lipgloss.Style // cells matching an open search4851 Traced lipgloss.Style // precedents or dependents being traced4952 Argument lipgloss.Style // the argument at the caret in a function's signature
@@ -257,6 +260,7 @@ func roles(dark bool) Theme {
257260 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),258261 Link: lipgloss.NewStyle().Foreground(link).Underline(true),259262 Spilled: lipgloss.NewStyle().Foreground(bar).Italic(true),263+ Region: lipgloss.NewStyle().Foreground(muted).Underline(true).UnderlineSpaces(true),260264 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),261265 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),262266 Argument: lipgloss.NewStyle().Bold(true).Underline(true),
M internal/ui/view.go +3 -0