From 8fad8e5a7602c711602d263261adb146009d7725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Fri, 3 Apr 2026 22:20:51 +0200 Subject: [PATCH] Add TUI profile management modal --- cmd/cloudstic/cmd_tui_input.go | 9 + cmd/cloudstic/cmd_tui_profile_form.go | 709 ++++++++++++++++++++++++++ cmd/cloudstic/cmd_tui_test.go | 197 ++++++- cmd/cloudstic/tui_runtime.go | 31 +- internal/app/tui_service.go | 37 ++ internal/app/tui_service_test.go | 60 +++ internal/tui/dashboard.go | 39 ++ internal/tui/shell.go | 235 +++++++-- internal/tui/shell_test.go | 52 +- internal/ui/term.go | 12 +- 10 files changed, 1326 insertions(+), 55 deletions(-) create mode 100644 cmd/cloudstic/cmd_tui_profile_form.go diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go index 03a9b82..4c17380 100644 --- a/cmd/cloudstic/cmd_tui_input.go +++ b/cmd/cloudstic/cmd_tui_input.go @@ -17,6 +17,9 @@ const ( tuiActionDown tuiActionRun tuiActionCheck + tuiActionCreate + tuiActionEdit + tuiActionDelete tuiActionQuit ) @@ -81,6 +84,12 @@ func readTUIAction(r io.ByteReader) (tuiAction, error) { return tuiActionRun, nil case 'c', 'C': return tuiActionCheck, nil + case 'n', 'N': + return tuiActionCreate, nil + case 'e', 'E': + return tuiActionEdit, nil + case 'd', 'D': + return tuiActionDelete, nil case 0x1b: next, err := r.ReadByte() if err != nil { diff --git a/cmd/cloudstic/cmd_tui_profile_form.go b/cmd/cloudstic/cmd_tui_profile_form.go new file mode 100644 index 0000000..df00795 --- /dev/null +++ b/cmd/cloudstic/cmd_tui_profile_form.go @@ -0,0 +1,709 @@ +package main + +import ( + "context" + "fmt" + "io" + "slices" + "strings" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/tui" + "github.com/cloudstic/cli/internal/ui" +) + +type tuiModalInputKind int + +const ( + tuiModalInputNone tuiModalInputKind = iota + tuiModalInputText + tuiModalInputBackspace + tuiModalInputEnter + tuiModalInputEscape + tuiModalInputUp + tuiModalInputDown + tuiModalInputLeft + tuiModalInputRight + tuiModalInputTab +) + +type tuiModalInput struct { + Kind tuiModalInputKind + Text string +} + +type tuiProfileModal struct { + profilesFile string + cfg *cloudstic.ProfilesConfig + editing bool + originalName string + modal tui.Modal +} + +var tuiSourceTypes = []string{ + "local", + "sftp", + "gdrive", + "gdrive-changes", + "onedrive", + "onedrive-changes", +} + +func newTUIProfileModal(profilesFile, existingName string, editing bool) (*tuiProfileModal, error) { + cfg, err := loadProfilesOrInit(profilesFile) + if err != nil { + return nil, fmt.Errorf("load profiles: %w", err) + } + ensureProfilesMaps(cfg) + + var existing cloudstic.BackupProfile + if editing { + var ok bool + existing, ok = cfg.Profiles[existingName] + if !ok { + return nil, fmt.Errorf("unknown profile %q", existingName) + } + } + + storeOptions := sortedKeys(cfg.Stores) + if len(storeOptions) == 0 { + return nil, fmt.Errorf("no store references available; create one first") + } + moveDefaultToFront(storeOptions, existing.Store) + + m := &tuiProfileModal{ + profilesFile: profilesFile, + cfg: cfg, + editing: editing, + originalName: existingName, + modal: tui.Modal{ + Kind: tui.ModalKindProfileForm, + Title: profileModalTitle(editing), + Subtitle: "Edit the fields below and press Enter to save.", + 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: "source_type", Label: "Source Type", Kind: tui.ModalFieldSelect, Value: "local", Options: append([]string{}, tuiSourceTypes...), Required: true}, + {Key: "source_value", Label: "Path", Kind: tui.ModalFieldText, Required: true}, + {Key: "store", Label: "Store", Kind: tui.ModalFieldSelect, Value: firstNonEmpty(existing.Store, firstOption(storeOptions)), Options: storeOptions, Required: true}, + {Key: "auth", Label: "Auth", Kind: tui.ModalFieldSelect, Value: existing.AuthRef}, + }, + }, + } + m.loadSource(existing.Source) + m.syncAuthField() + m.selectFirstEditableField() + return m, nil +} + +func (m *tuiProfileModal) View() tui.Modal { + m.syncSourceFieldMetadata() + view := m.modal + view.Subtitle = profileModalSubtitle(m) + view.Message = sourceFieldExamples(m) + return view +} + +func (m *tuiProfileModal) 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 { + m.modal.ErrorField, m.modal.Error = modalValidationError(err) + return false, "", nil + } + return true, name, nil + } + return false, "", nil +} + +func (m *tuiProfileModal) selectFirstEditableField() { + for i, field := range m.modal.Fields { + if !field.Disabled { + m.modal.Selected = i + return + } + } + m.modal.Selected = 0 +} + +func (m *tuiProfileModal) 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 *tuiProfileModal) 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 == "source_type" { + m.syncSourceFieldMetadata() + } + if field.Key == "source_type" || field.Key == "source_value" { + m.syncAuthField() + } +} + +func (m *tuiProfileModal) appendField(text string) { + field := &m.modal.Fields[m.modal.Selected] + if field.Disabled || field.Kind != tui.ModalFieldText { + return + } + field.Value += text + m.clearError() + if field.Key == "source_type" || field.Key == "source_value" { + m.syncAuthField() + } +} + +func (m *tuiProfileModal) 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() + if field.Key == "source_type" || field.Key == "source_value" { + m.syncAuthField() + } +} + +func (m *tuiProfileModal) syncAuthField() { + field := m.fieldByKey("auth") + if field == nil { + return + } + provider := profileProviderFromSource(m.composedSource()) + if provider == "" { + field.Disabled = true + field.Options = nil + field.Value = "" + field.Required = false + field.Label = "Auth" + return + } + options := profileAuthOptions(m.cfg, provider) + field.Disabled = false + field.Required = true + field.Options = options + field.Label = fmt.Sprintf("Auth (%s)", provider) + if len(options) == 0 { + field.Value = "" + return + } + if slices.Index(options, field.Value) < 0 { + field.Value = options[0] + } +} + +func (m *tuiProfileModal) submit() (string, error) { + name := strings.TrimSpace(m.fieldValue("name")) + if !m.editing { + if name == "" { + return "", modalFieldError("name", "profile name is required") + } + if err := validateRefName("profile", name); err != nil { + return "", modalFieldError("name", err.Error()) + } + if _, exists := m.cfg.Profiles[name]; exists { + return "", modalFieldError("name", fmt.Sprintf("profile %q already exists", name)) + } + } else { + name = m.originalName + } + + source := m.composedSource() + if source == "" { + return "", modalFieldError("source_value", "source details are required") + } + if _, err := parseSourceURI(source); err != nil { + return "", modalFieldError("source_value", fmt.Sprintf("invalid source: %v", err)) + } + + storeRef := strings.TrimSpace(m.fieldValue("store")) + if storeRef == "" { + return "", modalFieldError("store", "store reference is required") + } + if _, ok := m.cfg.Stores[storeRef]; !ok { + return "", modalFieldError("store", fmt.Sprintf("unknown store %q", storeRef)) + } + + authRef := strings.TrimSpace(m.fieldValue("auth")) + provider := profileProviderFromSource(source) + if provider != "" { + if authRef == "" { + return "", modalFieldError("auth", fmt.Sprintf("auth reference is required for %s sources", provider)) + } + auth, ok := m.cfg.Auth[authRef] + if !ok { + return "", modalFieldError("auth", fmt.Sprintf("unknown auth %q", authRef)) + } + if auth.Provider != provider { + return "", modalFieldError("auth", fmt.Sprintf("auth %q is not a %s entry", authRef, provider)) + } + } else { + authRef = "" + } + + profile := cloudstic.BackupProfile{ + Source: source, + Store: storeRef, + AuthRef: authRef, + } + if m.editing { + profile = m.cfg.Profiles[m.originalName] + profile.Source = source + profile.Store = storeRef + profile.AuthRef = authRef + } + if err := tuiServiceFactory(nil, m.profilesFile, nil).SaveProfile(m.profilesFile, name, profile); err != nil { + return "", err + } + return name, nil +} + +func (m *tuiProfileModal) clearError() { + m.modal.Error = "" + m.modal.ErrorField = "" +} + +func (m *tuiProfileModal) loadSource(raw string) { + sourceType := firstNonEmpty(sourceTypeFromSource(raw), "local") + sourceValue := sourceValueFromSource(raw) + if field := m.fieldByKey("source_type"); field != nil { + field.Value = sourceType + } + if field := m.fieldByKey("source_value"); field != nil { + field.Value = sourceValue + } + m.syncSourceFieldMetadata() +} + +func (m *tuiProfileModal) composedSource() string { + sourceType := m.fieldValue("source_type") + sourceValue := strings.TrimSpace(m.fieldValue("source_value")) + switch sourceType { + case "local": + return "local:" + sourceValue + case "sftp": + if sourceValue == "" { + return "" + } + return "sftp://" + sourceValue + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + switch { + case sourceValue == "", sourceValue == "/": + return sourceType + case strings.HasPrefix(sourceValue, "/"): + return sourceType + ":" + sourceValue + default: + return sourceType + "://" + sourceValue + } + default: + return sourceValue + } +} + +func (m *tuiProfileModal) syncSourceFieldMetadata() { + field := m.fieldByKey("source_value") + if field == nil { + return + } + switch m.fieldValue("source_type") { + case "local": + field.Label = "Path" + field.Required = true + case "sftp": + field.Label = "Target" + field.Required = true + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + field.Label = "Location" + field.Required = false + default: + field.Label = "Source" + field.Required = true + } +} + +func (m *tuiProfileModal) 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 *tuiProfileModal) fieldValue(key string) string { + field := m.fieldByKey(key) + if field == nil { + return "" + } + return field.Value +} + +func (s *tuiSession) runProfileModal(ctx context.Context, existingName string, editing bool) error { + modal, err := newTUIProfileModal(s.profilesFile, existingName, editing) + if err != nil { + return err + } + action := "Create profile" + if editing { + action = "Edit profile" + } + for { + view := modal.View() + s.dashboard.Modal = &view + if err := s.render(); err != nil { + return err + } + input, err := readTUIModalInput(s.r.lineReader()) + if err != nil { + return err + } + done, name, err := modal.Handle(input) + if err != nil { + return err + } + if !done { + continue + } + s.dashboard.Modal = nil + if name == "" { + s.dashboard.Activity = managementActivity(tui.ActivityStatusIdle, action, "canceled") + return nil + } + if err := s.refresh(ctx); err != nil { + return fmt.Errorf("failed to refresh TUI dashboard: %v", err) + } + s.dashboard.SelectedProfile = name + s.dashboard.Activity = managementActivity(tui.ActivityStatusSuccess, action, fmt.Sprintf("saved %q", name)) + return nil + } +} + +func (s *tuiSession) runDeleteModal(ctx context.Context, profile tui.ProfileCard) error { + modal := tui.Modal{ + Kind: tui.ModalKindConfirm, + Title: "Delete Profile", + Subtitle: "Confirm profile deletion.", + Message: []string{fmt.Sprintf("Delete profile %q?", profile.Name), "", "Press Enter to delete or Esc to cancel."}, + Hint: "This removes the profile from profiles.yaml only.", + SubmitLabel: "Delete", + CancelLabel: "Cancel", + } + for { + s.dashboard.Modal = &modal + if err := s.render(); err != nil { + return err + } + input, err := readTUIModalInput(s.r.lineReader()) + if err != nil { + return err + } + switch input.Kind { + case tuiModalInputEscape: + s.dashboard.Modal = nil + s.dashboard.Activity = managementActivity(tui.ActivityStatusIdle, "Delete profile", "canceled") + return nil + case tuiModalInputEnter: + s.dashboard.Modal = nil + if err := tuiServiceFactory(nil, s.profilesFile, nil).DeleteProfile(s.profilesFile, profile.Name); err != nil { + return err + } + if err := s.refresh(ctx); err != nil { + return fmt.Errorf("failed to refresh TUI dashboard: %v", err) + } + s.dashboard.SelectedProfile = "" + s.dashboard = ensureSelectedProfile(s.dashboard) + s.dashboard.Activity = managementActivity(tui.ActivityStatusSuccess, "Delete profile", fmt.Sprintf("deleted %q", profile.Name)) + return nil + } + } +} + +func readTUIModalInput(r io.ByteReader) (tuiModalInput, error) { + b, err := r.ReadByte() + if err != nil { + return tuiModalInput{}, err + } + switch b { + case '\r', '\n': + return tuiModalInput{Kind: tuiModalInputEnter}, nil + case 0x1b: + if isStandaloneEscape(r) { + return tuiModalInput{Kind: tuiModalInputEscape}, nil + } + next, err := r.ReadByte() + if err != nil { + return tuiModalInput{Kind: tuiModalInputEscape}, nil + } + if next == 'O' { + dir, err := r.ReadByte() + if err != nil { + return tuiModalInput{Kind: tuiModalInputEscape}, nil + } + switch dir { + case 'A': + return tuiModalInput{Kind: tuiModalInputUp}, nil + case 'B': + return tuiModalInput{Kind: tuiModalInputDown}, nil + case 'C': + return tuiModalInput{Kind: tuiModalInputRight}, nil + case 'D': + return tuiModalInput{Kind: tuiModalInputLeft}, nil + default: + return tuiModalInput{Kind: tuiModalInputNone}, nil + } + } + if next != '[' { + return tuiModalInput{Kind: tuiModalInputEscape}, nil + } + csi, err := readTUICSISequence(r) + if err != nil || len(csi) == 0 { + return tuiModalInput{Kind: tuiModalInputNone}, nil + } + switch csi[len(csi)-1] { + case 'A': + return tuiModalInput{Kind: tuiModalInputUp}, nil + case 'B': + return tuiModalInput{Kind: tuiModalInputDown}, nil + case 'C': + return tuiModalInput{Kind: tuiModalInputRight}, nil + case 'D': + return tuiModalInput{Kind: tuiModalInputLeft}, nil + case 'Z': + return tuiModalInput{Kind: tuiModalInputUp}, nil + default: + return tuiModalInput{Kind: tuiModalInputNone}, nil + } + case '\t': + return tuiModalInput{Kind: tuiModalInputTab}, nil + case 0x7f, 0x08: + return tuiModalInput{Kind: tuiModalInputBackspace}, nil + default: + if b >= 0x20 && b <= 0x7e { + return tuiModalInput{Kind: tuiModalInputText, Text: string(rune(b))}, nil + } + return tuiModalInput{Kind: tuiModalInputNone}, nil + } +} + +func isStandaloneEscape(r io.ByteReader) bool { + buffered, ok := r.(interface{ Buffered() int }) + if !ok { + return false + } + return buffered.Buffered() == 0 +} + +func profileAuthOptions(cfg *cloudstic.ProfilesConfig, provider string) []string { + options := []string{} + for name, auth := range cfg.Auth { + if auth.Provider == provider { + options = append(options, name) + } + } + slices.Sort(options) + return options +} + +func profileModalTitle(editing bool) string { + if editing { + return "Edit Profile" + } + return "Create Profile" +} + +func profileModalSubtitle(m *tuiProfileModal) string { + m.syncSourceFieldMetadata() + source := m.composedSource() + provider := profileProviderFromSource(source) + switch { + case provider == "": + return sourceTypeDescription(m.fieldValue("source_type")) + case len(profileAuthOptions(m.cfg, provider)) == 0: + return fmt.Sprintf("No %s auth refs available yet.", provider) + default: + return fmt.Sprintf("Source requires a %s auth reference.", provider) + } +} + +func sourceFieldExamples(m *tuiProfileModal) []string { + selected := "" + if m.modal.Selected >= 0 && m.modal.Selected < len(m.modal.Fields) { + selected = m.modal.Fields[m.modal.Selected].Key + } + if selected != "source_value" { + return nil + } + switch m.fieldValue("source_type") { + case "local": + return []string{fmt.Sprintf("%sExample:%s /Users/me/Documents", ui.Dim, ui.Reset)} + case "sftp": + return []string{fmt.Sprintf("%sExample:%s backup@host.example.com/data", ui.Dim, ui.Reset)} + case "gdrive", "gdrive-changes": + return []string{fmt.Sprintf("%sExamples:%s /Team Folder or Shared Drive/Finance (leave empty for the whole drive)", ui.Dim, ui.Reset)} + case "onedrive", "onedrive-changes": + return []string{fmt.Sprintf("%sExamples:%s /Documents or Shared Library/Reports (leave empty for the whole drive)", ui.Dim, ui.Reset)} + default: + return nil + } +} + +func sourceTypeDescription(sourceType string) string { + switch sourceType { + case "local": + return "Back up a local filesystem path." + case "sftp": + return "Back up files from an SFTP server." + case "gdrive": + return "Back up Google Drive with a full scan." + case "gdrive-changes": + return "Back up Google Drive incrementally via the Changes API." + case "onedrive": + return "Back up OneDrive with a full scan." + case "onedrive-changes": + return "Back up OneDrive incrementally via the delta API." + default: + return "Configure the source details below." + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func firstOption(options []string) string { + if len(options) == 0 { + return "" + } + return options[0] +} + +func sourceTypeFromSource(raw string) string { + parts, err := parseSourceURI(raw) + if err != nil { + return "" + } + return parts.scheme +} + +func sourceValueFromSource(raw string) string { + parts, err := parseSourceURI(raw) + if err != nil { + return "" + } + switch parts.scheme { + case "local": + return parts.path + case "sftp": + target := "" + if parts.user != "" { + target += parts.user + "@" + } + target += parts.host + if parts.port != "" { + target += ":" + parts.port + } + target += parts.path + return target + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + if parts.host != "" { + if parts.path == "/" { + return parts.host + } + return parts.host + parts.path + } + if parts.path == "/" { + return "/" + } + return parts.path + default: + return raw + } +} + +func moveDefaultToFront(options []string, current string) { + if current == "" { + return + } + if idx := slices.Index(options, current); idx > 0 { + options[0], options[idx] = options[idx], options[0] + } +} + +func modalFieldError(field, message string) error { + return fmt.Errorf("%s::%s", field, message) +} + +func modalValidationError(err error) (field, message string) { + if err == nil { + return "", "" + } + parts := strings.SplitN(err.Error(), "::", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + return "", err.Error() +} + +func managementActivity(status tui.ActivityStatus, action, summary string, lines ...string) tui.ActivityPanel { + return tui.ActivityPanel{ + Status: status, + Action: action, + Summary: summary, + Lines: append([]string{}, lines...), + } +} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 736091c..a8798be 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -362,6 +362,65 @@ func TestRunTUI_CheckActionRunsSelectedProfileCheck(t *testing.T) { } } +func TestRunTUI_CreateActionUsesModalAndSavesProfile(t *testing.T) { + stubTUITestHooks(t) + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"cloudstic", "tui"} + + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/test"}, + }, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + if _, err := writeEnd.WriteString("nphotos\t\t/photos\rq"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + + r := &runner{ + out: &out, + errOut: &errOut, + stdoutFile: os.Stdout, + stdin: readEnd, + lineIn: bufio.NewReader(readEnd), + } + oldEnv := os.Getenv("CLOUDSTIC_PROFILES_FILE") + t.Cleanup(func() { _ = os.Setenv("CLOUDSTIC_PROFILES_FILE", oldEnv) }) + _ = os.Setenv("CLOUDSTIC_PROFILES_FILE", profilesPath) + if code := r.runTUI(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + cfg, err := cloudstic.LoadProfilesFile(profilesPath) + if err != nil { + t.Fatalf("LoadProfilesFile: %v", err) + } + if got := cfg.Profiles["photos"].Source; got != "local:/photos" { + t.Fatalf("saved profile source=%q want local:/photos", got) + } + if !strings.Contains(out.String(), "saved \"photos\"") { + t.Fatalf("expected create activity in output, got:\n%s", out.String()) + } +} + func TestReadTUIAction_ParsesCSIArrowKeys(t *testing.T) { ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A"))) if err != nil { @@ -426,6 +485,42 @@ func TestReadTUIAction_ParsesCheckShortcut(t *testing.T) { } } +func TestReadTUIAction_ParsesManagementShortcuts(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n"))) + if err != nil { + t.Fatalf("readTUIAction create: %v", err) + } + if ev != tuiActionCreate { + t.Fatalf("create action=%v want %v", ev, tuiActionCreate) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("e"))) + if err != nil { + t.Fatalf("readTUIAction edit: %v", err) + } + if ev != tuiActionEdit { + t.Fatalf("edit action=%v want %v", ev, tuiActionEdit) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("d"))) + if err != nil { + t.Fatalf("readTUIAction delete: %v", err) + } + if ev != tuiActionDelete { + t.Fatalf("delete action=%v want %v", ev, tuiActionDelete) + } +} + +func TestReadTUIModalInput_ParsesStandaloneEscape(t *testing.T) { + ev, err := readTUIModalInput(bufio.NewReader(bytes.NewBufferString("\x1b"))) + if err != nil { + t.Fatalf("readTUIModalInput escape: %v", err) + } + if ev.Kind != tuiModalInputEscape { + t.Fatalf("escape kind=%v want %v", ev.Kind, tuiModalInputEscape) + } +} + func TestTUISession_EnterLeaveManagesTerminalState(t *testing.T) { readEnd, writeEnd, err := os.Pipe() if err != nil { @@ -541,6 +636,104 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { } } +func TestTUISession_HandleActionCreateRefreshesDashboard(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, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/test"}, + }, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }); 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\r"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + s := newTUISession(&runner{out: &out, stdoutFile: os.Stdout, stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, profilesPath, tui.Dashboard{}) + if _, err := s.handleAction(context.Background(), tuiActionCreate); err != nil { + t.Fatalf("handleAction(create): %v", err) + } + if s.dashboard.SelectedProfile != "photos" { + t.Fatalf("selected profile=%q want photos", s.dashboard.SelectedProfile) + } + if s.dashboard.Activity.Status != tui.ActivityStatusSuccess { + t.Fatalf("unexpected activity: %+v", s.dashboard.Activity) + } +} + +func TestTUISession_HandleActionDeleteRefreshesDashboard(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, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/test"}, + }, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + "photos": {Source: "local:/photos", Store: "remote"}, + }, + }); 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("\r"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{ + SelectedProfile: "docs", + Profiles: []tui.ProfileCard{{Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}}, + }) + s.r.stdin = readEnd + s.r.lineIn = bufio.NewReader(readEnd) + s.profilesFile = profilesPath + if _, err := s.handleAction(context.Background(), tuiActionDelete); err != nil { + t.Fatalf("handleAction(delete): %v", err) + } + if s.dashboard.SelectedProfile != "photos" { + t.Fatalf("selected profile=%q want photos", s.dashboard.SelectedProfile) + } + if s.dashboard.Activity.Status != tui.ActivityStatusSuccess { + t.Fatalf("unexpected activity: %+v", s.dashboard.Activity) + } +} + func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { oldBuild := tuiBuildDashboard t.Cleanup(func() { tuiBuildDashboard = oldBuild }) @@ -597,9 +790,11 @@ func TestReadInput_ClosesChannelOnEOF(t *testing.T) { _ = writeEnd.Close() s := newTUISession(&runner{stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, "", tui.Dashboard{}) + readPermitCh := make(chan struct{}, 1) eventCh := make(chan tuiAction, 2) errCh := make(chan error, 1) - s.readInput(eventCh, errCh) + close(readPermitCh) + s.readInput(readPermitCh, eventCh, errCh) if _, ok := <-eventCh; ok { t.Fatalf("expected event channel to be closed") diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index b92f627..e2c9058 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -166,8 +166,10 @@ func (s *tuiSession) run(ctx context.Context) int { } eventCh := make(chan tuiAction, 32) + readPermitCh := make(chan struct{}, 1) readErrCh := make(chan error, 1) - go s.readInput(eventCh, readErrCh) + go s.readInput(readPermitCh, eventCh, readErrCh) + readPermitCh <- struct{}{} resizeCh := make(chan os.Signal, 1) tuiNotifyResize(resizeCh) @@ -196,6 +198,7 @@ func (s *tuiSession) run(ctx context.Context) int { if code >= 0 { return code } + readPermitCh <- struct{}{} } } } @@ -253,9 +256,9 @@ func (s *tuiSession) render() error { return renderTUIScreenWidth(s.r.out, s.dashboard, tuiWidth(s.r)) } -func (s *tuiSession) readInput(eventCh chan<- tuiAction, readErrCh chan<- error) { +func (s *tuiSession) readInput(readPermitCh <-chan struct{}, eventCh chan<- tuiAction, readErrCh chan<- error) { defer close(eventCh) - for { + for range readPermitCh { event, err := readTUIAction(s.r.lineReader()) if err != nil { if err != io.EOF { @@ -297,6 +300,28 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e if err := s.resumeRaw(); err != nil { return -1, fmt.Errorf("failed to configure terminal: %v", err) } + case tuiActionCreate: + if err := s.runProfileModal(ctx, "", false); err != nil { + s.dashboard.Activity = managementActivity(tui.ActivityStatusError, "Create profile", err.Error()) + } + case tuiActionEdit: + profile, ok := selectedTUIProfile(s.dashboard) + if !ok { + s.dashboard.Activity = managementActivity(tui.ActivityStatusError, "Edit profile", "no profile selected") + } else { + if err := s.runProfileModal(ctx, profile.Name, true); err != nil { + s.dashboard.Activity = managementActivity(tui.ActivityStatusError, "Edit profile", err.Error()) + } + } + case tuiActionDelete: + profile, ok := selectedTUIProfile(s.dashboard) + if !ok { + s.dashboard.Activity = managementActivity(tui.ActivityStatusError, "Delete profile", "no profile selected") + } else { + if err := s.runDeleteModal(ctx, profile); err != nil { + s.dashboard.Activity = managementActivity(tui.ActivityStatusError, "Delete profile", err.Error()) + } + } default: return -1, nil } diff --git a/internal/app/tui_service.go b/internal/app/tui_service.go index 584029c..79ce6bb 100644 --- a/internal/app/tui_service.go +++ b/internal/app/tui_service.go @@ -20,12 +20,14 @@ type TUIBackend interface { type TUIService struct { loadProfiles func(string) (*cloudstic.ProfilesConfig, error) + saveProfiles func(string, *cloudstic.ProfilesConfig) error backend TUIBackend } func NewTUIService(backend TUIBackend) *TUIService { return &TUIService{ loadProfiles: loadProfilesConfig, + saveProfiles: cloudstic.SaveProfilesFile, backend: backend, } } @@ -87,6 +89,41 @@ func (s *TUIService) RunProfileCheck(ctx context.Context, profilesFile string, p return s.backend.CheckProfile(ctx, profilesFile, profile.Name, profileCfg, cfg, reporter) } +func (s *TUIService) SaveProfile(profilesFile, name string, profile cloudstic.BackupProfile) error { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + cfg.Profiles[name] = profile + 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) DeleteProfile(profilesFile, name string) error { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + if _, ok := cfg.Profiles[name]; !ok { + return fmt.Errorf("unknown profile %q", name) + } + delete(cfg.Profiles, name) + 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 7dd2a35..3af9b5e 100644 --- a/internal/app/tui_service_test.go +++ b/internal/app/tui_service_test.go @@ -183,3 +183,63 @@ func TestTUIServiceRunProfileCheckRejectsUninitializedRepo(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestTUIServiceSaveProfilePersistsConfig(t *testing.T) { + svc := NewTUIService(nil) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket"}, + }, + Profiles: map[string]cloudstic.BackupProfile{}, + }, nil + } + var saved *cloudstic.ProfilesConfig + svc.saveProfiles = func(_ string, cfg *cloudstic.ProfilesConfig) error { + saved = cfg + return nil + } + + err := svc.SaveProfile("profiles.yaml", "docs", cloudstic.BackupProfile{ + Source: "local:/docs", + Store: "remote", + }) + if err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if saved == nil { + t.Fatalf("saveProfiles was not called") + } + if got := saved.Profiles["docs"].Source; got != "local:/docs" { + t.Fatalf("saved profile source=%q want local:/docs", got) + } +} + +func TestTUIServiceDeleteProfileRemovesProfile(t *testing.T) { + svc := NewTUIService(nil) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }, nil + } + var saved *cloudstic.ProfilesConfig + svc.saveProfiles = func(_ string, cfg *cloudstic.ProfilesConfig) error { + saved = cfg + return nil + } + + err := svc.DeleteProfile("profiles.yaml", "docs") + if err != nil { + t.Fatalf("DeleteProfile: %v", err) + } + if saved == nil { + t.Fatalf("saveProfiles was not called") + } + if _, ok := saved.Profiles["docs"]; ok { + t.Fatalf("profile docs still present after delete") + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 5f3c3ee..bf9724b 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -17,9 +17,48 @@ type Dashboard struct { AuthCount int SelectedProfile string Activity ActivityPanel + Modal *Modal Profiles []ProfileCard } +type ModalKind string + +const ( + ModalKindProfileForm ModalKind = "profile_form" + ModalKindConfirm ModalKind = "confirm" +) + +type ModalFieldKind string + +const ( + ModalFieldText ModalFieldKind = "text" + ModalFieldSelect ModalFieldKind = "select" +) + +type Modal struct { + Kind ModalKind + Title string + Subtitle string + Error string + ErrorField string + Hint string + Message []string + Fields []ModalField + Selected int + SubmitLabel string + CancelLabel string +} + +type ModalField struct { + Key string + Label string + Kind ModalFieldKind + Value string + Options []string + Required bool + Disabled bool +} + type ActivityStatus string const ( diff --git a/internal/tui/shell.go b/internal/tui/shell.go index bcaf58f..ef32947 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -26,43 +26,26 @@ func RenderDashboard(w io.Writer, d Dashboard) error { } func RenderDashboardWidth(w io.Writer, d Dashboard, width int) error { - if _, err := fmt.Fprintf(w, "%s%s%s\n", ui.Bold, "Cloudstic TUI", ui.Reset); err != nil { - return err - } - if _, err := fmt.Fprintf(w, "%sOperator dashboard for profiles, stores, and auth.%s\n", ui.Dim, ui.Reset); err != nil { - return err - } - if _, err := fmt.Fprintln(w); err != nil { - return err - } - - stats := []string{ - fmt.Sprintf("%sProfiles%s %d", ui.Cyan, ui.Reset, d.ProfileCount), - fmt.Sprintf("%sStores%s %d", ui.Cyan, ui.Reset, d.StoreCount), - fmt.Sprintf("%sAuth%s %d", ui.Cyan, ui.Reset, d.AuthCount), - } - if err := renderBoxExact(w, "Overview", []string{strings.Join(stats, " ")}, panelWidth(width)); err != nil { - return err - } - - profilesWidth, detailWidth := splitPaneWidths(width) - leftLines := renderProfileList(d) - rightLines := renderSelectedProfile(d) - leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) - if err := renderColumns(w, - boxLinesExact("Profiles", leftLines, profilesWidth), - boxLinesExact("Selection", rightLines, detailWidth), - width, - ); err != nil { - return err + lines := dashboardLinesWidth(d, width) + dimBackground := d.Modal != nil + for _, line := range lines { + if dimBackground { + line = dimmedLine(line) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } } - - if err := renderBoxExact(w, "Activity", renderActivityPanel(d.Activity), panelWidth(width)); err != nil { - return err + if d.Modal != nil { + if err := renderModalOverlay(w, *d.Modal, width, len(lines)); err != nil { + return err + } } + return nil +} - _, err := fmt.Fprintf(w, "\n%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, q to quit.%s\n", ui.Dim, ui.Reset) - return err +func dimmedLine(line string) string { + return ui.Dim + strings.ReplaceAll(line, ui.Reset, ui.Reset+ui.Dim) + ui.Reset } func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { @@ -96,13 +79,32 @@ func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { return layout } -func renderBoxExact(w io.Writer, title string, lines []string, width int) error { - for _, line := range boxLinesExact(title, lines, width) { - if _, err := fmt.Fprintln(w, line); err != nil { - return err - } +func dashboardLinesWidth(d Dashboard, width int) []string { + lines := []string{ + fmt.Sprintf("%s%s%s", ui.Bold, "Cloudstic TUI", ui.Reset), + fmt.Sprintf("%sOperator dashboard for profiles, stores, and auth.%s", ui.Dim, ui.Reset), + "", } - return nil + + stats := []string{ + fmt.Sprintf("%sProfiles%s %d", ui.Cyan, ui.Reset, d.ProfileCount), + fmt.Sprintf("%sStores%s %d", ui.Cyan, ui.Reset, d.StoreCount), + fmt.Sprintf("%sAuth%s %d", ui.Cyan, ui.Reset, d.AuthCount), + } + lines = append(lines, boxLinesExact("Overview", []string{strings.Join(stats, " ")}, panelWidth(width))...) + + profilesWidth, detailWidth := splitPaneWidths(width) + leftLines := renderProfileList(d) + rightLines := renderSelectedProfile(d) + leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) + lines = append(lines, renderColumnLines( + boxLinesExact("Profiles", leftLines, profilesWidth), + boxLinesExact("Selection", rightLines, detailWidth), + )...) + + lines = append(lines, boxLinesExact("Activity", renderActivityPanel(d.Activity), panelWidth(width))...) + lines = append(lines, "", fmt.Sprintf("%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.%s", ui.Dim, ui.Reset)) + return lines } func boxLinesExact(title string, lines []string, width int) []string { @@ -134,21 +136,20 @@ func boxLinesExact(title string, lines []string, width int) []string { return out } -func renderColumns(w io.Writer, left, right []string, maxWidth int) error { +func renderColumnLines(left, right []string) []string { leftWidth := longestVisible(left) rightWidth := longestVisible(right) height := len(left) if len(right) > height { height = len(right) } + lines := make([]string, 0, height) for i := 0; i < height; i++ { leftLine := paddedLine(left, i, leftWidth) rightLine := paddedLine(right, i, rightWidth) - if _, err := fmt.Fprintf(w, "%s %s\n", leftLine, rightLine); err != nil { - return err - } + lines = append(lines, fmt.Sprintf("%s %s", leftLine, rightLine)) } - return nil + return lines } func renderProfileList(d Dashboard) []string { @@ -227,9 +228,153 @@ func renderSelectedProfile(d Dashboard) []string { for _, action := range profile.Actions { lines = append(lines, fmt.Sprintf("%sAction%s %s", ui.Dim, ui.Reset, actionLabel(action))) } + lines = append(lines, fmt.Sprintf("%sAction%s Press e to edit this profile", ui.Dim, ui.Reset)) + lines = append(lines, fmt.Sprintf("%sAction%s Press d to delete this profile", ui.Dim, ui.Reset)) return lines } +func renderModalOverlay(w io.Writer, modal Modal, screenWidth, screenHeight int) error { + startX, width := modalLayout(screenWidth) + lines := modalLines(modal, width) + startY := 4 + if screenHeight > 0 { + startY = ((screenHeight - len(lines)) / 2) + 1 + if startY < 4 { + startY = 4 + } + } + for i, line := range lines { + if _, err := fmt.Fprintf(w, "\x1b[%d;%dH%s", startY+i, startX, line); err != nil { + return err + } + } + _, err := fmt.Fprintf(w, "\x1b[%d;%dH", startY+len(lines), 1) + return err +} + +func modalLines(modal Modal, width int) []string { + lines := []string{} + if modal.Subtitle != "" { + lines = append(lines, fmt.Sprintf("%s%s%s", ui.Dim, modal.Subtitle, ui.Reset), "") + } + labelWidth := modalLabelWidth(modal) + for i, field := range modal.Fields { + selected := i == modal.Selected + hasError := modal.ErrorField == field.Key && modal.Error != "" + prefix := " " + if selected { + prefix = fmt.Sprintf("%s› %s", ui.Cyan, ui.Reset) + } + labelText := field.Label + if field.Required && !field.Disabled { + labelText += " " + ui.Yellow + "*" + ui.Reset + } + label := fmt.Sprintf("%s%s%s", ui.Dim, labelText, ui.Reset) + if selected { + label = fmt.Sprintf("%s%s%s", ui.Cyan, labelText, ui.Reset) + } + if hasError { + label = labelText + } + padding := labelWidth - visibleLen(label) + if padding < 0 { + padding = 0 + } + lines = append(lines, fmt.Sprintf("%s%s%s %s", prefix, label, strings.Repeat(" ", padding), modalFieldValue(field, selected))) + if hasError { + lines = append(lines, fmt.Sprintf(" %s%s%s", ui.Red, modal.Error, ui.Reset)) + } + } + if len(modal.Message) > 0 { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, modal.Message...) + } + if modal.Error != "" && modal.ErrorField == "" { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("%s%s%s", ui.Red, modal.Error, ui.Reset)) + } + if modal.Hint != "" { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("%sFields marked * are required.%s", ui.Dim, ui.Reset)) + lines = append(lines, fmt.Sprintf("%s%s%s", ui.Dim, modal.Hint, ui.Reset)) + } + return boxLinesExact(modal.Title, lines, width) +} + +func modalLabelWidth(modal Modal) int { + width := 0 + for _, field := range modal.Fields { + label := field.Label + if field.Required && !field.Disabled { + label += " *" + } + if l := len(label); l > width { + width = l + } + } + return width +} + +func modalFieldValue(field ModalField, selected bool) string { + if field.Disabled { + return fmt.Sprintf("%snot required%s", ui.Dim, ui.Reset) + } + switch field.Kind { + case ModalFieldSelect: + value := field.Value + if value == "" { + value = "none" + } + if selected { + return fmt.Sprintf("%s<%s>%s %s←/→%s", ui.Cyan, value, ui.Reset, ui.Dim, ui.Reset) + } + return fmt.Sprintf("[%s]", value) + default: + value := field.Value + if value == "" { + value = fmt.Sprintf("%s%s", ui.Dim, ui.Reset) + } + if selected { + cursor := fmt.Sprintf("%s_%s", ui.Cyan, ui.Reset) + return fmt.Sprintf("%s%s%s", value, cursor, "") + } + return value + } +} + +func modalLayout(screenWidth int) (startX int, width int) { + if screenWidth <= 0 { + return 1, 60 + } + leftWidth, rightWidth := splitPaneWidths(screenWidth) + startX = leftWidth + 7 + width = rightWidth + if width < 40 { + width = 40 + } + maxWidth := screenWidth - startX - 4 + if maxWidth < width { + width = maxWidth + } + if width < 40 { + width = screenWidth - 12 + if width < 40 { + width = 40 + } + startX = ((screenWidth - (width + 4)) / 2) + 1 + if startX < 1 { + startX = 1 + } + } + return startX, width +} + func profileHeaderLine(profile ProfileCard, selected bool) string { prefix := " " if selected { diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 6b53726..0c5694b 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -75,10 +75,60 @@ func TestRenderDashboard(t *testing.T) { "2026-04-03 15:05:00", "Snapshot abc123 saved", "Press c to run repository check", - "Use ↑/↓ to select a profile. Press b to backup/init, c to check, q to quit.", + "Press e to edit this profile", + "Press d to delete this profile", + "Use ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.", } { if !strings.Contains(got, want) { t.Fatalf("missing %q in output:\n%s", want, got) } } } + +func TestRenderDashboardWithModal(t *testing.T) { + d := Dashboard{ + ProfileCount: 1, + StoreCount: 1, + Profiles: []ProfileCard{{ + Name: "documents", + Source: "local:/docs", + StoreRef: "remote", + Enabled: true, + Status: ProfileStatusReady, + }}, + Modal: &Modal{ + Title: "Create Profile", + Subtitle: "Configure the profile fields.", + Hint: "Enter to save, Esc to cancel.", + Selected: 0, + Fields: []ModalField{ + {Key: "name", Label: "Name", Kind: ModalFieldText, Value: "photos", Required: true}, + {Key: "source", Label: "Source", Kind: ModalFieldText, Value: "local:/photos", Required: true}, + }, + }, + } + + var out strings.Builder + if err := RenderDashboardWidth(&out, d, 100); err != nil { + t.Fatalf("RenderDashboardWidth: %v", err) + } + got := out.String() + for _, want := range []string{ + "Create Profile", + "Configure the profile fields.", + "Name", + "Source", + "photos", + "local:/photos", + "_", + "Enter to save, Esc to cancel.", + "Fields marked * are required.", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in output:\n%s", want, got) + } + } + if strings.Contains(got, "Example:") { + t.Fatalf("did not expect example when source field is not active:\n%s", got) + } +} diff --git a/internal/ui/term.go b/internal/ui/term.go index c5159d3..507d55a 100644 --- a/internal/ui/term.go +++ b/internal/ui/term.go @@ -8,11 +8,13 @@ import ( ) const ( - Bold = logger.ColorBold - Dim = logger.ColorDim - Cyan = logger.ColorCyan - Green = logger.ColorGreen - Reset = logger.ColorReset + Bold = logger.ColorBold + Dim = logger.ColorDim + Red = logger.ColorRed + Cyan = logger.ColorCyan + Green = logger.ColorGreen + Yellow = logger.ColorYellow + Reset = logger.ColorReset ) // TermWriter provides helpers for styled terminal output.