diff --git a/cmd/cloudstic/cmd_tui_profile_form.go b/cmd/cloudstic/cmd_tui_profile_form.go index 2975ebe..5a4ac5d 100644 --- a/cmd/cloudstic/cmd_tui_profile_form.go +++ b/cmd/cloudstic/cmd_tui_profile_form.go @@ -40,6 +40,8 @@ type tuiProfileModal struct { modal tui.Modal } +const tuiCreateStoreOption = "+ Create store" + var tuiSourceTypes = []string{ "local", "sftp", @@ -65,11 +67,7 @@ func newTUIProfileModal(profilesFile, existingName string, editing bool) (*tuiPr } } - storeOptions := sortedKeys(cfg.Stores) - if len(storeOptions) == 0 { - return nil, fmt.Errorf("no store references available; create one first") - } - moveDefaultToFront(storeOptions, existing.Store) + storeOptions := profileStoreOptions(cfg, existing.Store) source := newTUIProfileSource(existing.Source) m := &tuiProfileModal{ @@ -103,6 +101,9 @@ func (m *tuiProfileModal) View() tui.Modal { view := m.modal view.Subtitle = profileModalSubtitle(source, m.cfg) view.Message = sourceFieldExamples(m.selectedFieldKey(), source) + if selected := m.selectedFieldKey(); selected == "store" { + view.Message = append(view.Message, profileStoreFieldHelp(m.fieldValue("store"))...) + } return view } @@ -278,6 +279,9 @@ func (m *tuiProfileModal) submit() (string, error) { if storeRef == "" { return "", fieldError("store", "store reference is required") } + if storeRef == tuiCreateStoreOption { + return "", fieldError("store", "create a store before saving the profile") + } if _, ok := m.cfg.Stores[storeRef]; !ok { return "", fieldError("store", fmt.Sprintf("unknown store %q", storeRef)) } @@ -316,6 +320,41 @@ func (m *tuiProfileModal) submit() (string, error) { return name, nil } +func (m *tuiProfileModal) wantsCreateStore(input tuiModalInput) bool { + return m.selectedFieldKey() == "store" && input.Kind == tuiModalInputEnter && m.fieldValue("store") == tuiCreateStoreOption +} + +func (m *tuiProfileModal) wantsEditStore(input tuiModalInput) (string, bool) { + if m.selectedFieldKey() != "store" || input.Kind != tuiModalInputText || !strings.EqualFold(input.Text, "e") { + return "", false + } + storeRef := m.fieldValue("store") + if storeRef == "" || storeRef == tuiCreateStoreOption { + return "", false + } + if _, ok := m.cfg.Stores[storeRef]; !ok { + return "", false + } + return storeRef, true +} + +func (m *tuiProfileModal) reloadStoreOptions(selected string) error { + cfg, err := loadProfilesOrInit(m.profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + ensureProfilesMaps(cfg) + m.cfg = cfg + field := m.fieldByKey("store") + if field == nil { + return nil + } + field.Options = profileStoreOptions(cfg, selected) + field.Value = firstNonEmpty(selected, firstOption(field.Options)) + m.clearError() + return nil +} + func (m *tuiProfileModal) clearError() { m.modal.Error = "" m.modal.ErrorField = "" @@ -385,6 +424,32 @@ func (s *tuiSession) runProfileModal(ctx context.Context, existingName string, e if err != nil { return err } + if modal.wantsCreateStore(input) { + storeName, canceled, err := s.runStoreModal("", false) + if err != nil { + return err + } + if canceled { + continue + } + if err := modal.reloadStoreOptions(storeName); err != nil { + return err + } + continue + } + if storeRef, ok := modal.wantsEditStore(input); ok { + storeName, canceled, err := s.runStoreModal(storeRef, true) + if err != nil { + return err + } + if canceled { + continue + } + if err := modal.reloadStoreOptions(storeName); err != nil { + return err + } + continue + } done, name, err := modal.Handle(input) if err != nil { return err @@ -406,6 +471,35 @@ func (s *tuiSession) runProfileModal(ctx context.Context, existingName string, e } } +func (s *tuiSession) runStoreModal(existingName string, editing bool) (string, bool, error) { + modal, err := newTUIStoreModal(s.profilesFile, existingName, editing) + if err != nil { + return "", false, err + } + for { + view := modal.View() + s.dashboard.Modal = &view + if err := s.render(); err != nil { + return "", false, err + } + input, err := readTUIModalInput(s.r.lineReader()) + if err != nil { + return "", false, err + } + done, name, err := modal.Handle(input) + if err != nil { + return "", false, err + } + if !done { + continue + } + if name == "" { + return "", true, nil + } + return name, false, nil + } +} + func (s *tuiSession) runDeleteModal(ctx context.Context, profile tui.ProfileCard) error { modal := tui.Modal{ Kind: tui.ModalKindConfirm, @@ -551,6 +645,25 @@ func profileModalSubtitle(source tuiProfileSource, cfg *cloudstic.ProfilesConfig } } +func profileStoreOptions(cfg *cloudstic.ProfilesConfig, current string) []string { + options := sortedKeys(cfg.Stores) + moveDefaultToFront(options, current) + options = append(options, tuiCreateStoreOption) + return options +} + +func profileStoreFieldHelp(storeRef string) []string { + lines := []string{} + if storeRef == tuiCreateStoreOption { + lines = append(lines, fmt.Sprintf("%sPress Enter to create a store.%s", ui.Dim, ui.Reset)) + return lines + } + if storeRef != "" { + lines = append(lines, fmt.Sprintf("%sType e to edit the selected store.%s", ui.Dim, ui.Reset)) + } + return lines +} + func sourceFieldExamples(selectedField string, source tuiProfileSource) []string { if selectedField != "source_value" { return nil diff --git a/cmd/cloudstic/cmd_tui_store_form.go b/cmd/cloudstic/cmd_tui_store_form.go new file mode 100644 index 0000000..72886a7 --- /dev/null +++ b/cmd/cloudstic/cmd_tui_store_form.go @@ -0,0 +1,382 @@ +package main + +import ( + "fmt" + "slices" + "strings" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/tui" + "github.com/cloudstic/cli/internal/ui" +) + +type tuiStoreConfig struct { + Type string + Value string +} + +func newTUIStoreConfig(raw string) tuiStoreConfig { + parts, err := parseStoreURI(raw) + if err != nil { + return tuiStoreConfig{Type: "local"} + } + switch parts.scheme { + case "local": + return tuiStoreConfig{Type: "local", Value: parts.path} + case "s3", "b2": + value := parts.bucket + if parts.prefix != "" { + value += "/" + parts.prefix + } + return tuiStoreConfig{Type: parts.scheme, Value: value} + case "sftp": + target := "" + if parts.user != "" { + target += parts.user + "@" + } + target += parts.host + if parts.port != "" { + target += ":" + parts.port + } + target += parts.path + return tuiStoreConfig{Type: "sftp", Value: target} + default: + return tuiStoreConfig{Type: "local"} + } +} + +func (s tuiStoreConfig) Compose() string { + value := strings.TrimSpace(s.Value) + switch s.Type { + case "local": + return "local:" + value + case "s3", "b2": + return s.Type + ":" + value + case "sftp": + if value == "" { + return "" + } + return "sftp://" + value + default: + return value + } +} + +func (s tuiStoreConfig) DetailLabel() string { + switch s.Type { + case "local": + return "Path" + case "sftp": + return "Target" + default: + return "Bucket/Prefix" + } +} + +func (s tuiStoreConfig) Description(editing bool, usedBy int) string { + if editing { + if usedBy > 1 { + return fmt.Sprintf("This store is shared by %d profiles.", usedBy) + } + if usedBy == 1 { + return "This store is currently referenced by 1 profile." + } + return "Edit the store settings below." + } + switch s.Type { + case "local": + return "Store backups in a local filesystem path." + case "sftp": + return "Store backups on a remote SFTP server." + case "b2": + return "Store backups in a Backblaze B2 bucket." + default: + return "Store backups in an S3-compatible bucket." + } +} + +func (s tuiStoreConfig) ExampleText() string { + switch s.Type { + case "local": + return "Example: /Users/me/.cloudstic" + case "sftp": + return "Example: backup@host.example.com/backups" + case "b2": + return "Example: my-bucket/backups" + default: + return "Example: my-bucket/backups" + } +} + +type tuiStoreModal struct { + profilesFile string + cfg *cloudstic.ProfilesConfig + editing bool + originalName string + modal tui.Modal +} + +var tuiStoreTypes = []string{"local", "s3", "b2", "sftp"} + +func newTUIStoreModal(profilesFile, existingName string, editing bool) (*tuiStoreModal, error) { + cfg, err := loadProfilesOrInit(profilesFile) + if err != nil { + return nil, fmt.Errorf("load profiles: %w", err) + } + ensureProfilesMaps(cfg) + + var existing cloudstic.ProfileStore + if editing { + var ok bool + existing, ok = cfg.Stores[existingName] + if !ok { + return nil, fmt.Errorf("unknown store %q", existingName) + } + } + storeCfg := newTUIStoreConfig(existing.URI) + m := &tuiStoreModal{ + profilesFile: profilesFile, + cfg: cfg, + editing: editing, + originalName: existingName, + modal: tui.Modal{ + Kind: tui.ModalKindProfileForm, + Title: storeModalTitle(editing), + Subtitle: storeCfg.Description(editing, storeUsageCount(cfg, existingName)), + Hint: "Type to edit, ↑/↓ or Tab to move, ←/→ to change selections, Enter to save, Esc to cancel.", + SubmitLabel: "Save", + CancelLabel: "Cancel", + Fields: []tui.ModalField{ + {Key: "name", Label: "Name", Kind: tui.ModalFieldText, Value: existingName, Required: true, Disabled: editing}, + {Key: "store_type", Label: "Store Type", Kind: tui.ModalFieldSelect, Value: firstNonEmpty(storeCfg.Type, "local"), Options: append([]string{}, tuiStoreTypes...), Required: true}, + {Key: "store_value", Label: storeCfg.DetailLabel(), Kind: tui.ModalFieldText, Value: storeCfg.Value, Required: true}, + }, + }, + } + m.updateStoreFieldMetadata() + m.selectFirstEditableField() + return m, nil +} + +func (m *tuiStoreModal) View() tui.Modal { + view := m.modal + store := m.currentStore() + view.Subtitle = store.Description(m.editing, storeUsageCount(m.cfg, m.originalName)) + view.Message = storeFieldExamples(m.selectedFieldKey(), store) + return view +} + +func (m *tuiStoreModal) Handle(input tuiModalInput) (bool, string, error) { + switch input.Kind { + case tuiModalInputEscape: + return true, "", nil + case tuiModalInputUp: + m.moveField(-1) + case tuiModalInputDown, tuiModalInputTab: + m.moveField(1) + case tuiModalInputLeft: + m.cycleField(-1) + case tuiModalInputRight: + m.cycleField(1) + case tuiModalInputBackspace: + m.backspaceField() + case tuiModalInputText: + m.appendField(input.Text) + case tuiModalInputEnter: + name, err := m.submit() + if err != nil { + if fieldErr, ok := err.(*tuiFieldError); ok { + m.modal.ErrorField = fieldErr.Field + m.modal.Error = fieldErr.Message + } else { + m.modal.ErrorField = "" + m.modal.Error = err.Error() + } + return false, "", nil + } + return true, name, nil + } + return false, "", nil +} + +func (m *tuiStoreModal) selectFirstEditableField() { + for i, field := range m.modal.Fields { + if !field.Disabled { + m.modal.Selected = i + return + } + } + m.modal.Selected = 0 +} + +func (m *tuiStoreModal) moveField(delta int) { + if len(m.modal.Fields) == 0 || delta == 0 { + return + } + idx := m.modal.Selected + for range m.modal.Fields { + idx += delta + if idx < 0 { + idx = len(m.modal.Fields) - 1 + } + if idx >= len(m.modal.Fields) { + idx = 0 + } + if !m.modal.Fields[idx].Disabled { + m.modal.Selected = idx + return + } + } +} + +func (m *tuiStoreModal) cycleField(delta int) { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldSelect || len(field.Options) == 0 { + return + } + idx := slices.Index(field.Options, field.Value) + if idx < 0 { + idx = 0 + } + idx += delta + if idx < 0 { + idx = len(field.Options) - 1 + } + if idx >= len(field.Options) { + idx = 0 + } + field.Value = field.Options[idx] + m.clearError() + if field.Key == "store_type" { + m.updateStoreFieldMetadata() + } +} + +func (m *tuiStoreModal) appendField(text string) { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldText { + return + } + field.Value += text + m.clearError() +} + +func (m *tuiStoreModal) backspaceField() { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldText || field.Value == "" { + return + } + runes := []rune(field.Value) + field.Value = string(runes[:len(runes)-1]) + m.clearError() +} + +func (m *tuiStoreModal) submit() (string, error) { + name := strings.TrimSpace(m.fieldValue("name")) + if !m.editing { + if name == "" { + return "", fieldError("name", "store name is required") + } + if err := validateRefName("store", name); err != nil { + return "", fieldError("name", err.Error()) + } + if _, exists := m.cfg.Stores[name]; exists { + return "", fieldError("name", fmt.Sprintf("store %q already exists", name)) + } + } else { + name = m.originalName + } + + uri := m.currentStore().Compose() + if uri == "" { + return "", fieldError("store_value", "store details are required") + } + if _, err := parseStoreURI(uri); err != nil { + return "", fieldError("store_value", fmt.Sprintf("invalid store: %v", err)) + } + store := cloudstic.ProfileStore{URI: uri} + if m.editing { + store = m.cfg.Stores[m.originalName] + store.URI = uri + } + if err := tuiServiceFactory(nil, m.profilesFile, nil).SaveStore(m.profilesFile, name, store); err != nil { + return "", err + } + return name, nil +} + +func (m *tuiStoreModal) currentStore() tuiStoreConfig { + return tuiStoreConfig{ + Type: firstNonEmpty(m.fieldValue("store_type"), "local"), + Value: m.fieldValue("store_value"), + } +} + +func (m *tuiStoreModal) updateStoreFieldMetadata() { + field := m.fieldByKey("store_value") + if field == nil { + return + } + field.Label = m.currentStore().DetailLabel() + field.Required = true +} + +func (m *tuiStoreModal) fieldByKey(key string) *tui.ModalField { + for i := range m.modal.Fields { + if m.modal.Fields[i].Key == key { + return &m.modal.Fields[i] + } + } + return nil +} + +func (m *tuiStoreModal) fieldValue(key string) string { + field := m.fieldByKey(key) + if field == nil { + return "" + } + return field.Value +} + +func (m *tuiStoreModal) selectedFieldKey() string { + if m.modal.Selected < 0 || m.modal.Selected >= len(m.modal.Fields) { + return "" + } + return m.modal.Fields[m.modal.Selected].Key +} + +func (m *tuiStoreModal) clearError() { + m.modal.Error = "" + m.modal.ErrorField = "" +} + +func storeFieldExamples(selectedField string, store tuiStoreConfig) []string { + if selectedField != "store_value" { + return nil + } + example := store.ExampleText() + if example == "" { + return nil + } + return []string{fmt.Sprintf("%s%s%s", ui.Dim, example, ui.Reset)} +} + +func storeModalTitle(editing bool) string { + if editing { + return "Edit Store" + } + return "Create Store" +} + +func storeUsageCount(cfg *cloudstic.ProfilesConfig, storeName string) int { + if cfg == nil || storeName == "" { + return 0 + } + count := 0 + for _, profile := range cfg.Profiles { + if profile.Store == storeName { + count++ + } + } + return count +} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index d2f7df7..710a1fd 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -93,6 +93,28 @@ func TestTUIProfileModalViewDoesNotMutateState(t *testing.T) { } } +func TestNewTUIProfileModal_AllowsCreatingStoreWhenNoneExist(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIProfileModal(profilesPath, "", false) + if err != nil { + t.Fatalf("newTUIProfileModal: %v", err) + } + storeField := modal.fieldByKey("store") + if storeField == nil { + t.Fatalf("missing store field") + } + if len(storeField.Options) != 1 || storeField.Options[0] != tuiCreateStoreOption { + t.Fatalf("store options=%v want [%q]", storeField.Options, tuiCreateStoreOption) + } +} + func stubTUITestHooks(t *testing.T) { t.Helper() @@ -859,6 +881,49 @@ func TestTUISession_HandleActionCreateRefreshesDashboard(t *testing.T) { } } +func TestTUISession_HandleActionCreateCanCreateStoreInline(t *testing.T) { + stubTUITestHooks(t) + + oldBuild := tuiBuildDashboard + t.Cleanup(func() { tuiBuildDashboard = oldBuild }) + + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return defaultBuildTUIDashboard(context.Background(), profilesPath) + } + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + if _, err := writeEnd.WriteString("photos\t\t/photos\t\rbackup-store\t\t/backups\r\r"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, profilesPath, tui.Dashboard{}) + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionCreate}); err != nil { + t.Fatalf("handleAction(create): %v", err) + } + cfg, err := cloudstic.LoadProfilesFile(profilesPath) + if err != nil { + t.Fatalf("LoadProfilesFile: %v", err) + } + if got := cfg.Stores["backup-store"].URI; got != "local:/backups" { + t.Fatalf("saved store uri=%q want local:/backups", got) + } + if got := cfg.Profiles["photos"].Store; got != "backup-store" { + t.Fatalf("saved profile store=%q want backup-store", got) + } +} + func TestTUISession_HandleActionDeleteRefreshesDashboard(t *testing.T) { stubTUITestHooks(t) diff --git a/internal/app/tui_service.go b/internal/app/tui_service.go index 79ce6bb..130a704 100644 --- a/internal/app/tui_service.go +++ b/internal/app/tui_service.go @@ -124,6 +124,22 @@ func (s *TUIService) DeleteProfile(profilesFile, name string) error { return nil } +func (s *TUIService) SaveStore(profilesFile, name string, store cloudstic.ProfileStore) error { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + cfg.Stores[name] = store + save := s.saveProfiles + if save == nil { + save = cloudstic.SaveProfilesFile + } + if err := save(profilesFile, cfg); err != nil { + return fmt.Errorf("save profiles: %w", err) + } + return nil +} + func (s *TUIService) loadConfig(profilesFile string) (*cloudstic.ProfilesConfig, error) { load := s.loadProfiles if load == nil { diff --git a/internal/app/tui_service_test.go b/internal/app/tui_service_test.go index 3af9b5e..b5b3032 100644 --- a/internal/app/tui_service_test.go +++ b/internal/app/tui_service_test.go @@ -243,3 +243,29 @@ func TestTUIServiceDeleteProfileRemovesProfile(t *testing.T) { t.Fatalf("profile docs still present after delete") } } + +func TestTUIServiceSaveStorePersistsConfig(t *testing.T) { + svc := NewTUIService(nil) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{}, + }, nil + } + var saved *cloudstic.ProfilesConfig + svc.saveProfiles = func(_ string, cfg *cloudstic.ProfilesConfig) error { + saved = cfg + return nil + } + + err := svc.SaveStore("profiles.yaml", "remote", cloudstic.ProfileStore{URI: "local:/tmp/store"}) + if err != nil { + t.Fatalf("SaveStore: %v", err) + } + if saved == nil { + t.Fatalf("saveProfiles was not called") + } + if got := saved.Stores["remote"].URI; got != "local:/tmp/store" { + t.Fatalf("saved store uri=%q want local:/tmp/store", got) + } +}