cat / 012
clone

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

A crash in 012 or 012 serve keeps unsaved work and reports where

Dami committed 14bbf83
Local sessions keep it in the config directory's recovery/ and write
a report to crashes/, printing both paths once the terminal is back;
012 serve keeps it in .012-recovery/ and logs the report. e2e builds
with crashtest and checks each kind of panic through libghostty.
commit
14bbf83393bc7cb82df77b515d5b63be7070c19d
parent
727d24fee1d12f66088b79ae342c9350dab42c33
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.8 of 3, Jev's estimate
10 files changed, +241 -21
Acmd/012/crash.go78++++++++++++++++++++++++++++++
Mcmd/012/main.go11++++-
Mdocs/files/saving.md32++++++++++++
Mdocs/terminal/ssh.md5+-
Ae2e/crash_test.go61+++++++++++++++++++++++
Me2e/harness_test.go5+-
Me2e/keys_test.go2+-
Minternal/serve/open.go26++++++----
Minternal/serve/open_test.go28+++++++++++
Minternal/serve/serve.go14++++--
A cmd/012/crash.go +78 -0
@@ -0,0 +1,78 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "path/filepath"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/config"11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/ui"13+)14+15+// When the UI stops on an internal error (a panic, which ui.Guard16+// catches), the terminal is already restored; what's left is to keep17+// the unsaved work where the next start on that file offers it back,18+// write a report, and say where both are. See docs/files/saving.md.19+20+// recoveryDir is where local sessions keep unsaved work after a crash,21+// <config.Dir>/recovery; "" when there's no config directory.22+func recoveryDir() string {23+ d, err := config.Dir()24+ if err != nil {25+ return ""26+ }27+ return filepath.Join(d, "recovery")28+}29+30+// crashDir is where crash reports go, <config.Dir>/crashes.31+func crashDir() string {32+ d, err := config.Dir()33+ if err != nil {34+ return ""35+ }36+ return filepath.Join(d, "crashes")37+}38+39+// offersKept reports whether a session started on args, reading40+// standard input or not, offers the changes a crash kept: when it opens41+// a .012 file or nothing, not when it imports a file or reads a table.42+func offersKept(args []string, stdin bool) bool {43+ if stdin {44+ return false45+ }46+ if len(args) == 1 {47+ if _, imports := fileio.KindOf(args[0]); imports {48+ return false49+ }50+ }51+ return true52+}53+54+// crashed keeps the unsaved work of the program g ran, writes the55+// report, and returns the error that tells the user where both are.56+func crashed(g *ui.Guarded, c *ui.Crash) error {57+ now := time.Now()58+ m := g.Model()59+ kept, keepErr := g.Keep(now)60+ lines := []string{fmt.Sprintf("stopped on an internal error (a panic in %s: %v)", c.Where, c.Value)}61+ switch {62+ case keepErr != nil:63+ lines = append(lines, "couldn't keep the unsaved changes: "+keepErr.Error())64+ case kept != "" && m.Filename() == "":65+ lines = append(lines, "unsaved changes kept in "+kept+"; start 012 again to restore them")66+ case kept != "":67+ lines = append(lines, "unsaved changes kept in "+kept+"; open "+m.Filename()+" again to restore them")68+ }69+ if dir := crashDir(); dir != "" {70+ report, err := c.Report(dir, buildVersion(), m.Filename(), kept, keepErr, now)71+ if err != nil {72+ lines = append(lines, "couldn't write a report: "+err.Error())73+ } else {74+ lines = append(lines, "a report is in "+report+"; include it when reporting the problem")75+ }76+ }77+ return errors.New(strings.Join(lines, "\n012: "))78+}
M cmd/012/main.go +10 -1
M docs/files/saving.md +32 -0
@@ -17,3 +17,35 @@ Under `012 serve`, a session that idles out or is ended by the server
1717 stopping keeps its unsaved changes in `.012-recovery/` in the served1818 directory, and the next session opening that file offers them back: see1919 [Serving over SSH](../terminal/ssh.md#unsaved-work).20+21+## If 012 crashes22+23+An internal error (a bug, a Go panic) stops 012 rather than leaving24+the terminal broken: the screen goes back to what the shell showed,25+and 012 says what happened and where it put things:26+27+```28+012: stopped on an internal error (a panic in update: ...)29+012: unsaved changes kept in ~/.config/012/recovery/%2FUsers%2Fann%2Fbudget-20260929-101112.012; open budget.012 again to restore them30+012: a report is in ~/.config/012/crashes/crash-20260929-101112.txt; include it when reporting the problem31+```32+33+- **Unsaved changes** are written, the whole workbook, to `recovery/`34+ in 012's config directory (the one `012 config path` prints the35+ [config file](../reference/config.md) of, `~/.config/012` on Linux),36+ named after the file's full path. Opening that37+ file again, or starting `012` on nothing for a sheet never saved,38+ asks on the context line: Enter restores them as unsaved changes to39+ the file, D deletes them, Esc leaves them for next time. Saving a40+ restored workbook removes its recovery file. Nothing is kept without41+ unsaved changes, and only the newest three files per name.42+- **The report** goes to `crashes/` beside it: where the error43+ happened, the error, its stack of calls, 012's version, the system,44+ the file's name and where its changes went, never what the workbook45+ holds. The newest ten are kept.46+47+If the error happened while drawing the screen, the terminal also48+shows the stack of calls above those lines. Under `012 serve`, the49+session's changes go to `.012-recovery/` as above, the client is told50+that 012 stopped on an internal error, and the report goes to the51+server's log.
M docs/terminal/ssh.md +3 -2
A e2e/crash_test.go +61 -0
@@ -0,0 +1,61 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ ghostty "go.mitchellh.com/libghostty"10+)11+12+// A panic stops 012 with the terminal restored (the primary screen, the13+// cursor shown), the unsaved work kept, and a report written, their14+// paths printed. Opening the file again offers the work back. The15+// binary is built with -tags crashtest, where F12 panics where16+// O12_CRASH_TEST says (internal/ui/crashtest.go).17+func TestPanicKeepsWork(t *testing.T) {18+ for _, where := range []string{"update", "command", "view"} {19+ t.Run(where, func(t *testing.T) {20+ dir := t.TempDir()21+ o := options{dir: dir, env: []string{"O12_CRASH_TEST=" + where}}22+ s := startWith(t, o, "budget.012")23+ s.keys("kept", "<enter>")24+ s.waitFor("kept")25+ s.keys("<f12>")26+ s.waitExit()27+ if scr := s.activeScreen(); scr != ghostty.ScreenPrimary {28+ t.Errorf("active screen after the crash = %v, want primary", scr)29+ }30+ s.mu.Lock()31+ visible, _ := s.vt.CursorVisible()32+ s.mu.Unlock()33+ if !visible {34+ t.Error("the cursor is hidden after the crash")35+ }36+ text := strings.ReplaceAll(s.screen(), "\n", "")37+ recovery, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "recovery", "*budget-*.012"))38+ reports, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "crashes", "crash-*.txt"))39+ if len(recovery) != 1 || len(reports) != 1 {40+ t.Fatalf("recovery files %v, reports %v; screen:\n%s", recovery, reports, s.screen())41+ }42+ for _, want := range []string{"012: stopped on an internal error (a panic in " + strings.Replace(where, "command", "a command", 1), "unsaved changes kept in " + recovery[0] + "; open budget.012 again to restore them",43+ "a report is in " + reports[0]} {44+ if !strings.Contains(text, want) {45+ t.Errorf("the screen lacks %q:\n%s", want, s.screen())46+ }47+ }48+ report, _ := os.ReadFile(reports[0])49+ if !strings.Contains(string(report), "Panic: crash test in ") {50+ t.Errorf("report:\n%s", report)51+ }52+53+ r := startWith(t, options{dir: dir, startsOn: "Unsaved changes to budget.012 were kept."}, "budget.012")54+ r.keys("<enter>")55+ r.waitFor("Restored the kept changes")56+ if !strings.Contains(r.line(gridRow1), "kept") {57+ t.Errorf("A1 after restoring: %q", r.line(gridRow1))58+ }59+ })60+ }61+}
M e2e/harness_test.go +3 -2
M e2e/keys_test.go +1 -1
M internal/serve/open.go +15 -11
@@ -49,21 +49,23 @@ func (s *Server) fileArg(sess ssh.Session) (string, error) {
4949 5050 // ending is how a session ended, and where its unsaved work was kept.5151 type ending struct {52- idle, stopped bool // it idled out; the server stopped53- kept string // the recovery file, relative to the served directory54- keptFor string // the file name it was kept for, "" for a book never saved55- err error // keeping it failed52+ idle, stopped bool // it idled out; the server stopped53+ crash *ui.Crash // it stopped on an internal error54+ kept string // the recovery file, relative to the served directory55+ keptFor string // the file name it was kept for, "" for a book never saved56+ err error // keeping it failed5657 }5758 58-// keep writes m's unsaved work to a recovery file when the session ended59-// by idling out or by the server stopping; when the user quits, the app60-// has already asked about unsaved changes.61-func (e *ending) keep(m *ui.Model) {62- if !e.idle && !e.stopped || !m.Unsaved() {59+// keep writes the unsaved work of the program g ran to a recovery file60+// when the session ended by idling out, by the server stopping or on an61+// internal error; when the user quits, the app has already asked about62+// unsaved changes.63+func (e *ending) keep(g *ui.Guarded) {64+ if !e.idle && !e.stopped && e.crash == nil {6365 return6466 }65- e.keptFor = m.Filename()66- e.kept, e.err = m.Recover(time.Now())67+ e.keptFor = g.Model().Filename()68+ e.kept, e.err = g.Keep(time.Now())6769 }6870 6971 // tell says on the client's terminal why the session ended and where its
@@ -74,6 +76,8 @@ func (e ending) tell(w io.Writer, idle time.Duration, root confine.Root) {
7476 fmt.Fprint(w, "012: the server is stopping\r\n")7577 case e.idle:7678 fmt.Fprintf(w, "012: closed after %v without input\r\n", idle)79+ case e.crash != nil:80+ fmt.Fprint(w, "012: stopped on an internal error; the server's log has a report\r\n")7781 }7882 switch {7983 case e.err != nil:
M internal/serve/open_test.go +28 -0
M internal/serve/serve.go +10 -4