transfer.go was 531 lines. File > Download, with its commands, moves to download.go. Starting and cancelling an import and drawing its context line, status line and terminal progress bar become methods of transfer, which owns the import's state; Model keeps the flow that opens pickers and prompts.
- commit
- a9bf9c233ffd678a1054682ceed7fadb2df115a0
- parent
- 9ee03491c3e32598b19f41e356ee0fcf83216d40
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 168 | +++++++++++++++++++++++ |
| M | internal/ | 6 | +- |
| M | internal/ | 223 | ++++++------------------------ |
@@ -0,0 +1,168 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// File > Download: the sheet, or for SQLite the selection, written in17+// the background as CSV, TSV, Excel or a SQLite table, asking before18+// replacing a file or table.19+20+func init() {21+ for _, k := range fileio.Kinds() {22+ if !k.CanExport() {23+ continue24+ }25+ register(&command{26+ id: "file.download." + strings.ToLower(k.String()),27+ title: "Download as " + k.String(),28+ desc: downloadDesc(k),29+ run: func(m *Model) tea.Cmd { return m.openDownload(k) },30+ })31+ }32+}33+34+type exportedMsg struct {35+ name string36+ kind fileio.Kind37+ res *fileio.ExportResult38+ err error39+}40+41+func downloadDesc(k fileio.Kind) string {42+ switch k {43+ case fileio.CSV, fileio.TSV:44+ return "Save the values as shown, as " + strings.ToLower(k.Label()) + " (" + k.Ext() + ")"45+ case fileio.XLSX:46+ return "Save as an Excel workbook (.xlsx) with formulas, formats and widths"47+ case fileio.SQLite:48+ return "Save the sheet, or the selection, as a table in a SQLite database"49+ }50+ return ""51+}52+53+// displayBase is the name downloads and saves start from: the sheet's54+// file or the imported file, without its extension.55+func (m *Model) displayBase() string {56+ name := m.filename57+ if name == "" {58+ name = m.xfer.source59+ }60+ if name == "" {61+ return "SHEET1"62+ }63+ return strings.TrimSuffix(name, filepath.Ext(name))64+}65+66+// openDownload asks where to download the sheet as k.67+func (m *Model) openDownload(k fileio.Kind) tea.Cmd {68+ if _, ok := m.sheet.UsedRange(); !ok {69+ m.note = "Nothing to download: the sheet is empty"70+ return nil71+ }72+ r := sheet.Rect{}73+ label := "Download as " + k.String() + ":"74+ if k == fileio.SQLite && m.hasRange() {75+ r = m.selection()76+ label = "Download " + r.String() + " as SQLite:"77+ }78+ m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {79+ if text == "" {80+ return nil81+ }82+ name := text83+ if filepath.Ext(name) == "" {84+ name += k.Ext()85+ }86+ if k == fileio.SQLite {87+ m.openTableName(name, r)88+ return nil89+ }90+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {91+ return m.download(name, k, r, "")92+ }, exists(name))93+ })94+ return nil95+}96+97+func exists(name string) bool {98+ _, err := os.Stat(name)99+ return err == nil100+}101+102+// openTableName asks for the table to write in a SQLite database.103+func (m *Model) openTableName(name string, r sheet.Rect) {104+ m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {105+ if table == "" {106+ return nil107+ }108+ has := false109+ if exists(name) {110+ ts, err := fileio.Tables(context.Background(), name)111+ if err != nil {112+ m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))113+ return nil114+ }115+ for _, t := range ts {116+ has = has || strings.EqualFold(t.Name, table)117+ }118+ }119+ return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {120+ return m.download(name, fileio.SQLite, r, table)121+ }, has)122+ })123+}124+125+// confirmReplace runs do, first asking on the context line when it126+// would replace something.127+func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {128+ if !replaces {129+ return do(m)130+ }131+ m.openOverlay(&choiceBar{132+ msg: msg,133+ warn: true,134+ choices: []choice{135+ {key: "enter", label: "Replace", run: do},136+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},137+ },138+ })139+ return nil140+}141+142+// download snapshots the sheet and writes it in the background.143+func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {144+ snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))145+ if k == fileio.XLSX && r == (sheet.Rect{}) {146+ snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download147+ }148+ return func() tea.Msg {149+ res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})150+ return exportedMsg{name: name, kind: k, res: res, err: err}151+ }152+}153+154+// saveImported is Save for a sheet imported from another format: save it155+// as a .012 file (keeping formulas and formatting), or export it back.156+func (m *Model) saveImported() tea.Cmd {157+ k := m.xfer.kind158+ choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}159+ if k.CanExport() {160+ choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})161+ }162+ choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {163+ m.quitAfterSave = false164+ return nil165+ }})166+ m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})167+ return nil168+}@@ -52,7 +52,7 @@ func (m *Model) View() tea.View {
5252 // Terminals that support it (OSC 9;4) show activity in the tab.5353 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)5454 }55- if bar := m.progressBar(); bar != nil {55+ if bar := m.xfer.progressBar(); bar != nil {5656 v.ProgressBar = bar5757 }5858 if m.changed {@@ -143,7 +143,7 @@ func (m *Model) contextLineText() string {
143143 var left, right string144144 switch {145145 case m.xfer.job != nil:146- left = m.importLine()146+ left = m.xfer.line(&m.th)147147 case m.mouse.drag == dragResize:148148 left = m.th.Key.Render("Column "+sheet.ColName(m.mouse.resizeCol)) + m.th.Muted.Render(" width ") +149149 strconv.Itoa(m.sheet.ColWidth(m.mouse.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")@@ -233,7 +233,7 @@ func (m *Model) statusLayout() (string, []tabSpan) {
233233 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil234234 }235235 if m.xfer.job != nil {236- return m.importStatus(), nil236+ return m.spread(m.xfer.status(&m.th, m.width)), nil237237 }238238 if line, ok := m.floatingStatus(); ok {239239 return line, nil@@ -14,7 +14,7 @@ import (
1414 "github.com/charmbracelet/x/ansi"1515 1616 "github.com/FelineStateMachine/012/internal/fileio"17- "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/ui/theme"1818 )1919 2020 // Data comes in through File > Import (a picker of the files around),@@ -51,13 +51,6 @@ type importedMsg struct {
5151 5252 type importTickMsg struct{ id int }5353 54-type exportedMsg struct {55- name string56- kind fileio.Kind57- res *fileio.ExportResult58- err error59-}60-6154 // importTick is how often the progress display refreshes.6255 const importTick = 100 * time.Millisecond6356 @@ -65,29 +58,6 @@ func init() {
6558 register(&command{id: "file.import", title: "Import",6659 desc: "Import a CSV, TSV, Excel, SQLite, Parquet or 1-2-3 file, replacing this sheet",6760 run: (*Model).openImport})68- for _, k := range fileio.Kinds() {69- if !k.CanExport() {70- continue71- }72- register(&command{73- id: "file.download." + strings.ToLower(k.String()),74- title: "Download as " + k.String(),75- desc: downloadDesc(k),76- run: func(m *Model) tea.Cmd { return m.openDownload(k) },77- })78- }79-}80-81-func downloadDesc(k fileio.Kind) string {82- switch k {83- case fileio.CSV, fileio.TSV:84- return "Save the values as shown, as " + strings.ToLower(k.Label()) + " (" + k.Ext() + ")"85- case fileio.XLSX:86- return "Save as an Excel workbook (.xlsx) with formulas, formats and widths"87- case fileio.SQLite:88- return "Save the sheet, or the selection, as a table in a SQLite database"89- }90- return ""9161 }9262 9363 // Import sets a file to import when the program starts, e.g. from@@ -203,15 +173,20 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
203173 204174 // startImport reads name in the background.205175 func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {206- if m.xfer.job != nil {207- m.xfer.job.cancel()176+ m.note = ""177+ return m.xfer.start(name, opt)178+}179+180+// start reads name in the background, replacing any import running.181+func (x *transfer) start(name string, opt fileio.Options) tea.Cmd {182+ if x.job != nil {183+ x.job.cancel()208184 }209185 ctx, cancel := context.WithCancel(context.Background())210- m.xfer.lastID++211- job := &importJob{id: m.xfer.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}186+ x.lastID++187+ job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}212188 opt.Progress = job.prog213- m.xfer.job = job214- m.note = ""189+ x.job = job215190 return tea.Batch(216191 func() tea.Msg {217192 res, err := fileio.Import(ctx, name, opt)@@ -251,10 +226,15 @@ func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {
251226 // cancelImport stops the import at once; its result, if it still252227 // arrives, is ignored.253228 func (m *Model) cancelImport() {254- job := m.xfer.job229+ m.note = "Import of " + filepath.Base(m.xfer.cancel()) + " cancelled"230+}231+232+// cancel stops the import and returns the name of its file.233+func (x *transfer) cancel() string {234+ job := x.job255235 job.cancel()256- m.xfer.job = nil257- m.note = "Import of " + filepath.Base(job.name) + " cancelled"236+ x.job = nil237+ return job.name258238 }259239 260240 // handleTransfer handles import and export messages.@@ -375,157 +355,38 @@ func quoteSQL(s string) string {
375355 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`376356 }377357 378-// importLine is the context line during an import.379-func (m *Model) importLine() string {380- return "Importing " + filepath.Base(m.xfer.job.name) + "… " + m.th.KeyHints("Esc", "cancel")358+// line is the context line during an import.359+func (x *transfer) line(th *theme.Theme) string {360+ return "Importing " + filepath.Base(x.job.name) + "… " + th.KeyHints("Esc", "cancel")381361 }382362 383-// importStatus is the status line during an import: the file, the rows384-// read, and a bar when the total is known.385-func (m *Model) importStatus() string {386- job := m.xfer.job387- rows, frac := job.prog.Get()388- left := m.th.Key.Render("Importing " + filepath.Base(job.name))389- right := countRows(rows) + " read"390- if frac >= 0 {391- pct := fmt.Sprintf(" %3d%%", int(frac*100))392- w := clamp(m.width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)393- if w >= 8 {394- done := int(frac * float64(w))395- right += " " + m.th.Progress.Render(strings.Repeat("━", done)) +396- m.th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct397- } else {398- right += pct399- }363+// status is the status line during an import, width wide: the file, the364+// rows read, and a bar when the total is known.365+func (x *transfer) status(th *theme.Theme, width int) (left, right string) {366+ rows, frac := x.job.prog.Get()367+ left = th.Key.Render("Importing " + filepath.Base(x.job.name))368+ right = countRows(rows) + " read"369+ if frac < 0 {370+ return left, right371+ }372+ pct := fmt.Sprintf(" %3d%%", int(frac*100))373+ w := clamp(width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)374+ if w < 8 {375+ return left, right + pct400376 }401- return m.spread(left, right)377+ done := int(frac * float64(w))378+ return left, right + " " + th.Progress.Render(strings.Repeat("━", done)) +379+ th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct402380 }403381 404382 // progressBar is the terminal's own progress indicator (OSC 9;4), shown405383 // in the tab or title bar where supported.406-func (m *Model) progressBar() *tea.ProgressBar {407- if m.xfer.job == nil {384+func (x *transfer) progressBar() *tea.ProgressBar {385+ if x.job == nil {408386 return nil409387 }410- if _, frac := m.xfer.job.prog.Get(); frac >= 0 {388+ if _, frac := x.job.prog.Get(); frac >= 0 {411389 return tea.NewProgressBar(tea.ProgressBarDefault, int(frac*100))412390 }413391 return tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)414392 }415-416-// displayBase is the name downloads and saves start from: the sheet's417-// file or the imported file, without its extension.418-func (m *Model) displayBase() string {419- name := m.filename420- if name == "" {421- name = m.xfer.source422- }423- if name == "" {424- return "SHEET1"425- }426- return strings.TrimSuffix(name, filepath.Ext(name))427-}428-429-// openDownload asks where to download the sheet as k.430-func (m *Model) openDownload(k fileio.Kind) tea.Cmd {431- if _, ok := m.sheet.UsedRange(); !ok {432- m.note = "Nothing to download: the sheet is empty"433- return nil434- }435- r := sheet.Rect{}436- label := "Download as " + k.String() + ":"437- if k == fileio.SQLite && m.hasRange() {438- r = m.selection()439- label = "Download " + r.String() + " as SQLite:"440- }441- m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {442- if text == "" {443- return nil444- }445- name := text446- if filepath.Ext(name) == "" {447- name += k.Ext()448- }449- if k == fileio.SQLite {450- m.openTableName(name, r)451- return nil452- }453- return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {454- return m.download(name, k, r, "")455- }, exists(name))456- })457- return nil458-}459-460-func exists(name string) bool {461- _, err := os.Stat(name)462- return err == nil463-}464-465-// openTableName asks for the table to write in a SQLite database.466-func (m *Model) openTableName(name string, r sheet.Rect) {467- m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {468- if table == "" {469- return nil470- }471- has := false472- if exists(name) {473- ts, err := fileio.Tables(context.Background(), name)474- if err != nil {475- m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))476- return nil477- }478- for _, t := range ts {479- has = has || strings.EqualFold(t.Name, table)480- }481- }482- return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {483- return m.download(name, fileio.SQLite, r, table)484- }, has)485- })486-}487-488-// confirmReplace runs do, first asking on the context line when it489-// would replace something.490-func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {491- if !replaces {492- return do(m)493- }494- m.openOverlay(&choiceBar{495- msg: msg,496- warn: true,497- choices: []choice{498- {key: "enter", label: "Replace", run: do},499- {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},500- },501- })502- return nil503-}504-505-// download snapshots the sheet and writes it in the background.506-func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {507- snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))508- if k == fileio.XLSX && r == (sheet.Rect{}) {509- snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download510- }511- return func() tea.Msg {512- res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})513- return exportedMsg{name: name, kind: k, res: res, err: err}514- }515-}516-517-// saveImported is Save for a sheet imported from another format: save it518-// as a .012 file (keeping formulas and formatting), or export it back.519-func (m *Model) saveImported() tea.Cmd {520- k := m.xfer.kind521- choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}522- if k.CanExport() {523- choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})524- }525- choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {526- m.quitAfterSave = false527- return nil528- }})529- m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})530- return nil531-}