012 - reads standard input in the background into an unsaved sheet named stdin. In a pipeline, the status line says what Ctrl+Q sends and as what, and Quit asks: send the selection or the sheet, or quit without sending; File and the palette offer Quit and send selection and Quit and send sheet only there.
- commit
- 0cb910c76f100bac97c7f2bce9c1de028cb9e91f
- parent
- d270e2425ec48e0befda0172c5f99e544e2ee2e3
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.6 of 3, Jev's estimate
| M | docs/ | 10 | +- |
| M | internal/ | 4 | + |
| M | internal/ | 6 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 17 | +++- |
| M | internal/ | 3 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 1 | + |
| A | internal/ | 146 | +++++++++++++++++++++++++++++ |
| A | internal/ | 152 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 4 | + |
| M | internal/ | 21 | ++++ |
@@ -9,7 +9,7 @@ sidebar_position: 2
99 Lip Gloss v2.1010 1111 ```12-cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file12+cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file, 012 - and --pipe on the terminal1313 internal/config the config file and the registry of options1414 internal/sheet the engine: cells, recalculation, undo, files, names, pivots1515 internal/functions the function library: the FuncDef table, evaluation, decimal arithmetic, JEV questions@@ -17,7 +17,8 @@ internal/value cell values, number formats, typed-entry parsing, the clock
1717 internal/formula the formula language: references, lexer, parser, printer, rewriting1818 internal/numfmt number formats, rounding, General, date serials1919 internal/locale the locales: separators, date order, currency, formula separators20-internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk120+internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1, JSON, NUON; tables on streams21+internal/nuon nushell's object notation: typed values, tables read a row at a time, written back2122 internal/chart chart layout, text rendering and kitty image encoding2223 internal/jev the API key's resolution, answer cache and TypeSafe client2324 internal/keyring the OS credential store the API key lives in@@ -374,7 +375,7 @@ draw. The components:
374375 | overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker (package `filterpick`, with `filter.go`), the sort bar (package `sortbar`, with `sort.go`), choice bars (package `choicebar`, with `dialog.go`), the chart editor and selection (`charteditor.go`, `chartsel.go`), the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts (package `shortcuts`, with `help.go`) |375376 | sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |376377 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |377-| import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |378+| import | `transfer.Transfer`, `pipeState` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`); standard input read as a sheet, and what a pipeline gets on quitting (`pipe.go`) |378379 | macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |379380 | others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal`, `session` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it, what outlasts the file open (the `:` history, whether keys can be held: `keyboard.go`) |380381 @@ -466,7 +467,8 @@ menu all come from the table. Importers build a new workbook through the
466467 engine's public API with a shared `builder`, which keeps text as text,467468 falls back to a formula's cached value when it can't be translated, and468469 counts what didn't fit. They stream: CSV and TSV are read record by469-record, Parquet a batch of rows at a time, SQLite a row at a time, and470+record, NUON and JSON a row at a time (`nuon.Reader`, which yields rows471+from a pipe as they arrive), Parquet a batch of rows at a time, SQLite a row at a time, and470472 Parquet files and SQLite tables stop at the sheet's last row, taking the471473 number of rows left out from the file. XLSX is read by 012's own472474 SpreadsheetML reader on `archive/zip` and `encoding/xml`: the workbook,@@ -24,6 +24,10 @@ type command struct {
2424 // enabled, when set, reports whether the command can run right now.2525 // Menus and the palette show unavailable commands dimmed.2626 enabled func(m *Model) bool27+ // hidden, when set, reports that the command doesn't apply to this28+ // session at all, as sending to a pipeline outside one: menus and29+ // the palette leave it out rather than dim it.30+ hidden func(m *Model) bool2731 2832 // checked, when set, makes the command a setting: menus show a ✓2933 // while it is on.@@ -32,8 +32,12 @@ func (m *Model) ask(q question) {
3232 }3333 3434 // quit exits, asking first when there are unsaved changes. Enter saves35-// and quits, so the quick path never loses work.35+// and quits, so the quick path never loses work. In a pipeline it asks36+// what to send instead (askSend).3637 func (m *Model) quit() tea.Cmd {38+ if m.pipe.on {39+ return m.askSend()40+ }3741 if !m.changed {3842 return m.exit()3943 }@@ -121,7 +121,7 @@ func (m *Model) openFile(text string) tea.Cmd {
121121 // session rather than the sheet carries over: the window, theme, terminal122122 // state, served directory and the JEV connection.123123 func (m *Model) reset(s *sheet.Sheet, filename string) {124- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session,124+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe,125125 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}126126 s.Book().SetTrace(m.spans)127127 if m.jev != nil {@@ -39,7 +39,7 @@ var menuBar = []menuDef{
3939 {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,4040 {cmd: "settings.config_edit"}, {cmd: "settings.config_reload"},4141 }}, sep,42- {cmd: "quit"},42+ {cmd: "pipe.send_selection"}, {cmd: "pipe.send_sheet"}, {cmd: "quit"},4343 }},4444 {title: "Edit", accel: 'e', items: []menuItem{4545 {cmd: "edit.undo"}, {cmd: "edit.redo"}, sep,@@ -50,7 +50,22 @@ func (m *Model) showBarMenu(i int) {
5050 return5151 }5252 i = (i%len(menus) + len(menus)) % len(menus)53- m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{newLevel(m, visibleItems(menus[i].def.items), true)}})53+ m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{newLevel(m, visibleItems(m.applicable(menus[i].def.items)), true)}})54+}55+56+// applicable drops the items whose commands are hidden in this session57+// (command.hidden), in submenus too.58+func (m *Model) applicable(items []menuItem) []menuItem {59+ out := make([]menuItem, 0, len(items))60+ for _, it := range items {61+ if it.items != nil {62+ it.items = m.applicable(it.items)63+ }64+ if c := commands[it.cmd]; c == nil || c.hidden == nil || !c.hidden(m) {65+ out = append(out, it)66+ }67+ }68+ return out5469 }5570 5671 // showContextMenu opens a menu of items with its corner at x, y.@@ -117,6 +117,7 @@ type Model struct {
117117 copied clipboard // what Ctrl+V pastes: clipboard.go118118 trace *trace // precedents or dependents being shown: trace.go119119 xfer transfer.Transfer // the import running and the file imported: package transfer120+ pipe pipeState // standard input and output, for 012 - and --pipe: pipe.go120121 jev *jevRunner // answers JEV functions; nil without an API key: jev.go121122 term terminal // what the terminal supports: graphics.go122123 prefs prefs // the settings in effect and the theme chosen: prefs.go@@ -151,7 +152,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
151152 // so the theme can adapt to light terminals.152153 func (m *Model) Init() tea.Cmd {153154 // jev.send starts any questions queued while loading the file.154- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startOpenCmd())155+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd())155156 }156157 157158 // Update implements tea.Model.@@ -29,11 +29,11 @@ func paletteItems(m *Model) []picker.Item {
2929 var items []picker.Item3030 seen := map[string]bool{"palette": true}3131 add := func(id, path string) {32- if seen[id] {32+ c := commands[id]33+ if seen[id] || c.hidden != nil && c.hidden(m) {3334 return3435 }3536 seen[id] = true36- c := commands[id]3737 items = append(items, picker.Item{3838 Title: c.title, Name: len(c.title), Detail: path, Key: m.shortcut(id), Desc: c.desc,3939 Off: !c.available(m), Pick: func() tea.Cmd { return m.runFromOverlay(id) },@@ -397,6 +397,7 @@ func (m *Model) statusState() string {
397397 if busy := m.jev.busy(); busy != "" {398398 b.WriteString(" " + m.th.Hint.Render(busy))399399 }400+ b.WriteString(m.pipeStatus())400401 return b.String()401402 }402403 @@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "io"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/transfer"12+)13+14+// 012 as a stage in a pipeline: 012 - reads a table from standard input15+// into a new, unsaved sheet named stdin, and 012 --pipe also writes a16+// table to standard output when it quits: the selection or the sheet,17+// as the user chooses, in the input's format unless another was asked18+// for. The screen is the terminal's own (cmd/012 opens it), so nothing19+// else reaches standard output. Quitting asks what to send, and20+// quitting without sending lets the pipeline know by the exit status.21+22+// StdinName is what a table read from standard input is called: its23+// sheet's name.24+const StdinName = "stdin"25+26+// pipeState is the pipeline 012 is a stage of, if any.27+type pipeState struct {28+ in io.Reader // standard input, read as the program starts; nil once read29+30+ on bool // --pipe: quitting writes a table to standard output31+ to fileio.Kind // the format asked for (--to), or 0 for the input's32+33+ // Set by a quit that sends: what to write, and in which format.34+ sent *fileio.Snapshot35+ kind fileio.Kind36+}37+38+// ReadStdin sets a table to read from r, standard input, as the program39+// starts, into a new unsaved sheet named stdin.40+func (m *Model) ReadStdin(r io.Reader) { m.pipe.in = r }41+42+// SetPipe makes quitting send a table to standard output, in format to43+// (a text format; 0 for the input's, or NUON when the input isn't text).44+func (m *Model) SetPipe(to fileio.Kind) { m.pipe.on, m.pipe.to = true, to }45+46+// Piped is what quitting sent: the table and its format, or false when47+// the user quit without sending.48+func (m *Model) Piped() (*fileio.Snapshot, fileio.Kind, bool) {49+ return m.pipe.sent, m.pipe.kind, m.pipe.sent != nil50+}51+52+func init() {53+ register(54+ &command{id: "pipe.send_selection", macro: macroNever, title: "Quit and send selection",55+ desc: "Quit, writing the selection to standard output for the next command in the pipeline",56+ enabled: func(m *Model) bool { return m.pipe.on && m.hasRange() },57+ hidden: func(m *Model) bool { return !m.pipe.on },58+ run: func(m *Model) tea.Cmd { return m.send(m.selection()) }},59+ &command{id: "pipe.send_sheet", macro: macroNever, title: "Quit and send sheet",60+ desc: "Quit, writing the sheet's data to standard output for the next command in the pipeline",61+ enabled: func(m *Model) bool { return m.pipe.on },62+ hidden: func(m *Model) bool { return !m.pipe.on },63+ run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }},64+ )65+}66+67+// startStdin starts reading standard input, if it's to be read.68+func (m *Model) startStdin() tea.Cmd {69+ r := m.pipe.in70+ if r == nil {71+ return nil72+ }73+ m.pipe.in = nil74+ return m.xfer.StartReader(StdinName, r, fileio.Options{Locale: m.locale()}, m.spans.Parent())75+}76+77+// streamed opens a table read from standard input: a new spreadsheet78+// with no file, unsaved.79+func (m *Model) streamed(msg transfer.ImportedMsg) {80+ m.reset(msg.Res.Sheet, "")81+ m.xfer.Kind = msg.Res.Kind82+ m.changed, m.saved = true, -183+ m.note = "Read " + transfer.Rows(msg.Res.Rows) + " of " + msg.Res.Kind.String() + " from standard input"84+ if len(msg.Res.Notes) > 0 {85+ m.note += "; " + strings.Join(msg.Res.Notes, "; ")86+ }87+}88+89+// pipeKind is the format sent: the one asked for, or the input's when90+// it's text, or NUON.91+func (m *Model) pipeKind() fileio.Kind {92+ switch {93+ case m.pipe.to != 0:94+ return m.pipe.to95+ case m.xfer.Kind.IsText():96+ return m.xfer.Kind97+ }98+ return fileio.NUON99+}100+101+// sendWhat names what quitting would send: "B2:D9" or "the sheet".102+func (m *Model) sendWhat() string {103+ if m.hasRange() {104+ return m.selection().String()105+ }106+ return "the sheet"107+}108+109+// askSend is Quit in a pipeline: send the selection, if there is one,110+// or the sheet, or quit without sending.111+func (m *Model) askSend() tea.Cmd {112+ var choices []choice113+ if m.hasRange() {114+ choices = append(choices,115+ choice{key: "enter", label: "Send selection", run: func(m *Model) tea.Cmd { return m.send(m.selection()) }},116+ choice{key: "s", label: "Send sheet", run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }})117+ } else {118+ choices = append(choices, choice{key: "enter", label: "Send sheet", run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }})119+ }120+ choices = append(choices,121+ choice{key: "d", label: "Don't send", run: (*Model).exit},122+ choice{key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }})123+ m.ask(question{124+ msg: "Send " + m.sendWhat() + " to the pipeline as " + m.pipeKind().String() + "?",125+ desc: "Quitting without sending exits with status 1, so the pipeline stops",126+ choices: choices,127+ })128+ return nil129+}130+131+// send quits, keeping r (the sheet's data when zero) to write to132+// standard output once the terminal is given back.133+func (m *Model) send(r sheet.Rect) tea.Cmd {134+ m.pipe.sent = fileio.Snap(m.sheet, r, m.sheet.Name())135+ m.pipe.kind = m.pipeKind()136+ return m.exit()137+}138+139+// pipeStatus is what the status line says in a pipeline: the key that140+// sends, what and as what.141+func (m *Model) pipeStatus() string {142+ if !m.pipe.on {143+ return ""144+ }145+ return " " + m.th.KeyHints(m.shortcut("quit"), "send "+m.sendWhat()+" as "+m.pipeKind().String())146+}@@ -0,0 +1,152 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+const filesNUON = "[[name, size]; [a, 1646b], [b, 2kb]]"15+16+// stdinModel is a model that has read text from standard input, in a17+// pipeline (to, or the input's format when 0) when pipe is set.18+func stdinModel(t *testing.T, text string, pipe bool, to fileio.Kind) *Model {19+ t.Helper()20+ m := newModel()21+ if pipe {22+ m.SetPipe(to)23+ }24+ m.ReadStdin(strings.NewReader(text))25+ run(m, m.startStdin())26+ if m.xfer.Busy() {27+ t.Fatal("still reading")28+ }29+ return m30+}31+32+func TestStdinOpensAnUnsavedSheet(t *testing.T) {33+ m := stdinModel(t, filesNUON, false, 0)34+ if m.sheet.Name() != StdinName || !m.changed || m.filename != "" || m.xfer.Source != "" {35+ t.Errorf("sheet %q, changed %v, file %q, source %q", m.sheet.Name(), m.changed, m.filename, m.xfer.Source)36+ }37+ if got := line(m, contextLine); !strings.Contains(got, "Read 3 rows of NUON from standard input") {38+ t.Errorf("context line %q", got)39+ }40+ if got := sheet.FormatText(m.sheet.Value(addr("B2")), m.sheet.DisplayFormat(addr("B2"))); got != "1.6 kB" {41+ t.Errorf("B2 shows %q", got)42+ }43+ press(t, m, "<ctrl+q>")44+ if got := line(m, contextLine); !strings.Contains(got, "You have unsaved changes.") {45+ t.Errorf("quitting outside a pipeline: %q", got)46+ }47+ if st := line(m, m.height-1); strings.Contains(st, "send") {48+ t.Errorf("status line outside a pipeline: %q", st)49+ }50+}51+52+func TestPipeSendsTheSheet(t *testing.T) {53+ m := stdinModel(t, filesNUON, true, 0)54+ if st := line(m, m.height-1); !strings.Contains(st, "Ctrl+Q send the sheet as NUON") {55+ t.Errorf("status line %q", st)56+ }57+ press(t, m, "<ctrl+q>")58+ if got := line(m, contextLine); !strings.Contains(got, "Send the sheet to the pipeline as NUON?") ||59+ !strings.Contains(got, "Enter Send sheet") || !strings.Contains(got, "D Don't send") || strings.Contains(got, "Send selection") {60+ t.Errorf("question %q", got)61+ }62+ if _, quit := press(t, m, "<enter>").(tea.QuitMsg); !quit {63+ t.Fatal("didn't quit")64+ }65+ snap, k, ok := m.Piped()66+ if !ok || k != fileio.NUON || snap.Range.String() != "A1:B3" {67+ t.Errorf("sent %v %v %v", snap, k, ok)68+ }69+}70+71+func TestPipeSendsTheSelection(t *testing.T) {72+ m := stdinModel(t, filesNUON, true, fileio.CSV)73+ press(t, m, "<shift+down>")74+ if st := line(m, m.height-1); !strings.Contains(st, "send A1:A2 as CSV") {75+ t.Errorf("status line %q", st)76+ }77+ press(t, m, "<ctrl+q>")78+ if got := line(m, contextLine); !strings.Contains(got, "Send A1:A2 to the pipeline as CSV?") || !strings.Contains(got, "S Send sheet") {79+ t.Errorf("question %q", got)80+ }81+ press(t, m, "<enter>")82+ if snap, k, ok := m.Piped(); !ok || k != fileio.CSV || snap.Range.String() != "A1:A2" {83+ t.Errorf("sent %v %v %v", snap.Range, k, ok)84+ }85+86+ m = stdinModel(t, "a\tb\n1\t2\n", true, 0)87+ press(t, m, "<shift+down>", "<ctrl+q>", "s")88+ if snap, k, ok := m.Piped(); !ok || k != fileio.TSV || snap.Range.String() != "A1:B2" {89+ t.Errorf("sent the sheet? %v %v %v", snap.Range, k, ok)90+ }91+}92+93+func TestPipeQuitWithoutSending(t *testing.T) {94+ m := stdinModel(t, filesNUON, true, 0)95+ press(t, m, "<ctrl+q>", "<esc>")96+ if m.mode != modeReady {97+ t.Errorf("Esc left mode %v", m.mode)98+ }99+ if _, quit := press(t, m, "<ctrl+q>", "d").(tea.QuitMsg); !quit {100+ t.Fatal("didn't quit")101+ }102+ if _, _, ok := m.Piped(); ok {103+ t.Error("sent anyway")104+ }105+}106+107+// TestPipeCommands: the send commands are in File and the palette only108+// in a pipeline, and run from there.109+func TestPipeCommands(t *testing.T) {110+ titles := func(m *Model) []string {111+ var out []string112+ for _, it := range paletteItems(m) {113+ out = append(out, it.Title)114+ }115+ return out116+ }117+ m := newModel()118+ if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-2 {119+ t.Error("send commands outside a pipeline")120+ }121+ m = stdinModel(t, filesNUON, true, 0)122+ if !slices.Contains(titles(m), "Quit and send sheet") || !slices.Contains(titles(m), "Quit and send selection") {123+ t.Error("send commands missing in a pipeline")124+ }125+ if commands["pipe.send_selection"].available(m) {126+ t.Error("send selection without a selection")127+ }128+ if _, quit := run(m, m.runCommand("pipe.send_sheet")).(tea.QuitMsg); !quit {129+ t.Fatal("didn't quit")130+ }131+ if _, _, ok := m.Piped(); !ok {132+ t.Error("nothing sent")133+ }134+}135+136+// TestPipeSendsAFileArgument: 012 --pipe data.nuon sends in the file's137+// format, and a .012 file as NUON.138+func TestPipeFormatOfAFile(t *testing.T) {139+ m := newModel()140+ m.SetPipe(0)141+ if m.pipeKind() != fileio.NUON {142+ t.Errorf("a .012 sheet sends %v", m.pipeKind())143+ }144+ m.xfer.Kind = fileio.JSON145+ if m.pipeKind() != fileio.JSON {146+ t.Errorf("a JSON file sends %v", m.pipeKind())147+ }148+ m.xfer.Kind = fileio.XLSX149+ if m.pipeKind() != fileio.NUON {150+ t.Errorf("an Excel file sends %v", m.pipeKind())151+ }152+}@@ -237,6 +237,10 @@ func (m *Model) handleImported(msg transfer.ImportedMsg) tea.Cmd {
237237 m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.Name), msg.Err))238238 return nil239239 }240+ if msg.Stream {241+ m.streamed(msg)242+ return m.term.notify("Read standard input")243+ }240244 what := filepath.Base(msg.Name)241245 switch {242246 case msg.Opt.Table != "":@@ -9,6 +9,7 @@ package transfer
99 import (1010 "context"1111 "fmt"12+ "io"1213 "path/filepath"1314 "strings"1415 "time"@@ -56,6 +57,9 @@ type ImportedMsg struct {
5657 Place Place5758 Res *fileio.Result5859 Err error60+ // Stream is set for a table read from a stream (StartReader), which61+ // has no file to save back to.62+ Stream bool5963 }6064 6165 // TickMsg refreshes the progress display.@@ -83,6 +87,23 @@ func (x *Transfer) Start(name, path string, opt fileio.Options, place Place, par
8387 )8488 }8589 90+// StartReader reads a table from r in the background, as Start reads a91+// file, into a sheet called name: standard input, for 012 -. Its result92+// is marked Stream.93+func (x *Transfer) StartReader(name string, r io.Reader, opt fileio.Options, parent telemetry.Parent) tea.Cmd {94+ ctx, cancel := context.WithCancel(telemetry.WithParent(context.Background(), parent))95+ prog := fileio.NewProgress()96+ id := x.Begin(name, prog, cancel, Book)97+ opt.Progress = prog98+ return tea.Batch(99+ func() tea.Msg {100+ res, err := fileio.ImportReader(ctx, name, r, opt)101+ return ImportedMsg{ID: id, Name: name, Opt: opt, Place: Book, Res: res, Err: err, Stream: true}102+ },103+ tickCmd(id),104+ )105+}106+86107 // Begin tracks an import of name reporting to prog, cancelled by cancel,87108 // replacing any import running, and returns its ID for its ImportedMsg.88109 func (x *Transfer) Begin(name string, prog *fileio.Progress, cancel func(), place Place) int {