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) <noreply@anthropic.com>
This commit is contained in:
3 files changed
+95
-30
No files matched your search
@@ -1,9 +1,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -54,9 +56,13 @@ type modal struct {
|
|||||||
options []option
|
options []option
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
// A text input instead of options; submit runs on enter.
|
// 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
|
input *textinput.Model
|
||||||
submit func(m *Model, text string) tea.Cmd
|
submit func(m *Model, text string) tea.Cmd
|
||||||
|
submitLabel string
|
||||||
|
err string
|
||||||
|
|
||||||
// A filter over the options: typing narrows them (all holds them
|
// A filter over the options: typing narrows them (all holds them
|
||||||
// all), so moving uses arrows or ctrl+j/k.
|
// 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"))
|
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
||||||
if p.input != nil {
|
if p.input != nil {
|
||||||
body = append(body, " "+p.input.View())
|
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
|
// 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),
|
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||||
).Render()
|
).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:]
|
||||||
|
}
|
||||||
@@ -191,7 +191,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
|||||||
switch {
|
switch {
|
||||||
case msg.err != nil && picking && errors.As(msg.err, &bad):
|
case msg.err != nil && picking && errors.As(msg.err, &bad):
|
||||||
// Picked: say why in the picker, to pick another.
|
// 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
|
return nil
|
||||||
case msg.err != nil:
|
case msg.err != nil:
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
@@ -287,12 +287,13 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
|||||||
case msg.dir == "":
|
case msg.dir == "":
|
||||||
return m.askShareDir(msg)
|
return m.askShareDir(msg)
|
||||||
default:
|
default:
|
||||||
m.offerWrite(msg.files, msg.dir)
|
m.offerWrite(msg.files, msg.dir, false)
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
||||||
in := textinput.New()
|
in := textinput.New()
|
||||||
in.Prompt = "folder: "
|
in.Prompt = "folder: "
|
||||||
@@ -307,31 +308,51 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
|||||||
if msg.sealed.Repo != "" {
|
if msg.sealed.Repo != "" {
|
||||||
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
|
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
|
||||||
}
|
}
|
||||||
m.prompt = &modal{
|
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
|
||||||
title: "Save the files", input: &in, lines: lines,
|
p.submit = func(m *Model, text string) tea.Cmd {
|
||||||
submit: func(m *Model, text string) tea.Cmd {
|
if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil {
|
||||||
m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)))
|
p.err = err.Error()
|
||||||
return nil
|
m.prompt = p // again, to fix the folder
|
||||||
},
|
return p.input.Focus()
|
||||||
}
|
}
|
||||||
return m.prompt.input.Focus()
|
return nil
|
||||||
|
}
|
||||||
|
m.prompt = p
|
||||||
|
return p.input.Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// offerWrite shows where each file goes, then writes them as chosen:
|
// offerWrite shows where each file goes, then writes them as chosen:
|
||||||
// files there already are replaced only when asked.
|
// files there already are replaced only when asked. With chosen (the
|
||||||
func (m *Model) offerWrite(files []share.File, dir string) {
|
// 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)
|
targets, err := share.Plan(dir, files)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
return fail(err)
|
||||||
return
|
}
|
||||||
|
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
|
var lines []string
|
||||||
exist := 0
|
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
|
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
|
||||||
line := file + m.st.Muted.Render(" · new")
|
line := file + m.st.Muted.Render(" · new")
|
||||||
if t.Exists {
|
if t.Exists {
|
||||||
exist++
|
|
||||||
line = file + m.st.Muted.Render(" · replaces the one there")
|
line = file + m.st.Muted.Render(" · replaces the one there")
|
||||||
}
|
}
|
||||||
if t.Exposed {
|
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...)
|
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
||||||
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
||||||
return func(m *Model) tea.Cmd {
|
return func(m *Model) tea.Cmd {
|
||||||
n := 0
|
if err := m.writeShared(targets, dir, onlyNew); err != nil {
|
||||||
for _, t := range targets {
|
|
||||||
if onlyNew && t.Exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := t.Write(); err != nil {
|
|
||||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
n++
|
|
||||||
}
|
|
||||||
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +382,31 @@ func (m *Model) offerWrite(files []share.File, dir string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
m.showMenu("Save the shared files", lines, opts)
|
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.
|
// isDir reports whether dir is a folder on disk.
|
||||||
|
|||||||
@@ -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 {
|
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())
|
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" {
|
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
|
||||||
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
|
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) {
|
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") {
|
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())
|
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)
|
entries, _ := os.ReadDir(dir)
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
t.Fatalf("nothing saved (%s)", d.m.flash.text)
|
t.Fatalf("nothing saved (%s)", d.m.flash.text)
|
||||||
|
|||||||
Reference in new issue
Block a user