cat / 012
clone

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

main
012 / internal / ui / file.go
file.go 10.8K
1package ui23import (4 "fmt"5 "io"6 "io/fs"7 "log/slog"8 "os"9 "path/filepath"10 "slices"11 "strings"12 "time"1314 tea "charm.land/bubbletea/v2"1516 "src.rest/cat/012/internal/confine"17 "src.rest/cat/012/internal/fileio"18 "src.rest/cat/012/internal/sheet"19 "src.rest/cat/012/internal/telemetry"20)2122// Saving and opening .012 files. Files are written and read in the23// background; other formats go through transfer.go.24//25// Names are kept as the user typed them and turned into paths on disk26// only to read or write (Model.path), so a session served over SSH stays27// inside its directory.2829// Serve prepares m for a session served over SSH: file names resolve30// inside root, and env (KEY=value pairs) is the client's environment,31// used instead of the server's to learn about the client's terminal.32// Notebooks' commands run only when the server's serve-shell allows.33func (m *Model) Serve(root confine.Root, env []string) {34 m.root = root35 m.term = newTerminal(envLookup(env))36 m.nb.served = true37}3839// envLookup reads a variable from a list of KEY=value pairs, the last40// one winning as in os/exec.41func envLookup(env []string) func(string) string {42 return func(key string) string {43 v := ""44 for _, kv := range env {45 if k, val, ok := strings.Cut(kv, "="); ok && k == key {46 v = val47 }48 }49 return v50 }51}5253// path is where name is on disk. It fails for a name outside the served54// directory, saying so with verb ("open", "save") in the note.55func (m *Model) path(verb, name string) (string, bool) {56 p, err := m.root.Resolve(name)57 if err != nil {58 m.fail(fmt.Sprintf("Couldn't %s %s: %v", verb, name, err))59 return "", false60 }61 return p, true62}6364func (m *Model) openSave() tea.Cmd {65 name := m.filename66 if name == "" {67 name = m.displayBase() + sheet.FileExt68 }69 m.openText("Save as:", name, (*Model).saveAsFile)70 return nil71}7273// saveAsFile saves the sheet under a name typed for Save as or :w,74// asking first when that's another file that exists.75func (m *Model) saveAsFile(text string) tea.Cmd {76 name := withExt(text)77 p, ok := m.path("save", name)78 if !ok {79 m.quitAfterSave = false80 return nil81 }82 return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {83 return m.saveAs(name, true)84 }, !m.isOpenFile(p) && exists(p))85}8687// isOpenFile reports whether path on disk is the open file's, however88// its name was typed. The open file has its own check, for changes89// since it was opened (saveAs).90func (m *Model) isOpenFile(path string) bool {91 if m.filename == "" {92 return false93 }94 open, err := m.root.Resolve(m.filename)95 if err != nil {96 return false97 }98 a, err1 := filepath.Abs(open)99 b, err2 := filepath.Abs(path)100 return err1 == nil && err2 == nil && a == b101}102103func (m *Model) openRetrieve() tea.Cmd {104 m.openText("Open file:", "", (*Model).openFile)105 return listFilesCmd(m.root)106}107108// openFile opens a sheet, or imports a file of another format, named for109// Open or :e.110func (m *Model) openFile(text string) tea.Cmd {111 if _, ok := fileio.KindOf(text); ok {112 return m.confirmImport(text, fileio.Options{})113 }114 name := withExt(text)115 p, ok := m.path("open", name)116 if !ok {117 return nil118 }119 if m.share.reg != nil {120 return m.openShared(name, p)121 }122 return loadCmd(name, p, m.spans.Parent())123}124125// reset starts over on s, as File > New and Open do. What belongs to the126// session rather than the sheet carries over: the window, theme, terminal127// state, served directory and the JEV connection.128func (m *Model) reset(s *sheet.Sheet, filename string) {129 if !m.shared() {130 m.stopCells() // the old workbook's cells, unless others share them131 }132 m.closeSources()133 nb := nbState{runner: m.nb.runner, runs: &nbRuns{}, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}134 defer func() { m.nb = nb; m.bookOpened() }()135 *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, share: m.share, agents: m.agents,136 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}137 s.Book().SetTrace(m.spans)138 if m.jev != nil {139 s.Book().SetRemote(m.jev.cache)140 }141}142143func withExt(name string) string {144 if filepath.Ext(name) == "" {145 return name + sheet.FileExt146 }147 return name148}149150// stamp tells versions of a file on disk apart: when it was last written151// and its size. The zero stamp stands for no file.152type stamp struct {153 mod time.Time154 size int64155}156157func stampOf(st fs.FileInfo) stamp { return stamp{st.ModTime(), st.Size()} }158159// diskStamp is the stamp of the file at path now, zero if there's none.160func diskStamp(path string) stamp {161 st, err := os.Stat(path)162 if err != nil {163 return stamp{}164 }165 return stampOf(st)166}167168func (s stamp) equal(o stamp) bool { return s.mod.Equal(o.mod) && s.size == o.size }169170type savedMsg struct {171 name string172 err error173 stamp stamp // the file as written174 conflict bool // not written: the file changed on disk since it was opened175}176177type loadedMsg struct {178 name string179 sheet *sheet.Sheet180 stamp stamp181 err error182}183184type filesMsg []string185186// saveAs saves the sheet as name. When name is the open file and check187// is set, it first makes sure nobody else wrote the file since it was188// opened or last saved here: another program, or another session of a189// served directory.190func (m *Model) saveAs(name string, check bool) tea.Cmd {191 p, ok := m.path("save", name)192 if !ok {193 m.quitAfterSave = false194 return nil195 }196 expect := (*stamp)(nil)197 if check && name == m.filename {198 expect = &m.disk199 }200 // Linked files are saved relative to the new file's folder, and stay201 // relative to this one's until the save is done.202 m.rebaseLinks(m.filename, name)203 m.book().SetOutputCaps(m.outputCaps())204 m.nb.saving = m.book().OutputsChanged()205 cmd := saveCmd(m.sheet, name, p, expect, m.spans)206 m.rebaseLinks(name, m.filename)207 return cmd208}209210// saveCmd writes the worksheet to path atomically: to a temporary file211// beside it first, then renamed over the target. The file is written212// here, as the workbook stands, streaming (see Workbook.Write), so a213// large workbook isn't held twice in memory; the rename happens in the214// background. With expect set, it renames only if the file on disk is215// still that version.216func saveCmd(s *sheet.Sheet, name, path string, expect *stamp, spans *telemetry.Trace) tea.Cmd {217 span := spans.Start("save")218 tmp, n, err := writeTemp(path, s.Write)219 if telemetry.Enabled() {220 span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", n))221 }222 return func() tea.Msg {223 if err != nil {224 return savedMsg{name: name, err: err}225 }226 // A file deleted meanwhile is simply written again.227 if now := diskStamp(path); expect != nil && now != (stamp{}) && !now.equal(*expect) {228 os.Remove(tmp)229 return savedMsg{name: name, conflict: true}230 }231 if err := os.Rename(tmp, path); err != nil {232 os.Remove(tmp)233 return savedMsg{name: name, err: err}234 }235 return savedMsg{name: name, stamp: diskStamp(path)}236 }237}238239// writeTemp writes a file with write into a temporary file of its own240// beside path, so two writers never share one, and returns its name and241// size. On an error it removes the file.242func writeTemp(path string, write func(io.Writer) error) (string, int64, error) {243 f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")244 if err != nil {245 return "", 0, err246 }247 tmp := f.Name()248 cw := &countWriter{w: f}249 err = write(cw)250 if err == nil {251 err = f.Chmod(0o644)252 }253 if cerr := f.Close(); err == nil {254 err = cerr255 }256 if err != nil {257 os.Remove(tmp)258 }259 return tmp, cw.n, err260}261262// countWriter counts the bytes written through it.263type countWriter struct {264 w io.Writer265 n int64266}267268func (c *countWriter) Write(p []byte) (int, error) {269 n, err := c.w.Write(p)270 c.n += int64(n)271 return n, err272}273274// loadCmd reads the file in the background, the span of it and its275// recalculation under parent.276func loadCmd(name, path string, parent telemetry.Parent) tea.Cmd {277 return func() tea.Msg {278 f, err := os.Open(path)279 if err != nil {280 return loadedMsg{name: name, err: err}281 }282 defer f.Close()283 var st stamp284 if fi, err := f.Stat(); err == nil {285 st = stampOf(fi)286 }287 span := parent.Start("open")288 s, err := sheet.ReadTraced(f, telemetry.NewTrace(span.Parent()))289 if err != nil {290 span.Fail(err)291 } else if telemetry.Enabled() {292 span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", st.size))293 }294 return loadedMsg{name, s, st, err}295 }296}297298// listFilesCmd lists the .012 files in the directory names are relative299// to.300func listFilesCmd(root confine.Root) tea.Cmd {301 return func() tea.Msg {302 dir, err := root.Resolve(".")303 if err != nil {304 return filesMsg(nil)305 }306 files, _ := filepath.Glob(filepath.Join(globEscape(dir), "*"+sheet.FileExt))307 for i, f := range files {308 files[i] = filepath.Base(f)309 }310 files = slices.DeleteFunc(files, func(f string) bool { return strings.HasPrefix(f, ".") })311 slices.Sort(files)312 return filesMsg(files)313 }314}315316// globEscape escapes the pattern characters in a directory name.317func globEscape(dir string) string {318 if dir == "." || !strings.ContainsAny(dir, `*?[\`) {319 return dir320 }321 var b strings.Builder322 for _, r := range dir {323 if strings.ContainsRune(`*?[\`, r) {324 b.WriteByte('\\')325 }326 b.WriteRune(r)327 }328 return b.String()329}330331func (m *Model) handleSaved(msg savedMsg) tea.Cmd {332 if msg.conflict {333 m.ask(question{334 msg: filepath.Base(msg.name) + " changed on disk since it was opened.",335 warn: true,336 choices: []choice{337 {key: "enter", label: "Overwrite", run: func(m *Model) tea.Cmd { return m.saveAs(msg.name, false) }},338 {key: "s", label: "Save as", run: (*Model).openSave},339 {key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {340 m.quitAfterSave = false341 return nil342 }},343 },344 })345 return nil346 }347 quit := m.quitAfterSave348 m.quitAfterSave = false349 if msg.err != nil {350 m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))351 return nil352 }353 m.rebaseLinks(m.filename, msg.name)354 m.filename, m.disk = msg.name, msg.stamp355 m.changed = false356 m.nb.saved = m.nb.saving357 if seat := m.share.seat; seat != nil {358 v := m.savedState()359 v.Outputs = m.nb.saved360 seat.SetSaved(v)361 }362 m.savedRecovered()363 if left := m.book().UnsavedOutputs(); len(left) > 0 {364 m.note = "Saved without the outputs of " + strings.Join(left, ", ") + " (over nu-save-cell-kb or nu-save-notebook-kb): run them to see them again"365 }366 if quit {367 return m.exit()368 }369 return nil370}371372func (m *Model) handleLoaded(msg loadedMsg) {373 if msg.err != nil {374 m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))375 return376 }377 if m.share.reg != nil {378 m.loadedShared(msg)379 return380 }381 m.reset(msg.sheet, msg.name)382 m.disk = msg.stamp383 m.offerRecovery(msg.name)384}385386// save writes to the current file, asking for a name the first time.387func (m *Model) save() tea.Cmd {388 if m.filename == "" && m.xfer.Source != "" {389 return m.saveImported()390 }391 if m.filename == "" {392 return m.openSave()393 }394 return m.saveAs(m.filename, true)395}