From 04b4243ad31d144c4caf9be4c5096a0f27a2648e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 00:23:51 +0200 Subject: gui: syntax colours, a file preview, and a settings window --- gui/internal/model/highlight.go | 97 ++++++++++++++++++ gui/internal/model/highlight_test.go | 83 ++++++++++++++++ gui/internal/model/mainconf.go | 184 +++++++++++++++++++++++++++++++++++ gui/internal/model/mainconf_test.go | 169 ++++++++++++++++++++++++++++++++ gui/internal/model/prefs.go | 62 ++++++++++++ gui/internal/model/prefs_test.go | 59 +++++++++++ gui/internal/model/preview.go | 173 ++++++++++++++++++++++++++++++++ gui/internal/model/preview_test.go | 98 +++++++++++++++++++ 8 files changed, 925 insertions(+) create mode 100644 gui/internal/model/highlight.go create mode 100644 gui/internal/model/highlight_test.go create mode 100644 gui/internal/model/mainconf.go create mode 100644 gui/internal/model/mainconf_test.go create mode 100644 gui/internal/model/prefs.go create mode 100644 gui/internal/model/prefs_test.go create mode 100644 gui/internal/model/preview.go create mode 100644 gui/internal/model/preview_test.go (limited to 'gui/internal/model') diff --git a/gui/internal/model/highlight.go b/gui/internal/model/highlight.go new file mode 100644 index 0000000..e34d9d9 --- /dev/null +++ b/gui/internal/model/highlight.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +// SpanKind is what a stretch of a configuration file is, for colouring it. +type SpanKind int + +const ( + SpanComment SpanKind = iota + SpanString + SpanHead // the symbol a form starts with + SpanAction // a head that does something to a file + SpanParen +) + +// Span is one stretch of text to colour, in character offsets - what a +// GtkTextBuffer counts in, not bytes. +type Span struct { + From, To int + Kind SpanKind +} + +// actionHeads are the forms that act on a file rather than describe one. +var actionHeads = map[string]bool{ + "copy": true, "move": true, "rename": true, "delete": true, "stop": true, +} + +// Spans reads a configuration and says what to colour: a ";" outside a +// string comments out the rest of its line; a string runs to its closing +// quote, a backslash escaping the next character; the first symbol after +// "(" is the form's head. Nothing here knows about widgets, so the rules of +// the little language stay testable (his request, 2026-09-16). +func Spans(text string) []Span { + var out []Span + runes := []rune(text) + afterParen := false + for i := 0; i < len(runes); i++ { + switch c := runes[i]; { + case c == ';': + j := i + for j < len(runes) && runes[j] != '\n' { + j++ + } + out = append(out, Span{i, j, SpanComment}) + i = j + afterParen = false + case c == '"': + j := i + 1 + for j < len(runes) { + if runes[j] == '\\' { + j += 2 + continue + } + if runes[j] == '"' { + j++ + break + } + j++ + } + if j > len(runes) { + j = len(runes) + } + out = append(out, Span{i, j, SpanString}) + i = j - 1 + afterParen = false + case c == '(' || c == ')': + out = append(out, Span{i, i + 1, SpanParen}) + afterParen = c == '(' + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + // whitespace between "(" and the head is allowed + default: + j := i + for j < len(runes) && !isDelimiter(runes[j]) { + j++ + } + if afterParen { + kind := SpanHead + if actionHeads[string(runes[i:j])] { + kind = SpanAction + } + out = append(out, Span{i, j, kind}) + } + i = j - 1 + afterParen = false + } + } + return out +} + +// isDelimiter reports whether a character ends a symbol. +func isDelimiter(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '(', ')', '"', ';': + return true + } + return false +} diff --git a/gui/internal/model/highlight_test.go b/gui/internal/model/highlight_test.go new file mode 100644 index 0000000..f5c1e28 --- /dev/null +++ b/gui/internal/model/highlight_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import "testing" + +// textOf is the stretch a span covers, by character offset. +func textOf(text string, s Span) string { + return string([]rune(text)[s.From:s.To]) +} + +// find is the first span of a kind whose text matches, for the assertions +// below. +func find(t *testing.T, text string, kind SpanKind, want string) { + t.Helper() + for _, s := range Spans(text) { + if s.Kind == kind && textOf(text, s) == want { + return + } + } + t.Errorf("no %v span %q in %q", kind, want, text) +} + +// TestSpansPaintsTheParts: heads, actions, strings and comments each come +// back as their own kind. +func TestSpansPaintsTheParts(t *testing.T) { + text := "(rule \"invoices\" ; the ones from suppliers\n (when (type pdf))\n (move \"Docs\"))\n" + find(t, text, SpanHead, "rule") + find(t, text, SpanHead, "when") + find(t, text, SpanHead, "type") + find(t, text, SpanAction, "move") + find(t, text, SpanString, `"invoices"`) + find(t, text, SpanString, `"Docs"`) + find(t, text, SpanComment, "; the ones from suppliers") +} + +// TestSpansKeepsQuotingStraight: a semicolon inside a string is not a +// comment, a quote inside a comment does not start a string, and an escaped +// quote does not end one. +func TestSpansKeepsQuotingStraight(t *testing.T) { + text := `(rule "a;b" (when (name "say \"hi\"")) (move "Out"))` + "\n;; \"not a string\"\n" + find(t, text, SpanString, `"a;b"`) + find(t, text, SpanString, `"say \"hi\""`) + find(t, text, SpanComment, `;; "not a string"`) + for _, s := range Spans(text) { + if s.Kind == SpanComment && textOf(text, s) == ";b\" (when (name \"say \\\"hi\\\"\")) (move \"Out\"))" { + t.Error("a semicolon inside a string started a comment") + } + } +} + +// TestSpansOnlyHeadsAreHeads: an argument that happens to be a symbol is +// not painted as a head. +func TestSpansOnlyHeadsAreHeads(t *testing.T) { + text := "(when (type pdf doc))\n" + for _, s := range Spans(text) { + if s.Kind == SpanHead && (textOf(text, s) == "pdf" || textOf(text, s) == "doc") { + t.Errorf("argument %q painted as a head", textOf(text, s)) + } + } + find(t, text, SpanHead, "type") +} + +// TestSpansCountsCharactersNotBytes: offsets are what a text buffer counts, +// so a file with Polish letters in it still colours the right stretch. +func TestSpansCountsCharactersNotBytes(t *testing.T) { + text := "(rule \"spółka\" (move \"Księgowość\"))\n" + find(t, text, SpanString, `"spółka"`) + find(t, text, SpanString, `"Księgowość"`) + find(t, text, SpanAction, "move") +} + +// TestSpansSurvivesUnclosedForms: text being typed is not yet valid, and +// the colouring must not run off the end of it. +func TestSpansSurvivesUnclosedForms(t *testing.T) { + for _, text := range []string{`(rule "half`, "(when (name ", ";; just a comment", `"`} { + for _, s := range Spans(text) { + if s.From < 0 || s.To > len([]rune(text)) || s.From > s.To { + t.Errorf("%q: span %+v is outside the text", text, s) + } + } + } +} diff --git a/gui/internal/model/mainconf.go b/gui/internal/model/mainconf.go new file mode 100644 index 0000000..327451e --- /dev/null +++ b/gui/internal/model/mainconf.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "crypto/sha256" + "fmt" + "os" + "strings" + + "krino/internal/config" + "krino/internal/engine" + "krino/internal/sexp" +) + +// MainSettings are the settings krino.conf carries for every directory, +// inside its (defaults ...) form, in the order the window shows them. +var MainSettings = []string{ + "case", "fold", "recursive", "min-age", "max-read", "max-size", + "busy", "on-conflict", +} + +// MainConf is krino.conf open for editing. Only its (defaults ...) block +// and the log path are edited here - the includes and the excludes are the +// file's own business, and the Text tab is where those are written (GUI +// design §5). +type MainConf struct { + File string + Text string + + e *engine.Engine + saved string + stamp [32]byte +} + +// OpenMain reads krino.conf. +func OpenMain(e *engine.Engine) (*MainConf, error) { + text, err := os.ReadFile(e.MainFile) + if err != nil { + return nil, err + } + return &MainConf{File: e.MainFile, Text: string(text), e: e, + saved: string(text), stamp: sha256.Sum256(text)}, nil +} + +// Modified reports whether the text differs from what is on disk. +func (m *MainConf) Modified() bool { return m.Text != m.saved } + +// Check loads the whole configuration with this text in place of the file +// and reports what is wrong with it. +func (m *MainConf) Check() []*config.Diag { + over := map[string][]byte{m.File: []byte(m.Text)} + _, diags := engine.LoadWith(m.File, over) + return diags +} + +// Setting reads one default as it is written: "log" from the top level, +// everything else from inside (defaults ...). +func (m *MainConf) Setting(head string) (args string, set bool, err error) { + if head != "log" && !isMainSetting(head) { + return "", false, fmt.Errorf("model: %s is not a setting of krino.conf", head) + } + node, err := m.node(head) + if err != nil || node == nil { + return "", false, err + } + return argsOf(node, m.Text), true, nil +} + +// SetSetting writes one default, or takes it out when args is empty. A +// setting that is not in the file yet is written into (defaults ...), which +// is created if the file has none. +func (m *MainConf) SetSetting(head, args string) error { + if head != "log" && !isMainSetting(head) { + return fmt.Errorf("model: %s is not a setting of krino.conf", head) + } + args = strings.TrimSpace(args) + node, err := m.node(head) + if err != nil { + return err + } + switch { + case node != nil && args == "": + start, end := lineStart(m.Text, node.Pos.Offset), lineEnd(m.Text, node.End.Offset) + m.Text = join(m.Text[:start], m.Text[end:]) + case node != nil: + m.Text = m.Text[:node.Pos.Offset] + "(" + head + " " + args + ")" + m.Text[node.End.Offset:] + case head == "log": + m.Text = join(m.Text, "\n(log "+args+")\n") + default: + if err := m.setInDefaults(head, args); err != nil { + return err + } + } + return nil +} + +// setInDefaults writes a setting inside (defaults ...), adding the form +// when the file has none. +func (m *MainConf) setInDefaults(head, args string) error { + defaults, err := m.topLevel("defaults") + if err != nil { + return err + } + if defaults == nil { + m.Text = join(m.Text, "\n(defaults\n ("+head+" "+args+"))\n") + return nil + } + // Just inside the closing paren, on a line of its own. + at := defaults.End.Offset - 1 + m.Text = m.Text[:at] + "\n (" + head + " " + args + ")" + m.Text[at:] + return nil +} + +// node is the form for a setting: (log ...) at the top level, the rest +// inside (defaults ...). +func (m *MainConf) node(head string) (*sexp.Node, error) { + if head == "log" { + return m.topLevel("log") + } + defaults, err := m.topLevel("defaults") + if err != nil || defaults == nil { + return nil, err + } + for _, n := range defaults.Args() { + if n.Kind == sexp.List && n.Head() == head { + return n, nil + } + } + return nil, nil +} + +// topLevel is the top-level form with that head, or nil. +func (m *MainConf) topLevel(head string) (*sexp.Node, error) { + nodes, err := sexp.Parse(m.File, []byte(m.Text)) + if err != nil { + return nil, err + } + for _, n := range nodes { + if n.Kind == sexp.List && n.Head() == head { + return n, nil + } + } + return nil, nil +} + +// Save writes krino.conf, refusing a file that will not load and one that +// changed on disk since it was opened - the rules the directory files are +// saved by (GUI design §5.3). The previous text is kept as krino.conf.bak. +func (m *MainConf) Save() error { + if diags := m.Check(); len(diags) > 0 { + return fmt.Errorf("%s", diags[0]) + } + on, err := os.ReadFile(m.File) + if err != nil { + return err + } + if sha256.Sum256(on) != m.stamp { + return ErrChangedOnDisk + } + mode := os.FileMode(0o600) + if fi, err := os.Stat(m.File); err == nil { + mode = fi.Mode().Perm() + } + if err := os.WriteFile(m.File+".bak", on, mode); err != nil { + return err + } + text := []byte(m.Text) + if err := config.Replace(m.File, text); err != nil { + return err + } + m.saved, m.stamp = m.Text, sha256.Sum256(text) + return nil +} + +// isMainSetting reports whether head is one of the defaults. +func isMainSetting(head string) bool { + for _, s := range MainSettings { + if s == head { + return true + } + } + return false +} diff --git a/gui/internal/model/mainconf_test.go b/gui/internal/model/mainconf_test.go new file mode 100644 index 0000000..bae739a --- /dev/null +++ b/gui/internal/model/mainconf_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "krino/internal/engine" +) + +// mainConf opens krino.conf over a sandbox whose main file holds text. +func mainConf(t *testing.T, main string) (*MainConf, *engine.Engine, string) { + t.Helper() + e, h := sandboxDir(t, "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n", + map[string]string{"a.pdf": "one"}) + if main != "" { + if err := os.WriteFile(e.MainFile, []byte(main), 0o644); err != nil { + t.Fatal(err) + } + reloaded, diags := engine.Load(e.MainFile) + if len(diags) > 0 { + t.Fatalf("the fixture does not load: %v", diags) + } + e = reloaded + } + m, err := OpenMain(e) + if err != nil { + t.Fatal(err) + } + return m, e, h +} + +// TestMainSettingsReadAndWrite: a default is read as written, changed in +// place, and taken out again, with everything else in the file untouched. +func TestMainSettingsReadAndWrite(t *testing.T) { + main := ";; the main file\n(include \"dl\")\n\n(defaults\n (min-age 2m) ; leave fresh files\n (on-conflict suffix))\n" + m, _, _ := mainConf(t, main) + + if args, set, err := m.Setting("min-age"); err != nil || !set || args != "2m" { + t.Errorf("min-age = %q, %v, %v", args, set, err) + } + if _, set, _ := m.Setting("fold"); set { + t.Error("fold reads as set") + } + if err := m.SetSetting("min-age", "1d"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(min-age 1d)") || strings.Contains(m.Text, "(min-age 2m)") { + t.Errorf("min-age not rewritten:\n%s", m.Text) + } + for _, keep := range []string{";; the main file", "(include \"dl\")", "; leave fresh files", + "(on-conflict suffix)"} { + if !strings.Contains(m.Text, keep) { + t.Errorf("writing one setting lost %q:\n%s", keep, m.Text) + } + } + // A setting the file does not have goes inside (defaults ...). + if err := m.SetSetting("fold", "no"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(fold no)") { + t.Errorf("fold not written:\n%s", m.Text) + } + if strings.Index(m.Text, "(fold no)") < strings.Index(m.Text, "(defaults") { + t.Errorf("fold landed outside the defaults:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Fatalf("the file no longer loads: %v", diags) + } + // And out again. + if err := m.SetSetting("min-age", ""); err != nil { + t.Fatal(err) + } + if strings.Contains(m.Text, "min-age") { + t.Errorf("min-age still there:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestMainSettingsWithoutADefaultsForm: a file with no (defaults ...) gets +// one, and it loads. +func TestMainSettingsWithoutADefaultsForm(t *testing.T) { + m, _, _ := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("recursive", "yes"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(defaults") || !strings.Contains(m.Text, "(recursive yes)") { + t.Errorf("no defaults form was written:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestMainLogIsTopLevel: the log path is not a default; it is written at +// the top level, where krino.conf(5) puts it. +func TestMainLogIsTopLevel(t *testing.T) { + m, _, h := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("log", "\"~/tmp/krino.log\""); err != nil { + t.Fatal(err) + } + if strings.Contains(m.Text, "(defaults") { + t.Errorf("the log went into the defaults:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Fatalf("the file no longer loads: %v", diags) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + e, diags := engine.Load(filepath.Join(h, ".config", "krino", "krino.conf")) + if len(diags) > 0 { + t.Fatal(diags) + } + if !strings.HasSuffix(e.Config.LogFile(), "tmp/krino.log") { + t.Errorf("the saved log path is not in effect: %s", e.Config.LogFile()) + } +} + +// TestMainSaveGuards: krino.conf is saved by the same rules as a directory +// file - never broken, never over someone else's edit, and the previous +// text is kept. +func TestMainSaveGuards(t *testing.T) { + main := "(include \"dl\")\n" + m, _, _ := mainConf(t, main) + + m.Text = "(include \"dl\")\n(defaults (min-age nonsense))\n" + if err := m.Save(); err == nil { + t.Error("a file that will not load was saved") + } + if on, _ := os.ReadFile(m.File); string(on) != main { + t.Errorf("the refused save wrote anyway: %q", on) + } + + m.Text = "(include \"dl\")\n(defaults (min-age 1d))\n" + if err := m.Save(); err != nil { + t.Fatal(err) + } + if on, _ := os.ReadFile(m.File + ".bak"); string(on) != main { + t.Errorf("backup = %q, want the previous text", on) + } + + // Someone else edits it, and the next save refuses. + if err := os.WriteFile(m.File, []byte("(include \"dl\")\n;; theirs\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Text = "(include \"dl\")\n;; mine\n" + if err := m.Save(); !errors.Is(err, ErrChangedOnDisk) { + t.Errorf("Save = %v, want ErrChangedOnDisk", err) + } +} + +// TestMainSettingRefusesAnUnknownHead: only the settings krino.conf(5) +// documents can be written this way. +func TestMainSettingRefusesAnUnknownHead(t *testing.T) { + m, _, _ := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("nonsense", "1"); err == nil { + t.Error("an unknown setting was accepted") + } + if _, _, err := m.Setting("nonsense"); err == nil { + t.Error("an unknown setting was read") + } +} diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go new file mode 100644 index 0000000..ce9ee42 --- /dev/null +++ b/gui/internal/model/prefs.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "encoding/json" + "os" + "path/filepath" + + "krino/internal/xdg" +) + +// Prefs is how the window behaves - nothing about what krino does to +// files, which belongs in the configuration. It lives beside krino.conf as +// gui.json, a file krino itself never reads (his request, 2026-09-16). +type Prefs struct { + // FontSize is the editor's font in points; 0 keeps the theme's. + FontSize int `json:"font_size"` + // Colours paints the configuration in the Text tab. + Colours bool `json:"colours"` + // Preview shows the file behind the selected row in the Plan tab. + Preview bool `json:"preview"` + // SelectAll starts a plan's rows checked, as the terminal review does. + SelectAll bool `json:"select_all"` +} + +// DefaultPrefs is what a window does before anything is chosen. +func DefaultPrefs() Prefs { + return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true} +} + +// PrefsFile is where they are kept. +func PrefsFile() string { + return filepath.Join(xdg.ConfigHome(), "krino", "gui.json") +} + +// LoadPrefs reads them. A missing or unreadable file is not an error: the +// window opens with the defaults, as it does the first time. +func LoadPrefs() Prefs { + p := DefaultPrefs() + data, err := os.ReadFile(PrefsFile()) + if err != nil { + return p + } + if err := json.Unmarshal(data, &p); err != nil { + return DefaultPrefs() + } + return p +} + +// Save writes them, creating the directory if it is not there yet. +func (p Prefs) Save() error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + file := PrefsFile() + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return err + } + return os.WriteFile(file, append(data, '\n'), 0o644) +} diff --git a/gui/internal/model/prefs_test.go b/gui/internal/model/prefs_test.go new file mode 100644 index 0000000..6c01dee --- /dev/null +++ b/gui/internal/model/prefs_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// sandboxHome points HOME and every XDG_* at a temporary directory, as the +// other tests here do. +func sandboxHome(t *testing.T) string { + t.Helper() + h := t.TempDir() + t.Setenv("HOME", h) + for _, v := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { + t.Setenv(v, "") + } + return h +} + +// TestPrefsRoundTrip: what is saved comes back, and the file lands beside +// krino.conf. +func TestPrefsRoundTrip(t *testing.T) { + h := sandboxHome(t) + p := DefaultPrefs() + p.FontSize = 13 + p.Colours = false + p.SelectAll = false + if err := p.Save(); err != nil { + t.Fatal(err) + } + if got := LoadPrefs(); got != p { + t.Errorf("loaded %+v, want %+v", got, p) + } + want := filepath.Join(h, ".config", "krino", "gui.json") + if _, err := os.Stat(want); err != nil { + t.Errorf("not written to %s: %v", want, err) + } +} + +// TestPrefsWithoutAFile: the first run has no file, and damaged text is not +// worth refusing to open a window over. +func TestPrefsWithoutAFile(t *testing.T) { + sandboxHome(t) + if got := LoadPrefs(); got != DefaultPrefs() { + t.Errorf("with no file: %+v, want the defaults", got) + } + if err := os.MkdirAll(filepath.Dir(PrefsFile()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PrefsFile(), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if got := LoadPrefs(); got != DefaultPrefs() { + t.Errorf("with damaged text: %+v, want the defaults", got) + } +} diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go new file mode 100644 index 0000000..6ca029e --- /dev/null +++ b/gui/internal/model/preview.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode/utf8" + + "krino/internal/cond" +) + +// PreviewKind is what a preview holds. +type PreviewKind int + +const ( + PreviewNone PreviewKind = iota + PreviewImage + PreviewText +) + +// Preview is what to show of a file beside its explanation: a picture, some +// of its text, or nothing with the reason (his request, 2026-09-16). +type Preview struct { + Kind PreviewKind + Image string // a file to show: the file itself, or a rendered page + Text string + Note string // what this is, or why there is nothing +} + +// previewBytes is how much of a text file is read, and previewLines how +// much of it is shown: enough to recognise a document, not to read it. +const ( + previewBytes = 64 << 10 + previewLines = 200 + // imageLimit is the size above which an image is described rather than + // loaded: decoding a huge photograph would stall the window. + imageLimit = 40 << 20 +) + +// MakePreview looks at one file. tmp is a directory the caller owns, where +// a rendered PDF page is written; the caller removes it. Nothing is written +// anywhere else, and the file itself is only read. +func MakePreview(ctx context.Context, path, tmp string) Preview { + fi, err := os.Stat(path) + if err != nil { + return Preview{Note: err.Error()} + } + if fi.IsDir() { + return Preview{Note: "a directory"} + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) + switch { + case isImageExt(ext): + if fi.Size() > imageLimit { + return Preview{Note: fmt.Sprintf("image of %s, too large to show", size(fi.Size()))} + } + return Preview{Kind: PreviewImage, Image: path, Note: fmt.Sprintf("image, %s", size(fi.Size()))} + case ext == "pdf": + return pdfPreview(ctx, path, tmp, fi.Size()) + } + text, ok := textHead(path) + if !ok { + return Preview{Note: fmt.Sprintf(".%s file, %s - no preview", ext, size(fi.Size()))} + } + return Preview{Kind: PreviewText, Text: text, Note: fmt.Sprintf("first lines of %s", size(fi.Size()))} +} + +// pdfPreview renders the first page if poppler can, and falls back to the +// document's text - the same pdftotext krino's own (content ...) tests use. +func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview { + if _, err := exec.LookPath("pdftoppm"); err == nil { + out := filepath.Join(tmp, "page") + cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1", + "-scale-to", "700", "--", path, out) + if err := cmd.Run(); err == nil { + if rendered := firstMatch(out + "*.png"); rendered != "" { + return Preview{Kind: PreviewImage, Image: rendered, + Note: fmt.Sprintf("page 1, %s", size(sz))} + } + } + } + if _, err := exec.LookPath("pdftotext"); err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - install poppler-utils to see it", size(sz))} + } + cmd := exec.CommandContext(ctx, "pdftotext", "-l", "2", "--", path, "-") + text, err := cmd.Output() + if err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - pdftotext cannot read it", size(sz))} + } + return Preview{Kind: PreviewText, Text: head(string(text)), + Note: fmt.Sprintf("text of the first pages, %s", size(sz))} +} + +// firstMatch is the first file matching a glob, "" when there is none. +func firstMatch(glob string) string { + names, err := filepath.Glob(glob) + if err != nil || len(names) == 0 { + return "" + } + return names[0] +} + +// textHead reads the start of a file and reports whether it is text: no NUL +// bytes, and valid UTF-8 as far as it was read. +func textHead(path string) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + buf := make([]byte, previewBytes) + n, _ := f.Read(buf) + buf = buf[:n] + if n == 0 { + return "", false + } + if strings.IndexByte(string(buf), 0) >= 0 { + return "", false + } + // A cut multi-byte character at the end is not a reason to call a file + // binary, so the last few bytes are dropped before the check. + trimmed := buf + for len(trimmed) > 0 && !utf8.Valid(trimmed) && len(buf)-len(trimmed) < 4 { + trimmed = trimmed[:len(trimmed)-1] + } + if !utf8.Valid(trimmed) { + return "", false + } + return head(string(trimmed)), true +} + +// head is the first previewLines lines of s. +func head(s string) string { + lines := strings.SplitN(s, "\n", previewLines+1) + if len(lines) > previewLines { + lines = lines[:previewLines] + return strings.Join(lines, "\n") + "\n..." + } + return strings.Join(lines, "\n") +} + +// isImageExt reports whether an extension is one krino's (type image) would +// take, minus the formats GTK cannot draw without extra libraries. +func isImageExt(ext string) bool { + switch ext { + case "svg", "ico", "raw", "cr2", "nef", "arw", "dng", "heic", "heif", "avif": + return false + } + for _, e := range cond.Group("image") { + if e == ext { + return true + } + } + return false +} + +// size is a file's size in the units krino's own settings use. +func size(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fG", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fM", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fK", float64(n)/(1<<10)) + } + return fmt.Sprintf("%dB", n) +} diff --git a/gui/internal/model/preview_test.go b/gui/internal/model/preview_test.go new file mode 100644 index 0000000..8c19922 --- /dev/null +++ b/gui/internal/model/preview_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestPreviewKinds: a text file comes back as text, a picture as a picture, +// and something krino cannot show says so instead of guessing. +func TestPreviewKinds(t *testing.T) { + dir := t.TempDir() + write := func(name string, body []byte) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatal(err) + } + return p + } + text := write("notes.txt", []byte("first line\nsecond line\n")) + png := write("a.png", []byte("\x89PNG\r\n\x1a\nnot really, but the name is what counts here")) + binary := write("a.bin", []byte{0, 1, 2, 3, 0}) + tmp := t.TempDir() + + if p := MakePreview(context.Background(), text, tmp); p.Kind != PreviewText || + !strings.Contains(p.Text, "second line") { + t.Errorf("text preview = %+v", p) + } + if p := MakePreview(context.Background(), png, tmp); p.Kind != PreviewImage || p.Image != png { + t.Errorf("image preview = %+v", p) + } + if p := MakePreview(context.Background(), binary, tmp); p.Kind != PreviewNone || + !strings.Contains(p.Note, "no preview") { + t.Errorf("binary preview = %+v", p) + } + if p := MakePreview(context.Background(), filepath.Join(dir, "gone.txt"), tmp); p.Kind != PreviewNone { + t.Errorf("a missing file = %+v", p) + } + if p := MakePreview(context.Background(), dir, tmp); p.Kind != PreviewNone { + t.Errorf("a directory = %+v", p) + } +} + +// TestPreviewReadsOnly: looking at a file changes nothing about it, and +// nothing is left beside it. +func TestPreviewReadsOnly(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "notes.txt") + body := []byte("one\ntwo\n") + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatal(err) + } + before, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + MakePreview(context.Background(), p, t.TempDir()) + after, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if !before.ModTime().Equal(after.ModTime()) || before.Size() != after.Size() { + t.Error("the file changed") + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("the directory holds %d files, want the one", len(entries)) + } + if on, _ := os.ReadFile(p); string(on) != string(body) { + t.Error("the contents changed") + } +} + +// TestPreviewShowsOnlyTheHead: a long file is cut, and says it was. +func TestPreviewShowsOnlyTheHead(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "long.txt") + var b strings.Builder + for i := 0; i < previewLines*2; i++ { + b.WriteString("line\n") + } + if err := os.WriteFile(p, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + pv := MakePreview(context.Background(), p, t.TempDir()) + if pv.Kind != PreviewText { + t.Fatalf("preview = %+v", pv) + } + if n := strings.Count(pv.Text, "\n"); n > previewLines+1 { + t.Errorf("%d lines shown, want at most %d", n, previewLines) + } + if !strings.HasSuffix(pv.Text, "...") { + t.Error("a cut file does not say it was cut") + } +} -- cgit v1.3