cat / 012
clone

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

Move the import runner into internal/ui/transfer

Dami committed f28e91a
Running an import in the background, dropping stale results and
drawing its progress need no host: the model starts it, hands it its
messages and gets the result to place. Where an import goes (Place)
moves with it; choosing and placing it stays in package ui.
commit
f28e91aa083c0bd9df36cfcb056671bee3b9227a
parent
31fec52c859b63a02001e3d08c49025b9814d412
author
Dami <dami.etoile@gmail.com>
11 files changed, +369 -223
Minternal/ui/download.go6+-
Minternal/ui/file.go2+-
Minternal/ui/importplace.go38++---
Minternal/ui/importplace_test.go4+-
Minternal/ui/model.go33+++--
Minternal/ui/panel.go16+-
Minternal/ui/serve_test.go7+-
Minternal/ui/transfer.go195++++++------------------------
Ainternal/ui/transfer/transfer.go181++++++++++++++++++++++++++++
Ainternal/ui/transfer/transfer_test.go89++++++++++++++
Minternal/ui/transfer_test.go21++--
M internal/ui/download.go +3 -3
M internal/ui/file.go +1 -1
M internal/ui/importplace.go +16 -22
@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/012/internal/fileio"1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/picker"12+ "github.com/FelineStateMachine/012/internal/ui/transfer"1213 )1314 1415 // Where File > Import puts what it reads, as Sheets' Import location:
@@ -17,19 +18,12 @@ import (
1718 // do). A new, empty spreadsheet is just replaced. Inserting or replacing1819 // a sheet is one undo step, and the file stays the one being edited.1920 20-// importPlace is where an import goes.21-type importPlace int22-23-const (24- placeBook importPlace = iota // replace the spreadsheet25- placeNewSheets // insert new sheets26- placeSheet // replace the sheet shown27-)21+// The places are transfer.Book, transfer.NewSheets and transfer.Sheet.2822 2923 // pristine reports whether the spreadsheet is new and untouched, so an3024 // import may simply replace it.3125 func (m *Model) pristine() bool {32- return !m.changed && m.filename == "" && m.xfer.source == "" && m.book().Len() == 1 &&26+ return !m.changed && m.filename == "" && m.xfer.Source == "" && m.book().Len() == 1 &&3327 m.sheet.Len() == 0 && len(m.sheet.Charts()) == 03428 }3529
@@ -37,7 +31,7 @@ func (m *Model) pristine() bool {
3731 // spreadsheet is new and empty.3832 func (m *Model) askImportPlace(name string) tea.Cmd {3933 if m.pristine() {40- return m.startImport(name, fileio.Options{}, placeBook)34+ return m.startImport(name, fileio.Options{}, transfer.Book)4135 }4236 k, _ := fileio.KindOf(name)4337 base := filepath.Base(name)
@@ -52,10 +46,10 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
5246 }}5347 }5448 items := []picker.Item{item(insert, "after the others", "Add "+base+as,55- func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeNewSheets) })}49+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, transfer.NewSheets) })}5650 if !k.HoldsSheets() {5751 items = append(items, item("Replace current sheet", m.sheet.Name(), "Put "+base+" in place of "+m.sheet.Name()+", keeping its name",58- func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeSheet) }))52+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, transfer.Sheet) }))5953 }6054 detail := "open it instead"6155 if m.changed {
@@ -71,11 +65,11 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
7165 7266 // placeImport puts an imported workbook where the import was asked to go7367 // and says what it did, or reports false to replace the spreadsheet.74-func (m *Model) placeImport(msg importedMsg, what string) bool {75- res := msg.res76- label := "import " + filepath.Base(msg.name)77- switch msg.place {78- case placeNewSheets:68+func (m *Model) placeImport(msg transfer.ImportedMsg, what string) bool {69+ res := msg.Res70+ label := "import " + filepath.Base(msg.Name)71+ switch msg.Place {72+ case transfer.NewSheets:7973 ins, err := m.book().InsertBook(res.Sheet.Book(), label)8074 if err != nil {8175 m.fail(err.Error())
@@ -86,18 +80,18 @@ func (m *Model) placeImport(msg importedMsg, what string) bool {
8680 show = ins.Sheets[0]8781 }8882 m.afterSheetsChange(show, m.book().Index(m.sheet))89- m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + countRows(res.Rows) + ")"83+ m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + transfer.Rows(res.Rows) + ")"9084 if ins.Names > 0 {91- m.note += "; " + plural(ins.Names, "1 named range", thousands(ins.Names)+" named ranges") + " left out, their names taken"85+ m.note += "; " + plural(ins.Names, "1 named range", transfer.Thousands(ins.Names)+" named ranges") + " left out, their names taken"9286 }93- case placeSheet:87+ case transfer.Sheet:9488 i := m.book().Index(m.sheet)9589 if err := m.book().ReplaceSheet(m.sheet, res.Sheet, label); err != nil {9690 m.fail(err.Error())9791 return true9892 }9993 m.afterSheetsChange(res.Sheet, i)100- m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + countRows(res.Rows) + ")"94+ m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + transfer.Rows(res.Rows) + ")"10195 default:10296 return false10397 }
@@ -117,6 +111,6 @@ func sheetList(sheets []*sheet.Sheet) string {
117111 case 2:118112 return sheets[0].Name() + " and " + sheets[1].Name()119113 default:120- return thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()114+ return transfer.Thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()121115 }122116 }
M internal/ui/importplace_test.go +2 -2
M internal/ui/model.go +17 -16
@@ -17,6 +17,7 @@ import (
1717 "github.com/FelineStateMachine/012/internal/ui/overlay"1818 "github.com/FelineStateMachine/012/internal/ui/tabstrip"1919 "github.com/FelineStateMachine/012/internal/ui/theme"20+ "github.com/FelineStateMachine/012/internal/ui/transfer"2021 )2122 2223 // mode is the state shown in the mode indicator.
@@ -87,21 +88,21 @@ type Model struct {
8788 8889 // Components. Each owns its state and the handling of the input it8990 // takes; Model routes messages to them and composes what they draw.90- line lineedit.Line // the edit line of entries, prompts and search fields: line.go91- entry entry // typing into a cell: entry.go92- point pointer // the cell or range pointed at in POINT mode and range prompts93- prompt *prompt // a question on the context line: prompt.go94- overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go95- mouse mouseState // drags, hover and double clicks: mouse.go96- tabs tabstrip.Strip // the sheet tabs and where each sheet was left: tabstrip.go97- find *findbar.Bar // the last search, reopened by Ctrl+F: find.go98- charts chartState // chart commands' target: charts.go99- copied clipboard // what Ctrl+V pastes: clipboard.go100- trace *trace // precedents or dependents being shown: trace.go101- xfer transfer // imports and downloads: transfer.go102- jev *jevRunner // answers JEV functions; nil without an API key: jev.go103- term terminal // what the terminal supports: graphics.go104- prefs prefs // the settings in effect and the theme chosen: prefs.go91+ line lineedit.Line // the edit line of entries, prompts and search fields: line.go92+ entry entry // typing into a cell: entry.go93+ point pointer // the cell or range pointed at in POINT mode and range prompts94+ prompt *prompt // a question on the context line: prompt.go95+ overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go96+ mouse mouseState // drags, hover and double clicks: mouse.go97+ tabs tabstrip.Strip // the sheet tabs and where each sheet was left: tabstrip.go98+ find *findbar.Bar // the last search, reopened by Ctrl+F: find.go99+ charts chartState // chart commands' target: charts.go100+ copied clipboard // what Ctrl+V pastes: clipboard.go101+ trace *trace // precedents or dependents being shown: trace.go102+ xfer transfer.Transfer // imports and downloads: transfer.go103+ jev *jevRunner // answers JEV functions; nil without an API key: jev.go104+ term terminal // what the terminal supports: graphics.go105+ prefs prefs // the settings in effect and the theme chosen: prefs.go105106 106107 vim vimState // a vim key sequence in progress: vim.go107108 rec *recorder // a macro being recorded: macrorec.go
@@ -184,7 +185,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184185 cmd = m.answerJEV(msg)185186 case jevRecalcMsg:186187 cmd = m.recalcAnswered()187- case importedMsg, importTickMsg, exportedMsg:188+ case transfer.ImportedMsg, transfer.TickMsg, exportedMsg:188189 cmd = m.handleTransfer(msg)189190 case macroCallMsg:190191 cmd = m.serveMacro(msg)
M internal/ui/panel.go +8 -8
@@ -57,7 +57,7 @@ func (m *Model) View() tea.View {
5757 // Terminals that support it (OSC 9;4) show activity in the tab.5858 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)5959 }60- if bar := m.xfer.progressBar(); bar != nil {60+ if bar := m.xfer.ProgressBar(); bar != nil {6161 v.ProgressBar = bar6262 }6363 if m.changed {
@@ -121,8 +121,8 @@ func (m *Model) timeFrame(start time.Time) {
121121 }122122 123123 func (m *Model) displayName() string {124- if m.filename == "" && m.xfer.source != "" {125- return filepath.Base(m.xfer.source)124+ if m.filename == "" && m.xfer.Source != "" {125+ return filepath.Base(m.xfer.Source)126126 }127127 if m.filename == "" {128128 return "untitled"
@@ -133,7 +133,7 @@ func (m *Model) displayName() string {
133133 // indicator is the mode shown at the top right.134134 func (m *Model) indicator() string {135135 switch {136- case m.xfer.job != nil:136+ case m.xfer.Busy():137137 return "WAIT"138138 case m.macros.run != nil:139139 return "CMD" // 1-2-3's indicator while a macro runs
@@ -196,8 +196,8 @@ func (m *Model) formulaBar() string {
196196 func (m *Model) contextLineText() string {197197 var left, right string198198 switch {199- case m.xfer.job != nil:200- left = m.xfer.line(&m.th)199+ case m.xfer.Busy():200+ left = m.xfer.Line(&m.th)201201 case m.mouse.drag == dragResize:202202 left = m.th.Key.Render("Column "+sheet.ColName(m.mouse.resizeCol)) + m.th.Muted.Render(" width ") +203203 strconv.Itoa(m.sheet.ColWidth(m.mouse.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")
@@ -286,8 +286,8 @@ func (m *Model) statusLayout() (string, []tabstrip.Span) {
286286 if m.mode == modeError {287287 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil288288 }289- if m.xfer.job != nil {290- return m.spread(m.xfer.status(&m.th, m.width)), nil289+ if m.xfer.Busy() {290+ return m.spread(m.xfer.Status(&m.th, m.width)), nil291291 }292292 if line, ok := m.floatingStatus(); ok {293293 return line, nil
M internal/ui/serve_test.go +4 -3
M internal/ui/transfer.go +37 -158
@@ -8,15 +8,13 @@ import (
88 "path/filepath"99 "slices"1010 "strings"11- "time"1211 1312 tea "charm.land/bubbletea/v2"14- "github.com/charmbracelet/x/ansi"1513 1614 "github.com/FelineStateMachine/012/internal/confine"1715 "github.com/FelineStateMachine/012/internal/fileio"1816 "github.com/FelineStateMachine/012/internal/ui/picker"19- "github.com/FelineStateMachine/012/internal/ui/theme"17+ "github.com/FelineStateMachine/012/internal/ui/transfer"2018 )2119 2220 // Data comes in through File > Import (a picker of the files around),
@@ -24,39 +22,8 @@ import (
2422 // Imports run in the background: the mode indicator says WAIT, the2523 // status line counts rows with a progress bar, and Esc cancels. An2624 // imported sheet remembers its source, so Save offers to save it as a27-// .012 file or export it back.28-29-// transfer is the model's import and export state.30-type transfer struct {31- job *importJob32- lastID int33- source string // the file the sheet was imported from, if any34- kind fileio.Kind // its format35- startup string // a file to import as the program starts36-}37-38-// importJob is an import in progress.39-type importJob struct {40- id int41- name string42- prog *fileio.Progress43- cancel context.CancelFunc44- place importPlace // where the result goes; see importplace.go45-}46-47-type importedMsg struct {48- id int49- name string50- opt fileio.Options51- place importPlace52- res *fileio.Result53- err error54-}55-56-type importTickMsg struct{ id int }57-58-// importTick is how often the progress display refreshes.59-const importTick = 100 * time.Millisecond25+// .012 file or export it back. The import running and its progress are26+// package transfer's; here is what the model does with them.6027 6128 func init() {6229 register(&command{id: "file.import", macro: macroNever, title: "Import",
@@ -76,17 +43,17 @@ func importNouns() string {
7643 // Import sets a file to import when the program starts, e.g. from7744 // "012 sales.csv".7845 func (m *Model) Import(name string) {79- m.xfer.startup = name46+ m.xfer.Startup = name8047 }8148 8249 // startupCmd starts the import set with Import, if any.8350 func (m *Model) startupCmd() tea.Cmd {84- name := m.xfer.startup51+ name := m.xfer.Startup8552 if name == "" {8653 return nil8754 }88- m.xfer.startup = ""89- return m.startImport(name, fileio.Options{}, placeBook)55+ m.xfer.Startup = ""56+ return m.startImport(name, fileio.Options{}, transfer.Book)9057 }9158 9259 // importable lists the files in the directory names are relative to
@@ -180,14 +147,14 @@ func fileSize(n int64) string {
180147 // unsaved changes.181148 func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {182149 if !m.changed {183- return m.startImport(name, opt, placeBook)150+ return m.startImport(name, opt, transfer.Book)184151 }185152 m.openOverlay(&choiceBar{186153 m: m,187154 msg: "Importing replaces this spreadsheet, which has unsaved changes.",188155 warn: true,189156 choices: []choice{190- {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt, placeBook) }},157+ {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt, transfer.Book) }},191158 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},192159 },193160 })
@@ -195,43 +162,19 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
195162 }196163 197164 // startImport reads name in the background.198-func (m *Model) startImport(name string, opt fileio.Options, place importPlace) tea.Cmd {165+func (m *Model) startImport(name string, opt fileio.Options, place transfer.Place) tea.Cmd {199166 m.note = ""200167 path, ok := m.path("import", name)201168 if !ok {202169 return nil203170 }204- return m.xfer.start(name, path, opt, place)205-}206-207-// start reads name, at path on disk, in the background, replacing any208-// import running.209-func (x *transfer) start(name, path string, opt fileio.Options, place importPlace) tea.Cmd {210- if x.job != nil {211- x.job.cancel()212- }213- ctx, cancel := context.WithCancel(context.Background())214- x.lastID++215- job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel, place: place}216- opt.Progress = job.prog217- x.job = job218- return tea.Batch(219- func() tea.Msg {220- res, err := fileio.Import(ctx, path, opt)221- return importedMsg{id: job.id, name: name, opt: opt, place: place, res: res, err: err}222- },223- tickImport(job.id),224- )225-}226-227-func tickImport(id int) tea.Cmd {228- return tea.Tick(importTick, func(time.Time) tea.Msg { return importTickMsg{id} })171+ return m.xfer.Start(name, path, opt, place)229172 }230173 231174 // importing handles input while an import runs: Esc cancels it, and232175 // everything else waits.233176 func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {234- if m.xfer.job == nil {177+ if !m.xfer.Busy() {235178 return nil, false236179 }237180 switch msg := msg.(type) {
@@ -254,37 +197,24 @@ func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {
254197 // cancelImport stops the import at once; its result, if it still255198 // arrives, is ignored.256199 func (m *Model) cancelImport() {257- m.note = "Import of " + filepath.Base(m.xfer.cancel()) + " cancelled"258-}259-260-// cancel stops the import and returns the name of its file.261-func (x *transfer) cancel() string {262- job := x.job263- job.cancel()264- x.job = nil265- return job.name200+ m.note = "Import of " + filepath.Base(m.xfer.Cancel()) + " cancelled"266201 }267202 268203 // handleTransfer handles import and export messages.269204 func (m *Model) handleTransfer(msg tea.Msg) tea.Cmd {270205 switch msg := msg.(type) {271- case importTickMsg:272- if m.xfer.job != nil && m.xfer.job.id == msg.id {273- return tickImport(msg.id)274- }275- case importedMsg:276- if m.xfer.job == nil || m.xfer.job.id != msg.id {277- return nil206+ case transfer.TickMsg, transfer.ImportedMsg:207+ res, done, cmd := m.xfer.Update(msg)208+ if done {209+ return m.handleImported(res)278210 }279- m.xfer.job.cancel()280- m.xfer.job = nil281- return m.handleImported(msg)211+ return cmd282212 case exportedMsg:283213 if msg.err != nil {284214 m.fail(fmt.Sprintf("Couldn't download %s: %v", msg.name, msg.err))285215 return nil286216 }287- m.note = "Downloaded " + filepath.Base(msg.name) + " (" + countRows(msg.res.Rows) + ")"217+ m.note = "Downloaded " + filepath.Base(msg.name) + " (" + transfer.Rows(msg.res.Rows) + ")"288218 if len(msg.res.Notes) > 0 {289219 m.note += "; " + strings.Join(msg.res.Notes, "; ")290220 }
@@ -292,49 +222,34 @@ func (m *Model) handleTransfer(msg tea.Msg) tea.Cmd {
292222 return nil293223 }294224 295-func countRows(n int) string {296- if n == 1 {297- return "1 row"298- }299- return thousands(n) + " rows"300-}301-302-func thousands(n int) string {303- s := fmt.Sprint(n)304- for i := len(s) - 3; i > 0; i -= 3 {305- s = s[:i] + "," + s[i:]306- }307- return s308-}309-310-func (m *Model) handleImported(msg importedMsg) tea.Cmd {225+func (m *Model) handleImported(msg transfer.ImportedMsg) tea.Cmd {311226 var need *fileio.ErrNeedTable312227 switch {313- case errors.Is(msg.err, context.Canceled):314- m.note = "Import of " + filepath.Base(msg.name) + " cancelled"228+ case errors.Is(msg.Err, context.Canceled):229+ m.note = "Import of " + filepath.Base(msg.Name) + " cancelled"315230 return nil316- case errors.As(msg.err, &need):317- m.openTablePicker(msg.name, need.Tables, msg.place)231+ case errors.As(msg.Err, &need):232+ m.openTablePicker(msg.Name, need.Tables, msg.Place)318233 return nil319- case msg.err != nil:320- m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.name), msg.err))234+ case msg.Err != nil:235+ m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.Name), msg.Err))321236 return nil322237 }323- what := filepath.Base(msg.name)238+ what := filepath.Base(msg.Name)324239 switch {325- case msg.opt.Table != "":326- what = msg.opt.Table + " from " + what327- case msg.opt.Query != "":240+ case msg.Opt.Table != "":241+ what = msg.Opt.Table + " from " + what242+ case msg.Opt.Query != "":328243 what = "the query from " + what329244 }330245 if m.placeImport(msg, what) {331246 return m.term.notify("Imported " + what)332247 }333- m.reset(msg.res.Sheet, "")334- m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind335- m.note = "Imported " + what + " (" + countRows(msg.res.Rows) + ")"336- if len(msg.res.Notes) > 0 {337- m.note += "; " + strings.Join(msg.res.Notes, "; ")248+ m.reset(msg.Res.Sheet, "")249+ m.xfer.Source, m.xfer.Kind = msg.Name, msg.Res.Kind250+ m.note = "Imported " + what + " (" + transfer.Rows(msg.Res.Rows) + ")"251+ if len(msg.Res.Notes) > 0 {252+ m.note += "; " + strings.Join(msg.Res.Notes, "; ")338253 }339254 // A long import may finish while the terminal is in the background.340255 return m.term.notify("Imported " + what)
@@ -342,10 +257,10 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
342257 343258 // openTablePicker asks which table of a SQLite database to import, or344259 // for a query.345-func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place importPlace) {260+func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place transfer.Place) {346261 var items []picker.Item347262 for _, t := range tables {348- detail := countRows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))263+ detail := transfer.Rows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))349264 if t.View {350265 detail = "view, " + detail351266 }
@@ -385,39 +300,3 @@ func quoteSQL(s string) string {
385300 }386301 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`387302 }388-389-// line is the context line during an import.390-func (x *transfer) line(th *theme.Theme) string {391- return "Importing " + filepath.Base(x.job.name) + "… " + th.KeyHints("Esc", "cancel")392-}393-394-// status is the status line during an import, width wide: the file, the395-// rows read, and a bar when the total is known.396-func (x *transfer) status(th *theme.Theme, width int) (left, right string) {397- rows, frac := x.job.prog.Get()398- left = th.Key.Render("Importing " + filepath.Base(x.job.name))399- right = countRows(rows) + " read"400- if frac < 0 {401- return left, right402- }403- pct := fmt.Sprintf(" %3d%%", int(frac*100))404- w := clamp(width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)405- if w < 8 {406- return left, right + pct407- }408- done := int(frac * float64(w))409- return left, right + " " + th.Progress.Render(strings.Repeat("━", done)) +410- th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct411-}412-413-// progressBar is the terminal's own progress indicator (OSC 9;4), shown414-// in the tab or title bar where supported.415-func (x *transfer) progressBar() *tea.ProgressBar {416- if x.job == nil {417- return nil418- }419- if _, frac := x.job.prog.Get(); frac >= 0 {420- return tea.NewProgressBar(tea.ProgressBarDefault, int(frac*100))421- }422- return tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)423-}
A internal/ui/transfer/transfer.go +181 -0
@@ -0,0 +1,181 @@
1+// Package transfer runs imports in the background and draws their2+// progress: the mode indicator says WAIT, the context line offers Esc,3+// the status line counts rows with a progress bar, and the terminal4+// shows its own progress indicator. It remembers where the sheet came5+// from, so Save can offer to export it back. What an import's result6+// does to the workbook is the UI's; this package needs no host.7+package transfer8+9+import (10+ "context"11+ "fmt"12+ "path/filepath"13+ "strings"14+ "time"15+16+ tea "charm.land/bubbletea/v2"17+ "github.com/charmbracelet/x/ansi"18+19+ "github.com/FelineStateMachine/012/internal/fileio"20+ "github.com/FelineStateMachine/012/internal/ui/theme"21+)22+23+// Place is where an import goes.24+type Place int25+26+const (27+ Book Place = iota // replace the spreadsheet28+ NewSheets // insert new sheets29+ Sheet // replace the sheet shown30+)31+32+// Transfer is the model's import state.33+type Transfer struct {34+ job *job35+ lastID int36+ Source string // the file the sheet was imported from, if any37+ Kind fileio.Kind // its format38+ Startup string // a file to import as the program starts39+}40+41+// job is an import in progress.42+type job struct {43+ id int44+ name string45+ prog *fileio.Progress46+ cancel func()47+ place Place48+}49+50+// ImportedMsg is an import's result.51+type ImportedMsg struct {52+ ID int53+ Name string54+ Opt fileio.Options55+ Place Place56+ Res *fileio.Result57+ Err error58+}59+60+// TickMsg refreshes the progress display.61+type TickMsg struct{ id int }62+63+// tick is how often the progress display refreshes.64+const tick = 100 * time.Millisecond65+66+// Busy reports whether an import is running.67+func (x *Transfer) Busy() bool { return x.job != nil }68+69+// Start reads name, at path on disk, in the background, replacing any70+// import running.71+func (x *Transfer) Start(name, path string, opt fileio.Options, place Place) tea.Cmd {72+ ctx, cancel := context.WithCancel(context.Background())73+ prog := fileio.NewProgress()74+ id := x.Begin(name, prog, cancel, place)75+ opt.Progress = prog76+ return tea.Batch(77+ func() tea.Msg {78+ res, err := fileio.Import(ctx, path, opt)79+ return ImportedMsg{ID: id, Name: name, Opt: opt, Place: place, Res: res, Err: err}80+ },81+ tickCmd(id),82+ )83+}84+85+// Begin tracks an import of name reporting to prog, cancelled by cancel,86+// replacing any import running, and returns its ID for its ImportedMsg.87+func (x *Transfer) Begin(name string, prog *fileio.Progress, cancel func(), place Place) int {88+ if x.job != nil {89+ x.job.cancel()90+ }91+ x.lastID++92+ x.job = &job{id: x.lastID, name: name, prog: prog, cancel: cancel, place: place}93+ return x.lastID94+}95+96+func tickCmd(id int) tea.Cmd {97+ return tea.Tick(tick, func(time.Time) tea.Msg { return TickMsg{id} })98+}99+100+// Cancel stops the import at once and returns the name of its file; its101+// result, if it still arrives, is ignored.102+func (x *Transfer) Cancel() string {103+ j := x.job104+ j.cancel()105+ x.job = nil106+ return j.name107+}108+109+// Update handles the transfer's own messages: a tick of the running110+// import asks for the next one, and the running import's result ends it111+// and is returned for the model to use. Stale ticks and results, from an112+// import replaced or cancelled, are dropped.113+func (x *Transfer) Update(msg tea.Msg) (res ImportedMsg, done bool, cmd tea.Cmd) {114+ switch msg := msg.(type) {115+ case TickMsg:116+ if x.job != nil && x.job.id == msg.id {117+ return res, false, tickCmd(msg.id)118+ }119+ case ImportedMsg:120+ if x.job == nil || x.job.id != msg.ID {121+ return res, false, nil122+ }123+ x.job.cancel()124+ x.job = nil125+ return msg, true, nil126+ }127+ return res, false, nil128+}129+130+// Line is the context line during an import.131+func (x *Transfer) Line(th *theme.Theme) string {132+ return "Importing " + filepath.Base(x.job.name) + "… " + th.KeyHints("Esc", "cancel")133+}134+135+// Status is the status line during an import, width wide: the file, the136+// rows read, and a bar when the total is known.137+func (x *Transfer) Status(th *theme.Theme, width int) (left, right string) {138+ rows, frac := x.job.prog.Get()139+ left = th.Key.Render("Importing " + filepath.Base(x.job.name))140+ right = Rows(rows) + " read"141+ if frac < 0 {142+ return left, right143+ }144+ pct := fmt.Sprintf(" %3d%%", int(frac*100))145+ w := max(0, min(width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 30))146+ if w < 8 {147+ return left, right + pct148+ }149+ done := int(frac * float64(w))150+ return left, right + " " + th.Progress.Render(strings.Repeat("━", done)) +151+ th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct152+}153+154+// ProgressBar is the terminal's own progress indicator (OSC 9;4), shown155+// in the tab or title bar where supported.156+func (x *Transfer) ProgressBar() *tea.ProgressBar {157+ if x.job == nil {158+ return nil159+ }160+ if _, frac := x.job.prog.Get(); frac >= 0 {161+ return tea.NewProgressBar(tea.ProgressBarDefault, int(frac*100))162+ }163+ return tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)164+}165+166+// Rows is n rows in words: "1 row", "4,096 rows".167+func Rows(n int) string {168+ if n == 1 {169+ return "1 row"170+ }171+ return Thousands(n) + " rows"172+}173+174+// Thousands writes n with commas between thousands.175+func Thousands(n int) string {176+ s := fmt.Sprint(n)177+ for i := len(s) - 3; i > 0; i -= 3 {178+ s = s[:i] + "," + s[i:]179+ }180+ return s181+}
A internal/ui/transfer/transfer_test.go +89 -0
@@ -0,0 +1,89 @@
1+package transfer2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+func TestProgressDisplay(t *testing.T) {17+ th := theme.New(true)18+ var x Transfer19+ prog := fileio.NewProgress()20+ cancelled := false21+ x.Begin("data/big.csv", prog, func() { cancelled = true }, Book)22+ prog.Report(1234, 0, 0)23+ if !x.Busy() || ansi.Strip(x.Line(&th)) != "Importing big.csv… Esc cancel" {24+ t.Errorf("line %q", ansi.Strip(x.Line(&th)))25+ }26+ left, right := x.Status(&th, 80)27+ if ansi.Strip(left) != "Importing big.csv" || right != "1,234 rows read" {28+ t.Errorf("status %q %q", left, right)29+ }30+ if pb := x.ProgressBar(); pb == nil || pb.State != tea.ProgressBarIndeterminate {31+ t.Errorf("progress %+v", pb)32+ }33+ prog.Report(4096, 1, 2)34+ if _, right := x.Status(&th, 80); !strings.HasPrefix(ansi.Strip(right), "4,096 rows read ━") || !strings.HasSuffix(right, " 50%") {35+ t.Errorf("status %q", ansi.Strip(right))36+ }37+ if pb := x.ProgressBar(); pb == nil || pb.Value != 50 {38+ t.Errorf("progress %+v", pb)39+ }40+ if name := x.Cancel(); name != "data/big.csv" || !cancelled || x.Busy() || x.ProgressBar() != nil {41+ t.Errorf("cancel: %q cancelled %v", name, cancelled)42+ }43+}44+45+func TestUpdateDropsStaleResults(t *testing.T) {46+ var x Transfer47+ old := x.Begin("old.csv", fileio.NewProgress(), func() {}, Book)48+ id := x.Begin("new.csv", fileio.NewProgress(), func() {}, Sheet)49+ if _, done, cmd := x.Update(TickMsg{old}); done || cmd != nil {50+ t.Error("a stale tick ticked")51+ }52+ if _, _, cmd := x.Update(TickMsg{id}); cmd == nil {53+ t.Error("the running import stopped ticking")54+ }55+ if _, done, _ := x.Update(ImportedMsg{ID: old}); done {56+ t.Error("a stale result was taken")57+ }58+ res, done, _ := x.Update(ImportedMsg{ID: id, Name: "new.csv", Place: Sheet})59+ if !done || res.Name != "new.csv" || res.Place != Sheet || x.Busy() {60+ t.Errorf("result %+v done %v busy %v", res, done, x.Busy())61+ }62+}63+64+func TestStartReadsInTheBackground(t *testing.T) {65+ path := filepath.Join(t.TempDir(), "a.csv")66+ if err := os.WriteFile(path, []byte("1,2\n3,4\n"), 0o600); err != nil {67+ t.Fatal(err)68+ }69+ var x Transfer70+ cmd := x.Start("a.csv", path, fileio.Options{}, NewSheets)71+ var res ImportedMsg72+ for _, c := range cmd().(tea.BatchMsg) {73+ if msg, ok := c().(ImportedMsg); ok {74+ res = msg75+ }76+ }77+ got, done, _ := x.Update(res)78+ if !done || got.Err != nil || got.Res.Rows != 2 || got.Place != NewSheets {79+ t.Errorf("imported %+v, err %v", got.Res, got.Err)80+ }81+}82+83+func TestRows(t *testing.T) {84+ for n, want := range map[int]string{1: "1 row", 2: "2 rows", 1234567: "1,234,567 rows"} {85+ if got := Rows(n); got != want {86+ t.Errorf("Rows(%d) = %q", n, got)87+ }88+ }89+}
M internal/ui/transfer_test.go +11 -10
@@ -13,6 +13,7 @@ import (
1313 1414 "github.com/FelineStateMachine/012/internal/fileio"1515 "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/transfer"1617 )1718 1819 func writeFile(t *testing.T, name, text string) {
@@ -65,8 +66,8 @@ func TestImportPicker(t *testing.T) {
6566 // A typed path imports a file that isn't listed.6667 m.runCommand("file.import")6768 press(t, m, "sub/deep.tsv", "<enter>", "Replace spreadsheet", "<enter>")68- if input(m, "B1") != "b" || m.xfer.source != "sub/deep.tsv" {69- t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.source, m.errMsg)69+ if input(m, "B1") != "b" || m.xfer.Source != "sub/deep.tsv" {70+ t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.Source, m.errMsg)7071 }7172 // Unknown types and broken files say what's wrong.7273 m.runCommand("file.import")
@@ -109,8 +110,8 @@ func TestOpenImportsOtherFormats(t *testing.T) {
109110 writeFile(t, "data.tsv", "x\t2\n")110111 m := newModel()111112 press(t, m, "<ctrl+o>", "data.tsv", "<enter>")112- if input(m, "B1") != "2" || m.filename != "" || m.xfer.source != "data.tsv" {113- t.Errorf("open: B1 %q file %q source %q", input(m, "B1"), m.filename, m.xfer.source)113+ if input(m, "B1") != "2" || m.filename != "" || m.xfer.Source != "data.tsv" {114+ t.Errorf("open: B1 %q file %q source %q", input(m, "B1"), m.filename, m.xfer.Source)114115 }115116 // Save offers a .012 file or exporting back.116117 press(t, m, "<ctrl+s>")
@@ -242,7 +243,7 @@ func TestImportProgress(t *testing.T) {
242243 m.Update(tea.WindowSizeMsg{Width: 80, Height: 16})243244 cancelled := false244245 prog := fileio.NewProgress()245- m.xfer.job = &importJob{id: 7, name: "data/big.csv", prog: prog, cancel: func() { cancelled = true }}246+ m.xfer.Begin("data/big.csv", prog, func() { cancelled = true }, transfer.Book)246247 prog.Report(1234, 0, 0)247248 if ind := m.indicator(); ind != "WAIT" {248249 t.Errorf("indicator %q", ind)
@@ -273,12 +274,12 @@ func TestImportProgress(t *testing.T) {
273274 if !cancelled {274275 t.Error("Esc didn't cancel")275276 }276- if m.xfer.job != nil || line(m, contextLine) != "Import of big.csv cancelled" {277+ if m.xfer.Busy() || line(m, contextLine) != "Import of big.csv cancelled" {277278 t.Errorf("after cancel: %q", line(m, contextLine))278279 }279280 // A stale result from an earlier import is ignored.280- send(m, importedMsg{id: 3, name: "old.csv", res: &fileio.Result{Sheet: sheet.New()}})281- if m.xfer.source != "" {281+ send(m, transfer.ImportedMsg{ID: 3, Name: "old.csv", Res: &fileio.Result{Sheet: sheet.New()}})282+ if m.xfer.Source != "" {282283 t.Errorf("stale import applied")283284 }284285 }
@@ -299,8 +300,8 @@ func TestImportOnStart(t *testing.T) {
299300 func TestImportNotifiesWhenBlurred(t *testing.T) {300301 m := newModel()301302 send(m, tea.BlurMsg{})302- m.xfer.job = &importJob{id: 5, name: "big.csv", prog: fileio.NewProgress(), cancel: func() {}}303- _, cmd := m.Update(importedMsg{id: 5, name: "big.csv", res: &fileio.Result{Sheet: sheet.New(), Rows: 3}})303+ id := m.xfer.Begin("big.csv", fileio.NewProgress(), func() {}, transfer.Book)304+ _, cmd := m.Update(transfer.ImportedMsg{ID: id, Name: "big.csv", Res: &fileio.Result{Sheet: sheet.New(), Rows: 3}})304305 var notified bool305306 for _, msg := range flatten(cmd) {306307 if raw, ok := msg.(tea.RawMsg); ok && strings.Contains(fmt.Sprint(raw.Msg), "012: Imported big.csv") {