From 25dbb38842979bbe43eb33d9e40d425c68182f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:35:01 +0200 Subject: [PATCH] feat: a chosen folder saves at once, and the prompt stays until it has Choosing where to save a share link's files saves them right away, unless one would replace a file there, which still asks. The folder prompt stays open until they're written: a folder that isn't there, or a save that fails, is said under it in red, to fix the path. Its footer says "enter save", and a file git doesn't ignore is named in the status line after saving. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/modal.go | 23 ++++++-- internal/ui/app/sharefiles.go | 91 +++++++++++++++++++++--------- internal/ui/app/sharefiles_test.go | 17 +++++- 3 files changed, 98 insertions(+), 33 deletions(-) diff --git a/internal/ui/app/modal.go b/internal/ui/app/modal.go index 0dac172..777ebdc 100644 --- a/internal/ui/app/modal.go +++ b/internal/ui/app/modal.go @@ -1,9 +1,11 @@ package app import ( + "cmp" "fmt" "slices" "strings" + "unicode" "unicode/utf8" "charm.land/bubbles/v2/key" @@ -54,9 +56,13 @@ type modal struct { options []option cursor int - // A text input instead of options; submit runs on enter. - input *textinput.Model - submit func(m *Model, text string) tea.Cmd + // A text input instead of options; submit runs on enter (named + // submitLabel in the footer; "open" if empty). err says why the last + // one wasn't taken, under the input. + input *textinput.Model + submit func(m *Model, text string) tea.Cmd + submitLabel string + err string // A filter over the options: typing narrows them (all holds them // all), so moving uses arrows or ctrl+j/k. @@ -260,7 +266,10 @@ func (m Model) modalView() string { foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss")) if p.input != nil { body = append(body, " "+p.input.View()) - foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel")) + if p.err != "" { + body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err))) + } + foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel")) } // Size from all options, not the ones shown or the cursor's, so the @@ -407,3 +416,9 @@ func (m Model) overlay(base string) string { lipgloss.NewLayer(box).X(x).Y(y).Z(1), ).Render() } + +// capitalize upper-cases the first letter of a message. +func capitalize(s string) string { + r, n := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[n:] +} diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 2030599..4e44838 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -191,7 +191,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { switch { case msg.err != nil && picking && errors.As(msg.err, &bad): // Picked: say why in the picker, to pick another. - m.search.pickErr = "✗ " + strings.ToUpper(bad.Error()[:1]) + bad.Error()[1:] + m.search.pickErr = "✗ " + capitalize(bad.Error()) return nil case msg.err != nil: m.noteErr(msg.err) @@ -287,12 +287,13 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { case msg.dir == "": return m.askShareDir(msg) default: - m.offerWrite(msg.files, msg.dir) + m.offerWrite(msg.files, msg.dir, false) } return nil } -// askShareDir asks where to save files from a repo that isn't here. +// askShareDir asks where to save files from a repo that isn't here. It +// stays until they're saved, saying why when they can't be. func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { in := textinput.New() in.Prompt = "folder: " @@ -307,31 +308,51 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { if msg.sealed.Repo != "" { lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...) } - m.prompt = &modal{ - title: "Save the files", input: &in, lines: lines, - submit: func(m *Model, text string) tea.Cmd { - m.offerWrite(msg.files, expandHome(strings.TrimSpace(text))) - return nil - }, + p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"} + p.submit = func(m *Model, text string) tea.Cmd { + if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil { + p.err = err.Error() + m.prompt = p // again, to fix the folder + return p.input.Focus() + } + return nil } - return m.prompt.input.Focus() + m.prompt = p + return p.input.Focus() } // offerWrite shows where each file goes, then writes them as chosen: -// files there already are replaced only when asked. -func (m *Model) offerWrite(files []share.File, dir string) { +// files there already are replaced only when asked. With chosen (the +// user just picked dir), new files are written at once, and what goes +// wrong is returned rather than shown. +func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error { + fail := func(err error) error { + if !chosen { + m.flash = flash{text: "save: " + err.Error(), isErr: true} + } + return err + } + if !isDir(dir) { + return fail(fmt.Errorf("no folder %s", dir)) + } targets, err := share.Plan(dir, files) if err != nil { - m.flash = flash{text: "save: " + err.Error(), isErr: true} - return + return fail(err) + } + exist := 0 + for _, t := range targets { + if t.Exists { + exist++ + } + } + if chosen && exist == 0 { + return m.writeShared(targets, dir, false) // nothing to ask } var lines []string - exist := 0 for _, t := range targets { file := " " + m.repoLabel(filepath.Base(dir), t.Path) line := file + m.st.Muted.Render(" · new") if t.Exists { - exist++ line = file + m.st.Muted.Render(" · replaces the one there") } if t.Exposed { @@ -342,18 +363,9 @@ func (m *Model) offerWrite(files []share.File, dir string) { lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...) write := func(onlyNew bool) func(*Model) tea.Cmd { return func(m *Model) tea.Cmd { - n := 0 - for _, t := range targets { - if onlyNew && t.Exists { - continue - } - if err := t.Write(); err != nil { - m.flash = flash{text: "save: " + err.Error(), isErr: true} - return nil - } - n++ + if err := m.writeShared(targets, dir, onlyNew); err != nil { + m.flash = flash{text: "save: " + err.Error(), isErr: true} } - m.flash = done("saved " + plural(n, "file") + " in " + dir) return nil } } @@ -370,6 +382,31 @@ func (m *Model) offerWrite(files []share.File, dir string) { ) } m.showMenu("Save the shared files", lines, opts) + return nil +} + +// writeShared writes the files (with onlyNew, those not there yet) and +// says where, and which ones git doesn't ignore. +func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error { + n := 0 + var exposed []string + for _, t := range targets { + if onlyNew && t.Exists { + continue + } + if err := t.Write(); err != nil { + return fmt.Errorf("%s: %w", t.Path, err) + } + n++ + if t.Exposed { + exposed = append(exposed, t.Path) + } + } + m.flash = done("saved " + plural(n, "file") + " in " + dir) + if len(exposed) > 0 { + m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", ")) + } + return nil } // isDir reports whether dir is a folder on disk. diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index ee3d061..7aa3699 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -163,10 +163,23 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) { if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir { t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen()) } - d.keys("enter", "w") + // A folder that isn't there: the prompt stays, saying so. + d.m.prompt.input.SetValue(filepath.Join(dir, "nope")) + d.keys("enter") + if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") { + t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen()) + } + if !strings.Contains(d.screen(), "enter save") { + t.Errorf("the footer doesn't say enter saves:\n%s", d.screen()) + } + d.m.prompt.input.SetValue(dir) + d.keys("enter") // saved at once: nothing there to replace if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" { t.Errorf(".env: %q (%s)", b, d.m.flash.text) } + if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) { + t.Errorf("not closed with where it saved: %q", d.m.flash.text) + } } func TestShareLinkGarbled(t *testing.T) { @@ -229,7 +242,7 @@ func TestShareInDemo(t *testing.T) { if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") { t.Fatalf("doesn't ask where:\n%s", d.screen()) } - d.keys("enter", "w") + d.keys("enter") // saved at once: nothing there to replace entries, _ := os.ReadDir(dir) if len(entries) == 0 { t.Fatalf("nothing saved (%s)", d.m.flash.text)