cat / 012
clone

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

Ask where File > Import puts the data: new sheets, the current sheet or the spreadsheet

Dami committed c867e16
commit
c867e165713d45fc163ac9e965200efa2fec52a3
parent
625fe586e7dc87dadad36db5dc705c5bcd1b576f
author
Dami <dami.etoile@gmail.com>
9 files changed, +512 -44
Mdocs/files.md9++
Ainternal/sheet/insertbook.go117+++++++++++++++++++++++++++++
Ainternal/sheet/insertbook_test.go112++++++++++++++++++++++++++++
Minternal/sheet/workbook.go15+---
Ainternal/ui/importplace.go121++++++++++++++++++++++++++++++
Ainternal/ui/importplace_test.go117+++++++++++++++++++++++++++++
Minternal/ui/serve_test.go2+-
Minternal/ui/transfer.go51+++++++------
Minternal/ui/transfer_test.go12+-
M docs/files.md +9 -0
A internal/sheet/insertbook.go +117 -0
@@ -0,0 +1,117 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+)8+9+// Importing into a workbook, as Sheets' Import location: "Insert new10+// sheet(s)" moves every sheet of an imported workbook into this one, and11+// "Replace current sheet" puts the imported sheet in the place of one.12+// The imported workbook is built on its own (fileio) and given up to13+// these; either is one undo step.14+15+// ImportResult says what an import into a workbook did.16+type ImportResult struct {17+ Sheets []*Sheet // the sheets added, in tab order18+ Renamed map[string]string // imported names changed to fit, old to new19+ Names int // named ranges left out, their names taken20+}21+22+// InsertBook moves the sheets of src, an imported workbook, into w after23+// its last sheet, with src's named ranges, as one undo step labelled24+// label. A sheet whose name w already has gets a number ("Sales 2"), and25+// src's formulas follow the new name; a named range whose name w already26+// has is left out. src is used up.27+func (w *Workbook) InsertBook(src *Workbook, label string) (ImportResult, error) {28+ var res ImportResult29+ if src == w || len(src.sheets) == 0 {30+ return res, errors.New("Nothing to import")31+ }32+ sheets := slices.Clone(src.sheets)33+ for _, s := range sheets {34+ if w.Lookup(s.name) == nil {35+ continue36+ }37+ name := freeIn(s.name, w, src)38+ if res.Renamed == nil {39+ res.Renamed = map[string]string{}40+ }41+ res.Renamed[s.name] = name42+ if err := src.RenameSheet(s, name); err != nil {43+ return res, err44+ }45+ }46+ names := src.Names()47+ for _, s := range sheets {48+ src.detach(s)49+ s.wb = w50+ }51+ src.sheets = nil52+ w.change(sheets[0], label, Rect{}, func() {53+ w.recordSheets()54+ for _, s := range sheets {55+ w.insert(s, len(w.sheets))56+ }57+ for _, n := range names {58+ if _, taken := w.LookupName(n.Name); taken {59+ res.Names++60+ continue61+ }62+ w.putName(nameKey(n.Name), &n)63+ }64+ })65+ res.Sheets = sheets66+ return res, nil67+}68+69+// ReplaceSheet puts s, the sheet of an imported workbook, in the place of70+// dst, as one undo step labelled label: it takes dst's name and position,71+// so formulas reading dst read it, and named ranges on dst move to it.72+// dst's cells, charts and the rest go with it; undo brings them back.73+func (w *Workbook) ReplaceSheet(dst, s *Sheet, label string) error {74+ switch {75+ case !dst.live || dst.wb != w:76+ return errors.New("That sheet was deleted")77+ case s.wb == w:78+ return errors.New("That sheet is already in the spreadsheet")79+ }80+ s.wb.detach(s)81+ s.wb.sheets = slices.DeleteFunc(s.wb.sheets, func(t *Sheet) bool { return t == s })82+ s.wb, s.tabHidden = w, dst.tabHidden83+ w.change(dst, label, Rect{}, func() {84+ w.recordSheets()85+ i := w.Index(dst)86+ w.remove(dst)87+ s.name = dst.name88+ w.insert(s, i)89+ for _, n := range w.Names() {90+ if n.Sheet == dst {91+ n.Sheet = s92+ w.putName(nameKey(n.Name), &n)93+ }94+ }95+ })96+ return nil97+}98+99+// freeIn returns base, or base with a number added ("Sales 2"), so that100+// none of books has a sheet of that name, within the length limit.101+func freeIn(base string, books ...*Workbook) string {102+ trim := func(s string, n int) string {103+ if r := []rune(s); len(r) > n {104+ return string(r[:n])105+ }106+ return s107+ }108+ taken := func(name string) bool {109+ return slices.ContainsFunc(books, func(w *Workbook) bool { return w.Lookup(name) != nil })110+ }111+ name := trim(base, maxSheetName)112+ for n := 2; taken(name); n++ {113+ suffix := fmt.Sprintf(" %d", n)114+ name = trim(base, maxSheetName-len(suffix)) + suffix115+ }116+ return name117+}
A internal/sheet/insertbook_test.go +112 -0
@@ -0,0 +1,112 @@
1+package sheet2+3+import (4+ "testing"5+)6+7+// Inserting an imported workbook adds its sheets after the others,8+// renaming the ones whose names are taken (and the formulas that use9+// them), brings its free named ranges, and undoes as one step.10+func TestInsertBook(t *testing.T) {11+ w := bookOf(t,12+ page{"Sales", map[string]string{"A1": "1"}},13+ page{"Notes", map[string]string{"A1": "=Sales!A1+Data!A1"}},14+ )15+ w.DefineName("Taken", w.Sheet(0), NewRect(at("A1"), at("A1")))16+ w.ClearHistory()17+ src := bookOf(t,18+ page{"Sales", map[string]string{"A1": "10", "A2": "=Data!A1*2"}},19+ page{"Data", map[string]string{"A1": "5", "A2": "=Sales!A1+1"}},20+ )21+ src.DefineName("Taken", src.Sheet(1), NewRect(at("A1"), at("A1")))22+ src.DefineName("Fresh", src.Sheet(1), NewRect(at("A1"), at("A1")))23+ src.hidePlain(src.Sheet(1))24+25+ res, err := w.InsertBook(src, "import book.xlsx")26+ if err != nil {27+ t.Fatal(err)28+ }29+ if got := namesOf(w); got != "Sales,Notes,Sales 2,Data" {30+ t.Fatalf("sheets %s", got)31+ }32+ if len(res.Sheets) != 2 || res.Renamed["Sales"] != "Sales 2" || res.Names != 1 {33+ t.Errorf("result %+v", res)34+ }35+ for ref, want := range map[string]string{36+ "Sales 2!A2": "10", // Data!A1*2 on the imported sheets37+ "Data!A2": "11", // Sales!A1+1 now reads Sales 238+ "Data!input:A2": "='Sales 2'!A1+1",39+ "Notes!A1": "6", // Data exists now40+ } {41+ if got := show(t, w, ref); got != want {42+ t.Errorf("%s = %s, want %s", ref, got, want)43+ }44+ }45+ if !w.Lookup("Data").Hidden() {46+ t.Error("Data lost its hidden flag")47+ }48+ if n, ok := w.LookupName("Fresh"); !ok || n.Sheet != w.Lookup("Data") {49+ t.Errorf("Fresh: %v %+v", ok, n)50+ }51+ if n, _ := w.LookupName("Taken"); n.Sheet != w.Sheet(0) {52+ t.Error("an imported name replaced Taken")53+ }54+55+ c, ok := w.Undo()56+ if !ok || c.Label != "import book.xlsx" || namesOf(w) != "Sales,Notes" {57+ t.Fatalf("undo: %+v, sheets %s", c, namesOf(w))58+ }59+ if _, ok := w.LookupName("Fresh"); ok || show(t, w, "Notes!A1") != "#REF!" {60+ t.Errorf("undo left Fresh %v, Notes!A1 %s", ok, show(t, w, "Notes!A1"))61+ }62+ w.Redo()63+ if namesOf(w) != "Sales,Notes,Sales 2,Data" || show(t, w, "Notes!A1") != "6" {64+ t.Errorf("redo: %s", namesOf(w))65+ }66+}67+68+// Replacing a sheet puts the imported one in its place, with its name,69+// so formulas and named ranges reading it read the new data.70+func TestReplaceSheet(t *testing.T) {71+ w := bookOf(t,72+ page{"Sales", map[string]string{"A1": "1", "B1": "old"}},73+ page{"Notes", map[string]string{"A1": "=Sales!A1*2"}},74+ )75+ w.DefineName("First", w.Sheet(0), NewRect(at("A1"), at("A1")))76+ w.Sheet(1).Set(at("A2"), "=First")77+ w.ClearHistory()78+ src := bookOf(t, page{"sales", map[string]string{"A1": "21"}})79+80+ old := w.Sheet(0)81+ if err := w.ReplaceSheet(old, src.Sheet(0), "import sales.csv"); err != nil {82+ t.Fatal(err)83+ }84+ if namesOf(w) != "Sales,Notes" || old.Live() || show(t, w, "Sales!B1") != "" {85+ t.Fatalf("sheets %s", namesOf(w))86+ }87+ if show(t, w, "Notes!A1") != "42" || show(t, w, "Notes!A2") != "21" {88+ t.Errorf("Notes!A1 %s, A2 %s", show(t, w, "Notes!A1"), show(t, w, "Notes!A2"))89+ }90+ c, _ := w.Undo()91+ if c.Label != "import sales.csv" || w.Sheet(0) != old || show(t, w, "Notes!A1") != "2" || show(t, w, "Notes!A2") != "1" {92+ t.Errorf("undo: %+v, Notes!A1 %s", c, show(t, w, "Notes!A1"))93+ }94+ w.Redo()95+ if show(t, w, "Notes!A1") != "42" {96+ t.Errorf("redo: %s", show(t, w, "Notes!A1"))97+ }98+}99+100+func namesOf(w *Workbook) string {101+ var out string102+ for i, s := range w.sheets {103+ if i > 0 {104+ out += ","105+ }106+ out += s.name107+ }108+ return out109+}110+111+// hidePlain hides s outside the history, as a loader would.112+func (w *Workbook) hidePlain(s *Sheet) { s.tabHidden = true }
M internal/sheet/workbook.go +1 -14
A internal/ui/importplace.go +121 -0
@@ -0,0 +1,121 @@
1+package ui2+3+import (4+ "path/filepath"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+)12+13+// Where File > Import puts what it reads, as Sheets' Import location:14+// into new sheets after the others, in place of the sheet shown, or in15+// place of the whole spreadsheet (what File > Open and the command line16+// do). A new, empty spreadsheet is just replaced. Inserting or replacing17+// a sheet is one undo step, and the file stays the one being edited.18+19+// importPlace is where an import goes.20+type importPlace int21+22+const (23+ placeBook importPlace = iota // replace the spreadsheet24+ placeNewSheets // insert new sheets25+ placeSheet // replace the sheet shown26+)27+28+// pristine reports whether the spreadsheet is new and untouched, so an29+// import may simply replace it.30+func (m *Model) pristine() bool {31+ return !m.changed && m.filename == "" && m.xfer.source == "" && m.book().Len() == 1 &&32+ m.sheet.Len() == 0 && len(m.sheet.Charts()) == 033+}34+35+// askImportPlace imports name, asking first where it goes unless the36+// spreadsheet is new and empty.37+func (m *Model) askImportPlace(name string) tea.Cmd {38+ if m.pristine() {39+ return m.startImport(name, fileio.Options{}, placeBook)40+ }41+ k, _ := fileio.KindOf(name)42+ base := filepath.Base(name)43+ insert := "Insert new sheet"44+ if k.HoldsSheets() {45+ insert = "Insert new sheets"46+ }47+ item := func(title, detail, desc string, run func(m *Model) tea.Cmd) pickItem {48+ return pickItem{title: title, name: len(title), detail: detail, desc: desc, pick: func(m *Model) tea.Cmd {49+ m.closeOverlay()50+ return run(m)51+ }}52+ }53+ items := []pickItem{item(insert, "after the others", "Add "+base+" as new sheets",54+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeNewSheets) })}55+ if !k.HoldsSheets() {56+ items = append(items, item("Replace current sheet", m.sheet.Name(), "Put "+base+" in place of "+m.sheet.Name()+", keeping its name",57+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeSheet) }))58+ }59+ detail := "open it instead"60+ if m.changed {61+ detail = "unsaved changes"62+ }63+ items = append(items, item("Replace spreadsheet", detail, "Open "+base+" instead, as File > Open does",64+ func(m *Model) tea.Cmd { return m.confirmImport(name, fileio.Options{}) }))65+ p := newPicker(m, "Import "+base, "Import location", 60, items)66+ p.action = "import"67+ m.openOverlay(p)68+ return nil69+}70+71+// placeImport puts an imported workbook where the import was asked to go72+// and says what it did, or reports false to replace the spreadsheet.73+func (m *Model) placeImport(msg importedMsg, what string) bool {74+ res := msg.res75+ label := "import " + filepath.Base(msg.name)76+ switch msg.place {77+ case placeNewSheets:78+ ins, err := m.book().InsertBook(res.Sheet.Book(), label)79+ if err != nil {80+ m.fail(err.Error())81+ return true82+ }83+ show := res.Sheet84+ if show.Hidden() {85+ show = ins.Sheets[0]86+ }87+ m.afterSheetsChange(show, m.book().Index(m.sheet))88+ m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + countRows(res.Rows) + ")"89+ if ins.Names > 0 {90+ m.note += "; " + plural(ins.Names, "1 named range", thousands(ins.Names)+" named ranges") + " left out, their names taken"91+ }92+ case placeSheet:93+ i := m.book().Index(m.sheet)94+ if err := m.book().ReplaceSheet(m.sheet, res.Sheet, label); err != nil {95+ m.fail(err.Error())96+ return true97+ }98+ m.afterSheetsChange(res.Sheet, i)99+ m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + countRows(res.Rows) + ")"100+ default:101+ return false102+ }103+ m.changed = true104+ if len(res.Notes) > 0 {105+ m.note += "; " + strings.Join(res.Notes, "; ")106+ }107+ return true108+}109+110+// sheetList names sheets for a note: "Sales", "Sales and Q3", or "3111+// sheets, Sales to Q3".112+func sheetList(sheets []*sheet.Sheet) string {113+ switch n := len(sheets); n {114+ case 1:115+ return sheets[0].Name()116+ case 2:117+ return sheets[0].Name() + " and " + sheets[1].Name()118+ default:119+ return thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()120+ }121+}
A internal/ui/importplace_test.go +117 -0
@@ -0,0 +1,117 @@
1+package ui2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// importInto runs File > Import on name and picks place in the location14+// picker.15+func importInto(t *testing.T, m *Model, name, place string) {16+ t.Helper()17+ m.runCommand("file.import")18+ press(t, m, name, "<enter>")19+ p, ok := m.overlay.(*picker)20+ if !ok || !strings.HasPrefix(p.title, "Import ") {21+ t.Fatalf("no location picker: %T", m.overlay)22+ }23+ press(t, m, place, "<enter>")24+}25+26+func TestImportLocation(t *testing.T) {27+ t.Chdir(t.TempDir())28+ writeFile(t, "sales.csv", "Region,Total\nNorth,12\n")29+ m := newModel()30+ press(t, m, "=sales!B2*2", "<enter>")31+ m.changed = false32+33+ // The picker offers Sheets' choices, what the current sheet is, and34+ // says what each does.35+ m.runCommand("file.import")36+ press(t, m, "sales.csv", "<enter>")37+ got := strings.Join(pickerTitles(t, m), "\n")38+ want := "Insert new sheet | after the others\nReplace current sheet | Sheet1\nReplace spreadsheet | open it instead"39+ if got != want {40+ t.Errorf("locations:\n%s\nwant\n%s", got, want)41+ }42+ if st := status(m); !strings.Contains(st, "Add sales.csv as new sheets") {43+ t.Errorf("status %q", st)44+ }45+ press(t, m, "<esc>")46+ if m.overlay != nil || m.book().Len() != 1 {47+ t.Fatalf("Esc imported: %d sheets", m.book().Len())48+ }49+50+ // Insert new sheet: one undo step, the file stays this one.51+ importInto(t, m, "sales.csv", "Insert new sheet")52+ if sheetNames(m) != "Sheet1,sales" || m.sheet.Name() != "sales" || input(m, "A2") != "North" {53+ t.Fatalf("inserted: %s on %s", sheetNames(m), m.sheet.Name())54+ }55+ if line(m, contextLine) != "Imported sales.csv as sales (2 rows)" || !m.changed || m.xfer.source != "" {56+ t.Errorf("context %q changed %v source %q", line(m, contextLine), m.changed, m.xfer.source)57+ }58+ if v := m.book().Sheet(0).Value(addr("A1")); v.Num != 24 {59+ t.Errorf("Sheet1!A1 reading the import = %v", v)60+ }61+ // Again: the name is taken, so the new sheet gets a number.62+ importInto(t, m, "sales.csv", "Insert new sheet")63+ if sheetNames(m) != "Sheet1,sales,sales 2" || m.sheet.Name() != "sales 2" {64+ t.Errorf("second insert: %s", sheetNames(m))65+ }66+ press(t, m, "<ctrl+z>")67+ if sheetNames(m) != "Sheet1,sales" || !strings.Contains(line(m, contextLine), "Undid: import sales.csv") {68+ t.Errorf("undo: %s, %q", sheetNames(m), line(m, contextLine))69+ }70+71+ // Replace current sheet keeps the sheet's name and place.72+ press(t, m, "<ctrl+pgup>")73+ writeFile(t, "sales.csv", "Region,Total\nSouth,50\n")74+ importInto(t, m, "sales.csv", "Replace current sheet")75+ if sheetNames(m) != "Sheet1,sales" || m.sheet.Name() != "Sheet1" || input(m, "A2") != "South" || input(m, "A1") != "Region" {76+ t.Fatalf("replaced: %s on %s, A2 %q", sheetNames(m), m.sheet.Name(), input(m, "A2"))77+ }78+ if line(m, contextLine) != "Imported sales.csv into Sheet1 (2 rows)" {79+ t.Errorf("context %q", line(m, contextLine))80+ }81+ press(t, m, "<ctrl+z>")82+ if m.sheet.Name() != "Sheet1" || input(m, "A1") != "=sales!B2*2" {83+ t.Errorf("undo replace: on %s, A1 %q", m.sheet.Name(), input(m, "A1"))84+ }85+}86+87+// An XLSX workbook inserts every sheet, names made unique, and offers no88+// Replace current sheet.89+func TestImportLocationWorkbook(t *testing.T) {90+ t.Chdir(t.TempDir())91+ src := sheet.New()92+ book := src.Book()93+ book.RenameSheet(src, "Sheet1")94+ src.Set(addr("A1"), "1")95+ q3, _ := book.AddSheet("Q3", 1)96+ q3.Set(addr("A1"), "=Sheet1!A1+1")97+ if _, err := fileio.Export(context.Background(), filepath.Join(".", "book.xlsx"), fileio.XLSX, fileio.SnapBook(src), fileio.ExportOptions{}); err != nil {98+ t.Fatal(err)99+ }100+ m := newModel()101+ press(t, m, "x", "<enter>")102+ m.runCommand("file.import")103+ press(t, m, "book", "<enter>")104+ if got := strings.Join(pickerTitles(t, m), "\n"); got != "Insert new sheets | after the others\nReplace spreadsheet | unsaved changes" {105+ t.Errorf("locations:\n%s", got)106+ }107+ press(t, m, "insert", "<enter>")108+ if sheetNames(m) != "Sheet1,Sheet1 2,Q3" || m.sheet.Name() != "Sheet1 2" {109+ t.Fatalf("sheets %s on %s (%q)", sheetNames(m), m.sheet.Name(), m.errMsg)110+ }111+ if got := m.book().Lookup("Q3"); got.Cell(addr("A1")).Input != "='Sheet1 2'!A1+1" || got.Value(addr("A1")).Num != 2 {112+ t.Errorf("Q3!A1 %q = %v", got.Cell(addr("A1")).Input, got.Value(addr("A1")))113+ }114+ if l := line(m, contextLine); l != "Imported book.xlsx as Sheet1 2 and Q3 (2 rows)" {115+ t.Errorf("context %q", l)116+ }117+}
M internal/ui/serve_test.go +1 -1
M internal/ui/transfer.go +28 -23
@@ -40,14 +40,16 @@ type importJob struct {
4040 name string4141 prog *fileio.Progress4242 cancel context.CancelFunc43+ place importPlace // where the result goes; see importplace.go4344 }4445 4546 type importedMsg struct {46- id int47- name string48- opt fileio.Options49- res *fileio.Result50- err error47+ id int48+ name string49+ opt fileio.Options50+ place importPlace51+ res *fileio.Result52+ err error5153 }5254 5355 type importTickMsg struct{ id int }
@@ -57,7 +59,7 @@ const importTick = 100 * time.Millisecond
5759 5860 func init() {5961 register(&command{id: "file.import", macro: macroNever, title: "Import",60- desc: "Import a " + importNouns() + " file, replacing this sheet",62+ desc: "Import a " + importNouns() + " file as new sheets, or in place of this sheet or spreadsheet",6163 run: (*Model).openImport})6264 }6365
@@ -83,7 +85,7 @@ func (m *Model) startupCmd() tea.Cmd {
8385 return nil8486 }8587 m.xfer.startup = ""86- return m.startImport(name, fileio.Options{})88+ return m.startImport(name, fileio.Options{}, placeBook)8789 }8890 8991 // importable lists the files in the directory names are relative to
@@ -130,7 +132,7 @@ func (m *Model) openImport() tea.Cmd {
130132 items = append(items, pickItem{title: name, name: len(name), detail: detail, desc: desc,131133 pick: func(m *Model) tea.Cmd {132134 m.closeOverlay()133- return m.confirmImport(name, fileio.Options{})135+ return m.askImportPlace(name)134136 }})135137 }136138 p := newPicker(m, "Import", "Type to filter, or a path", 72, items)
@@ -145,7 +147,7 @@ func (m *Model) openImport() tea.Cmd {
145147 m.fail(fmt.Sprintf("Can't import %s: 012 imports %s", query, importExts()))146148 return nil, true147149 }148- return m.confirmImport(query, fileio.Options{}), true150+ return m.askImportPlace(query), true149151 }150152 m.openOverlay(p)151153 return nil
@@ -177,13 +179,13 @@ func fileSize(n int64) string {
177179 // unsaved changes.178180 func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {179181 if !m.changed {180- return m.startImport(name, opt)182+ return m.startImport(name, opt, placeBook)181183 }182184 m.openOverlay(&choiceBar{183- msg: "Importing replaces this sheet, which has unsaved changes.",185+ msg: "Importing replaces this spreadsheet, which has unsaved changes.",184186 warn: true,185187 choices: []choice{186- {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt) }},188+ {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt, placeBook) }},187189 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},188190 },189191 })
@@ -191,30 +193,30 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
191193 }192194 193195 // startImport reads name in the background.194-func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {196+func (m *Model) startImport(name string, opt fileio.Options, place importPlace) tea.Cmd {195197 m.note = ""196198 path, ok := m.path("import", name)197199 if !ok {198200 return nil199201 }200- return m.xfer.start(name, path, opt)202+ return m.xfer.start(name, path, opt, place)201203 }202204 203205 // start reads name, at path on disk, in the background, replacing any204206 // import running.205-func (x *transfer) start(name, path string, opt fileio.Options) tea.Cmd {207+func (x *transfer) start(name, path string, opt fileio.Options, place importPlace) tea.Cmd {206208 if x.job != nil {207209 x.job.cancel()208210 }209211 ctx, cancel := context.WithCancel(context.Background())210212 x.lastID++211- job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}213+ job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel, place: place}212214 opt.Progress = job.prog213215 x.job = job214216 return tea.Batch(215217 func() tea.Msg {216218 res, err := fileio.Import(ctx, path, opt)217- return importedMsg{id: job.id, name: name, opt: opt, res: res, err: err}219+ return importedMsg{id: job.id, name: name, opt: opt, place: place, res: res, err: err}218220 },219221 tickImport(job.id),220222 )
@@ -310,14 +312,12 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
310312 m.note = "Import of " + filepath.Base(msg.name) + " cancelled"311313 return nil312314 case errors.As(msg.err, &need):313- m.openTablePicker(msg.name, need.Tables)315+ m.openTablePicker(msg.name, need.Tables, msg.place)314316 return nil315317 case msg.err != nil:316318 m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.name), msg.err))317319 return nil318320 }319- m.reset(msg.res.Sheet, "")320- m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind321321 what := filepath.Base(msg.name)322322 switch {323323 case msg.opt.Table != "":
@@ -325,6 +325,11 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
325325 case msg.opt.Query != "":326326 what = "the query from " + what327327 }328+ if m.placeImport(msg, what) {329+ return m.term.notify("Imported " + what)330+ }331+ m.reset(msg.res.Sheet, "")332+ m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind328333 m.note = "Imported " + what + " (" + countRows(msg.res.Rows) + ")"329334 if len(msg.res.Notes) > 0 {330335 m.note += "; " + strings.Join(msg.res.Notes, "; ")
@@ -335,7 +340,7 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
335340 336341 // openTablePicker asks which table of a SQLite database to import, or337342 // for a query.338-func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {343+func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place importPlace) {339344 var items []pickItem340345 for _, t := range tables {341346 detail := countRows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))
@@ -346,7 +351,7 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {
346351 items = append(items, pickItem{title: t.Name, name: len(t.Name), detail: detail, desc: desc,347352 pick: func(m *Model) tea.Cmd {348353 m.closeOverlay()349- return m.startImport(name, fileio.Options{Table: t.Name})354+ return m.startImport(name, fileio.Options{Table: t.Name}, place)350355 }})351356 }352357 first := "table"
@@ -361,7 +366,7 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {
361366 if text == "" {362367 return nil363368 }364- return m.startImport(name, fileio.Options{Query: text})369+ return m.startImport(name, fileio.Options{Query: text}, place)365370 })366371 m.prompt.indicator = "SQL"367372 return nil
M internal/ui/transfer_test.go +6 -6
@@ -64,7 +64,7 @@ func TestImportPicker(t *testing.T) {
6464 6565 // A typed path imports a file that isn't listed.6666 m.runCommand("file.import")67- press(t, m, "sub/deep.tsv", "<enter>")67+ press(t, m, "sub/deep.tsv", "<enter>", "Replace spreadsheet", "<enter>")6868 if input(m, "B1") != "b" || m.xfer.source != "sub/deep.tsv" {6969 t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.source, m.errMsg)7070 }
@@ -76,7 +76,7 @@ func TestImportPicker(t *testing.T) {
7676 }7777 press(t, m, "<esc>")7878 m.runCommand("file.import")79- press(t, m, "old", "<enter>")79+ press(t, m, "old", "<enter>", "Replace spreadsheet", "<enter>")8080 if m.mode != modeError || m.errMsg != "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet: the file is empty" &&8181 !strings.Contains(m.errMsg, "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet") {8282 t.Errorf("wk1: %v %q", m.mode, m.errMsg)
@@ -89,8 +89,8 @@ func TestImportAsksBeforeReplacingChanges(t *testing.T) {
8989 m := newModel()9090 press(t, m, "keep me", "<enter>")9191 m.runCommand("file.import")92- press(t, m, "<enter>")93- if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this sheet, which has unsaved changes.") {92+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>")93+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this spreadsheet, which has unsaved changes.") {9494 t.Fatalf("no warning: %q", line(m, contextLine))9595 }9696 press(t, m, "<esc>")
@@ -98,7 +98,7 @@ func TestImportAsksBeforeReplacingChanges(t *testing.T) {
9898 t.Errorf("Esc imported anyway")9999 }100100 m.runCommand("file.import")101- press(t, m, "<enter>", "<enter>")101+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "<enter>")102102 if input(m, "A1") != "1" {103103 t.Errorf("Enter didn't import: %q", input(m, "A1"))104104 }
@@ -162,7 +162,7 @@ func TestSQLiteTablePicker(t *testing.T) {
162162 }163163 164164 m.runCommand("file.import")165- press(t, m, "<enter>", "query", "<enter>")165+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "query", "<enter>")166166 if m.mode != modePrompt || m.line.text() != "SELECT * FROM orders" {167167 t.Fatalf("query prompt %v %q", m.mode, m.line.text())168168 }