From 8c6ff7d0cd100c7021ae8e9f8c7484dee1b5f544 Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:30:40 -0500 Subject: [PATCH 1/2] style(ui): improve wizard step visuals with bullet markers and radio buttons, upgrade tables to lipgloss/table --- internal/ui/add_repo_wizard.go | 31 ++++++++++++++++++-------- internal/ui/create_wizard.go | 37 ++++++++++++++++++++++--------- internal/ui/create_wizard_test.go | 4 ++-- internal/ui/table.go | 19 +++++++++++++--- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/internal/ui/add_repo_wizard.go b/internal/ui/add_repo_wizard.go index c8a9249..2582f70 100644 --- a/internal/ui/add_repo_wizard.go +++ b/internal/ui/add_repo_wizard.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "sort" + "strconv" "strings" "github.com/EndersonPro/flutree/internal/domain" @@ -308,15 +309,27 @@ func (m addRepoWizardModel) updateReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m addRepoWizardModel) progressLabel() string { - labels := []string{"1.Select repos", "2.Review", "3.Branches"} - for i := range labels { - if i == int(m.step) { - labels[i] = wizardProgressActiveStyle.Render(labels[i]) - continue + type stepInfo struct{ num int; label string } + steps := []stepInfo{ + {1, "Select repos"}, + {2, "Review"}, + {3, "Branches"}, + } + var out strings.Builder + out.WriteString("\n") + for _, s := range steps { + prefix := "○" + style := wizardProgressIdleStyle + if int(m.step)+1 == s.num { + prefix = "●" + style = wizardProgressActiveStyle + } + out.WriteString(style.Render(prefix+" Step "+strconv.Itoa(s.num)+": "+s.label)) + if s.num < len(steps) { + out.WriteString(" ") } - labels[i] = wizardProgressIdleStyle.Render(labels[i]) } - return "\n" + strings.Join(labels, " ") + return out.String() } func (m addRepoWizardModel) selectReposView() string { @@ -328,9 +341,9 @@ func (m addRepoWizardModel) selectReposView() string { if i == m.cursor { cursor = ">" } - marker := "[ ]" + marker := "○" if m.selected[i] { - marker = "[x]" + marker = "◉" } b.WriteString(fmt.Sprintf("%s %s %s [%s] (%s)\n", cursor, marker, repo.Name, repo.PackageName, repo.RepoRoot)) } diff --git a/internal/ui/create_wizard.go b/internal/ui/create_wizard.go index 3eb6daf..ad19996 100644 --- a/internal/ui/create_wizard.go +++ b/internal/ui/create_wizard.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "sort" + "strconv" "strings" "github.com/EndersonPro/flutree/internal/domain" @@ -264,9 +265,9 @@ func (m createWizardModel) View() string { if i == m.pkgCursor { cursor = ">" } - marker := "[ ]" + marker := "○" if m.pkgSelected[i] { - marker = "[x]" + marker = "◉" } b.WriteString(fmt.Sprintf("%s %s %s [%s]\n", cursor, marker, pkg.Name, pkg.PackageName)) } @@ -522,15 +523,29 @@ func (m *createWizardModel) refreshPackageCandidates(preselected []string) { } func (m createWizardModel) progressLabel() string { - labels := []string{"1.Name", "2.Root", "3.Packages", "4.Branches", "5.Review"} - for i := range labels { - if i == int(m.step) { - labels[i] = wizardProgressActiveStyle.Render(labels[i]) - continue + type stepInfo struct{ num int; label string } + steps := []stepInfo{ + {1, "Name"}, + {2, "Root"}, + {3, "Packages"}, + {4, "Branches"}, + {5, "Review"}, + } + var out strings.Builder + out.WriteString("\n") + for _, s := range steps { + prefix := "○" + style := wizardProgressIdleStyle + if int(m.step)+1 == s.num { + prefix = "●" + style = wizardProgressActiveStyle + } + out.WriteString(style.Render(prefix+" Step "+strconv.Itoa(s.num)+": "+s.label)) + if s.num < len(steps) { + out.WriteString(" ") } - labels[i] = wizardProgressIdleStyle.Render(labels[i]) } - return "\n" + strings.Join(labels, " ") + return out.String() } func (m createWizardModel) branchesView() string { @@ -569,7 +584,7 @@ func (m createWizardModel) reviewView() string { var b strings.Builder b.WriteString(wizardSectionStyle.Render("Step 5 - Final review")) b.WriteString("\n") - b.WriteString(renderTable( + b.WriteString(renderStyledTableNoZebra( []string{"Setting", "Value"}, [][]string{ {"Workspace", m.name}, @@ -579,7 +594,7 @@ func (m createWizardModel) reviewView() string { )) b.WriteString("\n") - b.WriteString(renderTable( + b.WriteString(renderStyledTableNoZebra( []string{"Role", "Repository", "Package", "Branch", "Base Branch"}, m.reviewRows(), )) diff --git a/internal/ui/create_wizard_test.go b/internal/ui/create_wizard_test.go index a06c295..e373ca7 100644 --- a/internal/ui/create_wizard_test.go +++ b/internal/ui/create_wizard_test.go @@ -192,10 +192,10 @@ func TestCreateWizardReviewViewUsesEnglishChoices(t *testing.T) { if !strings.Contains(view, "Apply changes") { t.Fatalf("expected english apply action in review view, got: %q", view) } - if !regexp.MustCompile(`\|\s*Role\s*\|\s*Repository\s*\|\s*Package\s*\|\s*Branch\s*\|\s*Base Branch\s*\|`).MatchString(view) { + if !regexp.MustCompile(`[|│]\s*Role\s*[|│]\s*Repository\s*[|│]\s*Package\s*[|│]\s*Branch\s*[|│]\s*Base Branch\s*[|│]`).MatchString(view) { t.Fatalf("expected review table header with role/branch/base columns, got: %q", view) } - if !regexp.MustCompile(`\|\s*package\s*\|\s*core\s*\|\s*core\s*\|\s*feature/feature\s*\|\s*develop\s*\|`).MatchString(view) { + if !regexp.MustCompile(`[|│]\s*package\s*[|│]\s*core\s*[|│]\s*core\s*[|│]\s*feature/feature\s*[|│]\s*develop\s*[|│]`).MatchString(view) { t.Fatalf("expected package detail row in review table, got: %q", view) } } diff --git a/internal/ui/table.go b/internal/ui/table.go index efd2143..9eddb89 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -32,6 +32,16 @@ func terminalWidth() int { // colors, status-aware cell rendering, zebra striping, and terminal-width // awareness. func renderStyledTable(headers []string, rows [][]string, listRows []domain.ListRow) string { + return renderStyledTableWithZebra(headers, rows, listRows, true) +} + +// renderStyledTableNoZebra renders a styled table without zebra striping. +// Used by create wizard review step for cleaner visual output. +func renderStyledTableNoZebra(headers []string, rows [][]string) string { + return renderStyledTableWithZebra(headers, rows, nil, false) +} + +func renderStyledTableWithZebra(headers []string, rows [][]string, listRows []domain.ListRow, zebra bool) string { if len(headers) == 0 { return "" } @@ -59,10 +69,13 @@ func renderStyledTable(headers []string, rows [][]string, listRows []domain.List if row == table.HeaderRow { return uiTableHeaderStyle } - if row%2 == 0 { - return uiTableRowStyle + if zebra { + if row%2 == 0 { + return uiTableRowStyle + } + return uiTableRowAltStyle } - return uiTableRowAltStyle + return uiTableRowStyle }) return t.Render() From f58064224ca02d562c101b5bc77d779b9f06abc7 Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:41:20 -0500 Subject: [PATCH 2/2] fix(app): merge existing pubspec_overrides.yaml instead of overwriting --- internal/app/add_repo_service.go | 14 +-- internal/app/create_service.go | 149 +++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/internal/app/add_repo_service.go b/internal/app/add_repo_service.go index d49c6d8..8b7bf05 100644 --- a/internal/app/add_repo_service.go +++ b/internal/app/add_repo_service.go @@ -232,20 +232,8 @@ func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, e }) } - rootPlan := domain.PlannedWorktree{ - Repo: domain.DiscoveredFlutterRepo{ - Name: filepath.Base(rootRecord.Path), - RepoRoot: rootRecord.RepoRoot, - PackageName: readPackageNameFromWorktree(rootRecord.Path), - }, - Role: "root", - Path: rootRecord.Path, - Branch: rootRecord.Branch, - } - overridePath := filepath.Join(rootRecord.Path, overrideFileName) - overrideContent := buildOverrideContent(rootPlan, overridePackages) - if err := os.WriteFile(overridePath, []byte(overrideContent), 0o644); err != nil { + if err := writeOverrideFile(overridePath, rootRecord.Path, overridePackages); err != nil { rollback() return domain.AddRepoResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to update pubspec_overrides.yaml.", overridePath, err) } diff --git a/internal/app/create_service.go b/internal/app/create_service.go index 826b68a..09145da 100644 --- a/internal/app/create_service.go +++ b/internal/app/create_service.go @@ -173,7 +173,7 @@ func (s *CreateService) Apply(plan domain.CreateDryPlan, options domain.CreateAp created = append(created, pkg) } - if err := os.WriteFile(plan.OverridePath, []byte(plan.OverrideContent), 0o644); err != nil { + if err := writeOverrideFile(plan.OverridePath, plan.Root.Path, plan.Packages); err != nil { rollback() return domain.CreateResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to write pubspec_overrides.yaml.", plan.OverridePath, err) } @@ -408,18 +408,153 @@ func repoLabel(repo domain.DiscoveredFlutterRepo) string { } func buildOverrideContent(root domain.PlannedWorktree, packages []domain.PlannedWorktree) string { - lines := []string{"dependency_overrides:"} - if len(packages) == 0 { - lines = append(lines, " {}") - return strings.Join(lines, "\n") + "\n" + entries := buildOverrideEntriesMap(root, packages, nil) + return formatOverrideYAML(entries) +} + +// OverrideEntry represents a single dependency_override entry. +type OverrideEntry struct { + PackageName string + RelativePath string +} + +// buildOverrideEntriesMap merges new packages with existing entries. +// existingEntries may be nil. When package name collides, new wins. +func buildOverrideEntriesMap(root domain.PlannedWorktree, packages []domain.PlannedWorktree, existingEntries map[string]string) map[string]string { + entries := make(map[string]string) + // Seed with existing entries first (will be overwritten by new if duplicate). + for pkg, relPath := range existingEntries { + entries[pkg] = relPath } + // Add new packages (overwrites any existing with same package name). for _, pkg := range packages { rel, err := filepath.Rel(root.Path, pkg.Path) if err != nil { rel = pkg.Path } - lines = append(lines, " "+pkg.Repo.PackageName+":") - lines = append(lines, " path: "+filepath.ToSlash(rel)) + entries[pkg.Repo.PackageName] = filepath.ToSlash(rel) + } + return entries +} + +// Merge existing overrides from pubspec.yaml into the overrides map. +// Returns the updated entries map with pubspec overrides included. +// Pubspec overrides are only moved if not already present in entries (new wins on conflict). +func mergePubspecOverrides(entries map[string]string, pubspecPath string) map[string]string { + content, err := os.ReadFile(pubspecPath) + if err != nil { + return entries + } + parsing := false + var currentPkg string + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "dependency_overrides:") { + parsing = true + currentPkg = "" + continue + } + if parsing && strings.HasPrefix(line, ":") && !strings.HasPrefix(line, " ") { + break + } + if parsing && strings.HasPrefix(line, " ") && strings.TrimSpace(line) != "" { + pkgLine := strings.TrimSpace(line) + if strings.HasSuffix(pkgLine, ":") { + currentPkg = strings.TrimSpace(strings.TrimSuffix(pkgLine, ":")) + continue + } + if strings.HasPrefix(pkgLine, "path:") && currentPkg != "" { + relPath := strings.TrimSpace(strings.TrimPrefix(pkgLine, "path:")) + relPath = strings.Trim(relPath, "\"'") + if relPath != "" { + if _, exists := entries[currentPkg]; !exists { + entries[currentPkg] = relPath + } + } + currentPkg = "" + } + } + } + return entries +} + +// readExistingOverrides reads the current pubspec_overrides.yaml and returns +// a map of package name -> relative path. Returns nil if file doesn't exist. +func readExistingOverrides(overridePath string) map[string]string { + content, err := os.ReadFile(overridePath) + if err != nil { + return nil + } + entries := make(map[string]string) + var currentPkg string + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "dependency_overrides:" || strings.TrimSpace(line) == "{}" { + continue + } + if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { + if strings.HasSuffix(line, ":") { + currentPkg = strings.TrimSpace(strings.TrimSuffix(line, ":")) + continue + } + if strings.HasPrefix(line, "path:") && currentPkg != "" { + pathVal := strings.TrimSpace(strings.TrimPrefix(line, "path:")) + pathVal = strings.Trim(pathVal, "\"'") + if pathVal != "" { + entries[currentPkg] = pathVal + } + currentPkg = "" + } + } + } + return entries +} + +// writeOverrideFile reads existing overrides, merges pubspec.yaml overrides, +// adds new packages, and writes the merged result. Skips write if content unchanged. +func writeOverrideFile(overridePath, rootPath string, newPackages []domain.PlannedWorktree) error { + pubspecPath := filepath.Join(rootPath, "pubspec.yaml") + entries := readExistingOverrides(overridePath) + if entries == nil { + entries = make(map[string]string) + } + entries = mergePubspecOverrides(entries, pubspecPath) + for _, pkg := range newPackages { + rel, err := filepath.Rel(rootPath, pkg.Path) + if err != nil { + rel = pkg.Path + } + entries[pkg.Repo.PackageName] = filepath.ToSlash(rel) + } + newContent := formatOverrideYAML(entries) + existingContent, err := os.ReadFile(overridePath) + if err == nil && string(existingContent) == newContent { + return nil + } + return os.WriteFile(overridePath, []byte(newContent), 0o644) +} + +// formatOverrideYAML generates the YAML content from entries map, +// sorted alphabetically for deterministic output. +func formatOverrideYAML(entries map[string]string) string { + if len(entries) == 0 { + return "dependency_overrides:\n {}\n" + } + var sorted []string + for pkg := range entries { + sorted = append(sorted, pkg) + } + sort.Strings(sorted) + lines := []string{"dependency_overrides:"} + for _, pkg := range sorted { + lines = append(lines, " "+pkg+":") + lines = append(lines, " path: "+entries[pkg]) } return strings.Join(lines, "\n") + "\n" }