From 79bc2e02581e9408d870626d7aabe6b39a3f5d74 Mon Sep 17 00:00:00 2001 From: jcleira Date: Fri, 6 Feb 2026 07:19:33 +0100 Subject: [PATCH] Add Interactive Setup Wizard with TUI and Dashboard Access * Objective Introduce a full-screen setup wizard for configuring workspace directories, featuring a terminal UI and interactive directory browser. * Why This enhances the onboarding and configuration experience by providing a guided, user-friendly interface accessible from the dashboard. * How - Add 'S' keybinding in the dashboard to launch the setup wizard - Wizard displays current config values as editable defaults - Includes a welcome screen and explanations for each setting - Flow includes steps for repos dir, workspaces dir, and claude dir - Each step supports text input with validation and directory browsing (Tab toggles browser, arrows/Enter to navigate) - Confirmation screen shown before applying changes - Implemented in new `pkg/ui/setup` using Bubble Tea components - Introduce `DashboardResult` to signal setup invocation - Fix value receiver bug in `handleDirectoryKey` - Improve focus and cursor behavior in text inputs --- cmd/config/setup.go | 72 +----- cmd/root.go | 80 ++++-- go.mod | 1 + go.sum | 2 + pkg/config/config.go | 47 ++++ pkg/ui/dashboard/dashboard.go | 29 ++- pkg/ui/dashboard/keys.go | 7 +- pkg/ui/setup/directory_input.go | 358 ++++++++++++++++++++++++++ pkg/ui/setup/keys.go | 49 ++++ pkg/ui/setup/messages.go | 5 + pkg/ui/setup/setup.go | 429 ++++++++++++++++++++++++++++++++ pkg/ui/setup/styles.go | 89 +++++++ 12 files changed, 1080 insertions(+), 88 deletions(-) create mode 100644 pkg/ui/setup/directory_input.go create mode 100644 pkg/ui/setup/keys.go create mode 100644 pkg/ui/setup/messages.go create mode 100644 pkg/ui/setup/setup.go create mode 100644 pkg/ui/setup/styles.go diff --git a/cmd/config/setup.go b/cmd/config/setup.go index 4de958f..847adf6 100644 --- a/cmd/config/setup.go +++ b/cmd/config/setup.go @@ -1,16 +1,14 @@ package config import ( - "bufio" "fmt" "os" - "path/filepath" - "strings" "github.com/spf13/cobra" "github.com/jcleira/workspace/cmd" "github.com/jcleira/workspace/pkg/ui/commands" + "github.com/jcleira/workspace/pkg/ui/setup" "github.com/jcleira/workspace/pkg/workspace" ) @@ -28,70 +26,26 @@ func init() { } func runInteractiveSetup() { - reader := bufio.NewReader(os.Stdin) - homeDir := os.Getenv("HOME") - - fmt.Println() - fmt.Println(commands.TitleStyle.Render("Workspace Configuration Setup")) - fmt.Println() - commands.PrintInfo("Configure the directories where workspace will store its data.") - fmt.Println() - - fmt.Printf("Workspaces directory (where workspace projects will be created):\n") - fmt.Printf("Default: %s\n", commands.InfoStyle.Render(filepath.Join(homeDir, "workspaces"))) - fmt.Print("Enter path: ") - workspacesDir, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(workspacesDir) == "" { - workspacesDir = filepath.Join(homeDir, "workspaces") - } else { - workspacesDir = strings.TrimSpace(workspacesDir) - } - - fmt.Println() - fmt.Printf("Repositories directory (where git repositories will be cloned):\n") - fmt.Printf("Default: %s\n", commands.InfoStyle.Render(filepath.Join(homeDir, "repos"))) - fmt.Print("Enter path: ") - reposDir, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(reposDir) == "" { - reposDir = filepath.Join(homeDir, "repos") - } else { - reposDir = strings.TrimSpace(reposDir) - } - - fmt.Println() - fmt.Printf("Claude directory (shared .claude context directory):\n") - fmt.Printf("Default: %s\n", commands.InfoStyle.Render(filepath.Join(homeDir, ".claude"))) - fmt.Print("Enter path: ") - claudeDir, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(claudeDir) == "" { - claudeDir = filepath.Join(homeDir, ".claude") - } else { - claudeDir = strings.TrimSpace(claudeDir) - } - - fmt.Println() - commands.PrintInfo("Saving configuration...") - - if err := cmd.ConfigManager.SetWorkspacesDir(workspacesDir); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set workspaces directory: %v", err)) - os.Exit(1) - } + cfg := cmd.ConfigManager.GetConfig() - if err := cmd.ConfigManager.SetReposDir(reposDir); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set repos directory: %v", err)) + result, err := setup.RunSetupWizardWithDefaults( + cmd.ConfigManager, + cfg.ReposDir, + cfg.WorkspacesDir, + cfg.ClaudeDir, + ) + if err != nil { + commands.PrintError(fmt.Sprintf("Setup wizard failed: %v", err)) os.Exit(1) } - if err := cmd.ConfigManager.SetClaudeDir(claudeDir); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set claude directory: %v", err)) - os.Exit(1) + if !result.Completed { + return } - cfg := cmd.ConfigManager.GetConfig() + cfg = cmd.ConfigManager.GetConfig() cmd.WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) - fmt.Println() - commands.PrintSuccess("Configuration saved successfully!") fmt.Println() showConfig() } diff --git a/cmd/root.go b/cmd/root.go index a727ea6..87d98b0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "os" "github.com/spf13/cobra" @@ -10,6 +11,7 @@ import ( "github.com/jcleira/workspace/pkg/shell" "github.com/jcleira/workspace/pkg/ui/commands" "github.com/jcleira/workspace/pkg/ui/dashboard" + "github.com/jcleira/workspace/pkg/ui/setup" "github.com/jcleira/workspace/pkg/workspace" ) @@ -48,6 +50,16 @@ func InitializeConfig() error { return fmt.Errorf("failed to initialize configuration: %w", err) } + if !ConfigManager.IsInitialized() { + result, err := setup.RunSetupWizard(ConfigManager) + if err != nil { + return fmt.Errorf("setup wizard failed: %w", err) + } + if !result.Completed { + os.Exit(0) + } + } + cfg := ConfigManager.GetConfig() WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) @@ -60,33 +72,55 @@ func init() { } func runInteractiveWorkspaceSelector() { - workspaces, err := WorkspaceManager.GetWorkspaces() - if err != nil { - commands.PrintError(fmt.Sprintf("Failed to get workspaces: %v", err)) - return - } + for { + workspaces, err := WorkspaceManager.GetWorkspaces() + if err != nil { + commands.PrintError(fmt.Sprintf("Failed to get workspaces: %v", err)) + return + } - if len(workspaces) == 0 { - commands.PrintWarning("No workspaces found.") - fmt.Println("Create one with: workspace create ") - return - } + if len(workspaces) == 0 { + commands.PrintWarning("No workspaces found.") + fmt.Println("Create one with: workspace create ") + return + } - selectedPath, err := dashboard.RunDashboard(WorkspaceManager, ConfigManager) - if err != nil { - commands.PrintError(fmt.Sprintf("Dashboard error: %v", err)) - return - } + result, err := dashboard.RunDashboard(WorkspaceManager, ConfigManager) + if err != nil { + commands.PrintError(fmt.Sprintf("Dashboard error: %v", err)) + return + } + + if result.OpenSetup { + cfg := ConfigManager.GetConfig() + setupResult, err := setup.RunSetupWizardWithDefaults( + ConfigManager, + cfg.ReposDir, + cfg.WorkspacesDir, + cfg.ClaudeDir, + ) + if err != nil { + commands.PrintError(fmt.Sprintf("Setup wizard failed: %v", err)) + return + } + if setupResult.Completed { + cfg = ConfigManager.GetConfig() + WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) + } + continue + } - if selectedPath != "" { - if OutputPathOnly { - fmt.Println(selectedPath) - } else { - ws := workspace.Workspace{Path: selectedPath} - shell.NavigateToWorkspace(ws) + if result.SelectedPath != "" { + if OutputPathOnly { + fmt.Println(result.SelectedPath) + } else { + ws := workspace.Workspace{Path: result.SelectedPath} + shell.NavigateToWorkspace(ws) + } + } else if OutputPathOnly { + fmt.Println("quit") } - } else if OutputPathOnly { - fmt.Println("quit") + return } } diff --git a/go.mod b/go.mod index 5430fc8..f5b8b3f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect diff --git a/go.sum b/go.sum index 2a64966..02dd041 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= diff --git a/pkg/config/config.go b/pkg/config/config.go index 8b97161..4f62fa4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -14,6 +14,7 @@ type Config struct { ReposDir string `json:"repos_dir"` ClaudeDir string `json:"claude_dir"` IgnoredBranches []string `json:"ignored_branches,omitempty"` + Initialized bool `json:"initialized"` } // ConfigManager handles configuration operations @@ -204,3 +205,49 @@ func (cm *ConfigManager) ClearIgnoredBranches() error { cm.config.IgnoredBranches = []string{} return cm.saveConfig() } + +// IsInitialized checks if the workspace has been initialized +func (cm *ConfigManager) IsInitialized() bool { + if cm.config.Initialized { + return true + } + if _, err := os.Stat(cm.config.ReposDir); err == nil { + entries, err := os.ReadDir(cm.config.ReposDir) + if err == nil && len(entries) > 0 { + return true + } + } + return false +} + +// SetInitialized sets the initialized flag and saves the config +func (cm *ConfigManager) SetInitialized(initialized bool) error { + cm.config.Initialized = initialized + return cm.saveConfig() +} + +// UpdateConfig updates multiple config values and saves +func (cm *ConfigManager) UpdateConfig(workspacesDir, reposDir, claudeDir string) error { + if workspacesDir != "" { + absDir, err := filepath.Abs(workspacesDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for workspaces dir: %w", err) + } + cm.config.WorkspacesDir = absDir + } + if reposDir != "" { + absDir, err := filepath.Abs(reposDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for repos dir: %w", err) + } + cm.config.ReposDir = absDir + } + if claudeDir != "" { + absDir, err := filepath.Abs(claudeDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for claude dir: %w", err) + } + cm.config.ClaudeDir = absDir + } + return cm.saveConfig() +} diff --git a/pkg/ui/dashboard/dashboard.go b/pkg/ui/dashboard/dashboard.go index 2edda3c..044f9f7 100644 --- a/pkg/ui/dashboard/dashboard.go +++ b/pkg/ui/dashboard/dashboard.go @@ -56,6 +56,7 @@ type DashboardModel struct { selectedPath string quitting bool + openSetup bool } // NewDashboard creates a new dashboard model. @@ -183,6 +184,10 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit + case key.Matches(msg, m.keys.Settings): + m.openSetup = true + return m, tea.Quit + case key.Matches(msg, m.keys.Help): m.showHelp = true return m, nil @@ -560,8 +565,8 @@ func (m DashboardModel) renderFooter() string { helpKeyStyle.Render("Enter") + helpDescStyle.Render(" select"), helpKeyStyle.Render("f") + helpDescStyle.Render(" fetch"), helpKeyStyle.Render("p") + helpDescStyle.Render(" pull"), - helpKeyStyle.Render("G") + helpDescStyle.Render(" staged diff"), helpKeyStyle.Render("d") + helpDescStyle.Render(" delete"), + helpKeyStyle.Render("S") + helpDescStyle.Render(" settings"), helpKeyStyle.Render("?") + helpDescStyle.Render(" help"), helpKeyStyle.Render("q") + helpDescStyle.Render(" quit"), } @@ -583,16 +588,30 @@ func (m DashboardModel) SelectedPath() string { return m.selectedPath } -// RunDashboard runs the dashboard and returns the selected workspace path. -func RunDashboard(wm *workspace.Manager, cm *config.ConfigManager) (string, error) { +// OpenSetup returns true if the user requested to open the setup wizard. +func (m DashboardModel) OpenSetup() bool { + return m.openSetup +} + +// DashboardResult contains the result of running the dashboard. +type DashboardResult struct { + SelectedPath string + OpenSetup bool +} + +// RunDashboard runs the dashboard and returns the result. +func RunDashboard(wm *workspace.Manager, cm *config.ConfigManager) (DashboardResult, error) { m := NewDashboard(wm, cm) p := tea.NewProgram(m, tea.WithAltScreen()) finalModel, err := p.Run() if err != nil { - return "", err + return DashboardResult{}, err } dm := finalModel.(DashboardModel) - return dm.SelectedPath(), nil + return DashboardResult{ + SelectedPath: dm.SelectedPath(), + OpenSetup: dm.OpenSetup(), + }, nil } diff --git a/pkg/ui/dashboard/keys.go b/pkg/ui/dashboard/keys.go index d8587d3..6ee29a5 100644 --- a/pkg/ui/dashboard/keys.go +++ b/pkg/ui/dashboard/keys.go @@ -16,6 +16,7 @@ type KeyMap struct { Refresh key.Binding Diff key.Binding DiffStaged key.Binding + Settings key.Binding Help key.Binding Quit key.Binding Filter key.Binding @@ -74,6 +75,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("G"), key.WithHelp("G", "diff staged"), ), + Settings: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "settings"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "help"), @@ -109,6 +114,6 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.Select, k.Fetch, k.Pull}, {k.Diff, k.DiffStaged, k.Refresh}, {k.Delete, k.Create}, - {k.Filter, k.Help, k.Quit}, + {k.Settings, k.Filter, k.Help, k.Quit}, } } diff --git a/pkg/ui/setup/directory_input.go b/pkg/ui/setup/directory_input.go new file mode 100644 index 0000000..9ca44de --- /dev/null +++ b/pkg/ui/setup/directory_input.go @@ -0,0 +1,358 @@ +package setup + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func getHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "/" + } + return home +} + +type inputMode int + +const ( + textMode inputMode = iota + pickerMode +) + +type DirectoryInputModel struct { + textInput textinput.Model + mode inputMode + pickerPath string + pickerItems []string + pickerCursor int + pickerScroll int + maxVisible int + validation validationState + focused bool +} + +type validationState struct { + valid bool + exists bool + message string +} + +func NewDirectoryInputModel(placeholder, defaultValue string) DirectoryInputModel { + ti := textinput.New() + ti.Placeholder = placeholder + ti.SetValue(defaultValue) + ti.Focus() + ti.CharLimit = 256 + ti.Width = 40 + + homeDir := getHomeDir() + + m := DirectoryInputModel{ + textInput: ti, + mode: textMode, + pickerPath: homeDir, + pickerItems: []string{}, + pickerCursor: 0, + pickerScroll: 0, + maxVisible: 8, + focused: true, + } + + m.updateValidation() + m.loadPickerItems() + + return m +} + +func (m *DirectoryInputModel) Focus() { + m.focused = true + m.textInput.Focus() +} + +func (m *DirectoryInputModel) FocusCmd() tea.Cmd { + m.focused = true + return m.textInput.Focus() +} + +func (m *DirectoryInputModel) Blur() { + m.focused = false + m.textInput.Blur() +} + +func (m *DirectoryInputModel) Value() string { + return m.expandPath(m.textInput.Value()) +} + +func (m *DirectoryInputModel) SetValue(val string) { + m.textInput.SetValue(val) + m.updateValidation() +} + +func (m *DirectoryInputModel) IsValid() bool { + return m.validation.valid +} + +func (m DirectoryInputModel) Update(msg tea.Msg) (DirectoryInputModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + if m.mode == textMode { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.updateValidation() + return m, cmd + } + return m, nil + } + + if keyMsg.Type == tea.KeyTab || key.Matches(keyMsg, keys.Tab) { + m.toggleMode() + return m, nil + } + + if m.mode == pickerMode { + return m.handlePickerKey(keyMsg) + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.updateValidation() + return m, cmd +} + +func (m *DirectoryInputModel) toggleMode() { + if m.mode == textMode { + m.mode = pickerMode + expanded := m.expandPath(m.textInput.Value()) + if expanded != "" { + if info, err := os.Stat(expanded); err == nil && info.IsDir() { + m.pickerPath = expanded + } + } + m.loadPickerItems() + m.pickerCursor = 0 + m.pickerScroll = 0 + } else { + m.mode = textMode + m.textInput.Focus() + m.textInput.CursorEnd() + } +} + +func (m DirectoryInputModel) handlePickerKey(msg tea.KeyMsg) (DirectoryInputModel, tea.Cmd) { + switch { + case key.Matches(msg, keys.Back): + m.mode = textMode + m.textInput.Focus() + m.textInput.CursorEnd() + return m, nil + + case key.Matches(msg, keys.Up): + if m.pickerCursor > 0 { + m.pickerCursor-- + if m.pickerCursor < m.pickerScroll { + m.pickerScroll = m.pickerCursor + } + } + + case key.Matches(msg, keys.Down): + if m.pickerCursor < len(m.pickerItems)-1 { + m.pickerCursor++ + if m.pickerCursor >= m.pickerScroll+m.maxVisible { + m.pickerScroll = m.pickerCursor - m.maxVisible + 1 + } + } + + case key.Matches(msg, keys.Enter): + if len(m.pickerItems) > 0 { + selected := m.pickerItems[m.pickerCursor] + if selected == ".." { + m.pickerPath = filepath.Dir(m.pickerPath) + } else { + m.pickerPath = filepath.Join(m.pickerPath, selected) + } + m.loadPickerItems() + m.pickerCursor = 0 + m.pickerScroll = 0 + m.textInput.SetValue(m.contractPath(m.pickerPath)) + m.updateValidation() + } + + case key.Matches(msg, keys.PageUp): + m.pickerCursor -= m.maxVisible + if m.pickerCursor < 0 { + m.pickerCursor = 0 + } + if m.pickerCursor < m.pickerScroll { + m.pickerScroll = m.pickerCursor + } + + case key.Matches(msg, keys.PageDown): + m.pickerCursor += m.maxVisible + if m.pickerCursor >= len(m.pickerItems) { + m.pickerCursor = len(m.pickerItems) - 1 + } + if m.pickerCursor >= m.pickerScroll+m.maxVisible { + m.pickerScroll = m.pickerCursor - m.maxVisible + 1 + } + } + + return m, nil +} + +func (m *DirectoryInputModel) loadPickerItems() { + m.pickerItems = []string{} + + entries, err := os.ReadDir(m.pickerPath) + if err != nil { + return + } + + homeDir := getHomeDir() + if m.pickerPath != "/" && m.pickerPath != homeDir { + m.pickerItems = append(m.pickerItems, "..") + } + + var dirs []string + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + dirs = append(dirs, entry.Name()) + } + } + sort.Strings(dirs) + m.pickerItems = append(m.pickerItems, dirs...) +} + +func (m *DirectoryInputModel) updateValidation() { + path := m.expandPath(m.textInput.Value()) + + if path == "" { + m.validation = validationState{ + valid: false, + exists: false, + message: "Path cannot be empty", + } + return + } + + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + m.validation = validationState{ + valid: true, + exists: true, + message: "Directory exists", + } + } else { + m.validation = validationState{ + valid: false, + exists: true, + message: "Path exists but is not a directory", + } + } + return + } + + parentDir := filepath.Dir(path) + if _, err := os.Stat(parentDir); err == nil { + m.validation = validationState{ + valid: true, + exists: false, + message: "Directory will be created", + } + return + } + + m.validation = validationState{ + valid: true, + exists: false, + message: "Directory will be created", + } +} + +func (m *DirectoryInputModel) expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + homeDir := getHomeDir() + return filepath.Join(homeDir, path[2:]) + } + if path == "~" { + homeDir := getHomeDir() + return homeDir + } + return path +} + +func (m *DirectoryInputModel) contractPath(path string) string { + homeDir := getHomeDir() + if strings.HasPrefix(path, homeDir) { + return "~" + path[len(homeDir):] + } + return path +} + +func (m DirectoryInputModel) View() string { + var b strings.Builder + + if m.mode == textMode { + boxStyle := inputBoxStyle + if m.focused { + boxStyle = focusedInputBoxStyle + } + b.WriteString(boxStyle.Render(m.textInput.View())) + b.WriteString("\n") + + if m.validation.message != "" { + var style = validationSuccessStyle + prefix := "✓ " + if !m.validation.valid { + style = validationErrorStyle + prefix = "✗ " + } else if !m.validation.exists { + style = validationWarningStyle + prefix = "○ " + } + b.WriteString(style.Render(prefix + m.validation.message)) + } + } else { + b.WriteString(selectedPathStyle.Render("📁 Browse: " + m.contractPath(m.pickerPath))) + b.WriteString("\n") + + if len(m.pickerItems) == 0 { + b.WriteString(inputBoxStyle.Render("(no subdirectories)")) + } else { + visibleStart := m.pickerScroll + visibleEnd := min(visibleStart+m.maxVisible, len(m.pickerItems)) + + var pickerContent strings.Builder + for i := visibleStart; i < visibleEnd; i++ { + item := m.pickerItems[i] + cursor := " " + style := pickerItemStyle + if i == m.pickerCursor { + cursor = "> " + style = pickerSelectedStyle + } + if item != ".." { + style = pickerDirStyle + } + pickerContent.WriteString(cursor + style.Render(item) + "\n") + } + + b.WriteString(inputBoxStyle.Render(strings.TrimRight(pickerContent.String(), "\n"))) + } + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("↑/↓: navigate Enter: open Tab: select")) + } + + return b.String() +} + +func (m DirectoryInputModel) Mode() inputMode { + return m.mode +} diff --git a/pkg/ui/setup/keys.go b/pkg/ui/setup/keys.go new file mode 100644 index 0000000..4a211a7 --- /dev/null +++ b/pkg/ui/setup/keys.go @@ -0,0 +1,49 @@ +package setup + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Tab key.Binding + Back key.Binding + Quit key.Binding + PageUp key.Binding + PageDown key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Tab: key.NewBinding( + key.WithKeys("tab", "ctrl+t"), + key.WithHelp("tab/ctrl+t", "browse"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("pgdn", "page down"), + ), +} diff --git a/pkg/ui/setup/messages.go b/pkg/ui/setup/messages.go new file mode 100644 index 0000000..ca64a60 --- /dev/null +++ b/pkg/ui/setup/messages.go @@ -0,0 +1,5 @@ +package setup + +type configSavedMsg struct { + err error +} diff --git a/pkg/ui/setup/setup.go b/pkg/ui/setup/setup.go new file mode 100644 index 0000000..43ca402 --- /dev/null +++ b/pkg/ui/setup/setup.go @@ -0,0 +1,429 @@ +// Package setup provides the first-run setup wizard for the workspace CLI. +package setup + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jcleira/workspace/pkg/config" +) + +type step int + +const ( + stepWelcome step = iota + stepReposDir + stepWorkspacesDir + stepClaudeDir + stepConfirm + stepComplete +) + +type SetupResult struct { + Completed bool + Config *config.Config +} + +type SetupModel struct { + configManager *config.ConfigManager + step step + reposInput DirectoryInputModel + workspacesInput DirectoryInputModel + claudeInput DirectoryInputModel + width int + height int + err error + quitting bool +} + +func NewSetupModel(cm *config.ConfigManager) SetupModel { + homeDir := getHomeDir() + + return SetupModel{ + configManager: cm, + step: stepWelcome, + reposInput: NewDirectoryInputModel("~/repos", homeDir+"/repos"), + workspacesInput: NewDirectoryInputModel("~/workspaces", homeDir+"/workspaces"), + claudeInput: NewDirectoryInputModel("~/.claude", homeDir+"/.claude"), + } +} + +func NewSetupModelWithDefaults(cm *config.ConfigManager, reposDir, workspacesDir, claudeDir string) SetupModel { + return SetupModel{ + configManager: cm, + step: stepWelcome, + reposInput: NewDirectoryInputModel("~/repos", contractPath(reposDir)), + workspacesInput: NewDirectoryInputModel("~/workspaces", contractPath(workspacesDir)), + claudeInput: NewDirectoryInputModel("~/.claude", contractPath(claudeDir)), + } +} + +func contractPath(path string) string { + homeDir := getHomeDir() + if len(path) > len(homeDir) && path[:len(homeDir)] == homeDir { + return "~" + path[len(homeDir):] + } + return path +} + +func (m SetupModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + if key.Matches(msg, keys.Quit) && m.step != stepComplete { + m.quitting = true + return m, tea.Quit + } + + switch m.step { + case stepWelcome: + return m.handleWelcomeKey(msg) + case stepReposDir: + input, newStep, cmd := handleDirectoryKey(msg, m.reposInput, m.step, stepWelcome, stepWorkspacesDir) + m.reposInput = input + m.step = newStep + if newStep == stepWorkspacesDir { + return m, m.workspacesInput.FocusCmd() + } + return m, cmd + case stepWorkspacesDir: + input, newStep, cmd := handleDirectoryKey(msg, m.workspacesInput, m.step, stepReposDir, stepClaudeDir) + m.workspacesInput = input + m.step = newStep + if newStep == stepClaudeDir { + return m, m.claudeInput.FocusCmd() + } + return m, cmd + case stepClaudeDir: + input, newStep, cmd := handleDirectoryKey(msg, m.claudeInput, m.step, stepWorkspacesDir, stepConfirm) + m.claudeInput = input + m.step = newStep + return m, cmd + case stepConfirm: + return m.handleConfirmKey(msg) + case stepComplete: + return m.handleCompleteKey(msg) + } + + case configSavedMsg: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.step = stepComplete + return m, nil + + default: + var cmd tea.Cmd + switch m.step { //nolint:exhaustive + case stepReposDir: + m.reposInput, cmd = m.reposInput.Update(msg) + case stepWorkspacesDir: + m.workspacesInput, cmd = m.workspacesInput.Update(msg) + case stepClaudeDir: + m.claudeInput, cmd = m.claudeInput.Update(msg) + } + return m, cmd + } + + return m, nil +} + +func (m SetupModel) handleWelcomeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, keys.Enter) { + m.step = stepReposDir + return m, m.reposInput.FocusCmd() + } + return m, nil +} + +func handleDirectoryKey(msg tea.KeyMsg, input DirectoryInputModel, currentStep, prevStep, nextStep step) (DirectoryInputModel, step, tea.Cmd) { + if msg.Type == tea.KeyTab || key.Matches(msg, keys.Tab) { + updated, cmd := input.Update(msg) + return updated, currentStep, cmd + } + + if input.Mode() == pickerMode { + if key.Matches(msg, keys.Up) || key.Matches(msg, keys.Down) || + key.Matches(msg, keys.Enter) || key.Matches(msg, keys.PageUp) || key.Matches(msg, keys.PageDown) { + updated, cmd := input.Update(msg) + return updated, currentStep, cmd + } + } + + switch { + case key.Matches(msg, keys.Enter): + if input.Mode() == textMode && input.IsValid() { + return input, nextStep, nil + } + return input, currentStep, nil + + case key.Matches(msg, keys.Back): + if input.Mode() == pickerMode { + updated, cmd := input.Update(msg) + return updated, currentStep, cmd + } + return input, prevStep, nil + + default: + updated, cmd := input.Update(msg) + return updated, currentStep, cmd + } +} + +func (m SetupModel) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Enter): + return m, m.saveConfig() + case key.Matches(msg, keys.Back): + m.step = stepClaudeDir + return m, nil + } + return m, nil +} + +func (m SetupModel) handleCompleteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, keys.Enter) || key.Matches(msg, keys.Quit) { + return m, tea.Quit + } + return m, nil +} + +func (m SetupModel) saveConfig() tea.Cmd { + return func() tea.Msg { + reposDir := m.reposInput.Value() + workspacesDir := m.workspacesInput.Value() + claudeDir := m.claudeInput.Value() + + if err := os.MkdirAll(reposDir, 0o755); err != nil { + return configSavedMsg{err: fmt.Errorf("failed to create repos directory: %w", err)} + } + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + return configSavedMsg{err: fmt.Errorf("failed to create workspaces directory: %w", err)} + } + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + return configSavedMsg{err: fmt.Errorf("failed to create claude directory: %w", err)} + } + + if err := m.configManager.UpdateConfig(workspacesDir, reposDir, claudeDir); err != nil { + return configSavedMsg{err: fmt.Errorf("failed to save config: %w", err)} + } + + if err := m.configManager.SetInitialized(true); err != nil { + return configSavedMsg{err: fmt.Errorf("failed to set initialized: %w", err)} + } + + return configSavedMsg{err: nil} + } +} + +func (m SetupModel) View() string { + if m.quitting { + return "" + } + + var content string + + switch m.step { + case stepWelcome: + content = m.viewWelcome() + case stepReposDir: + content = m.viewReposDir() + case stepWorkspacesDir: + content = m.viewWorkspacesDir() + case stepClaudeDir: + content = m.viewClaudeDir() + case stepConfirm: + content = m.viewConfirm() + case stepComplete: + content = m.viewComplete() + } + + box := wizardStyle.Render(content) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + +func (m SetupModel) viewWelcome() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Welcome to Workspace CLI")) + b.WriteString("\n\n") + b.WriteString("Manage multiple git repos as isolated\nworkspaces using git worktrees.\n\n") + b.WriteString("This setup will configure:\n\n") + b.WriteString("• Repositories directory\n") + b.WriteString(" Where your main git repos are cloned\n\n") + b.WriteString("• Workspaces directory\n") + b.WriteString(" Where isolated worktree copies are created\n\n") + b.WriteString("• Claude directory\n") + b.WriteString(" Shared config synced across workspaces\n\n") + b.WriteString(helpKeyStyle.Render("Enter")) + b.WriteString(helpStyle.Render(": Start")) + + return b.String() +} + +func (m SetupModel) viewReposDir() string { + var b strings.Builder + + b.WriteString(stepIndicatorStyle.Render("Step 1 of 3")) + b.WriteString("\n") + b.WriteString(titleStyle.Render("Repository Directory")) + b.WriteString("\n\n") + b.WriteString(subtitleStyle.Render("Where are your git repositories stored?")) + b.WriteString("\n\n") + b.WriteString(m.reposInput.View()) + if m.reposInput.Mode() == textMode { + b.WriteString("\n\n") + b.WriteString(m.directoryInputHelp()) + } + + return b.String() +} + +func (m SetupModel) viewWorkspacesDir() string { + var b strings.Builder + + b.WriteString(stepIndicatorStyle.Render("Step 2 of 3")) + b.WriteString("\n") + b.WriteString(titleStyle.Render("Workspaces Directory")) + b.WriteString("\n\n") + b.WriteString(subtitleStyle.Render("Where should workspaces be created?")) + b.WriteString("\n\n") + b.WriteString(m.workspacesInput.View()) + if m.workspacesInput.Mode() == textMode { + b.WriteString("\n\n") + b.WriteString(m.directoryInputHelp()) + } + + return b.String() +} + +func (m SetupModel) viewClaudeDir() string { + var b strings.Builder + + b.WriteString(stepIndicatorStyle.Render("Step 3 of 3")) + b.WriteString("\n") + b.WriteString(titleStyle.Render("Claude Directory")) + b.WriteString("\n\n") + b.WriteString(subtitleStyle.Render("Where is your shared .claude directory?")) + b.WriteString("\n\n") + b.WriteString(m.claudeInput.View()) + if m.claudeInput.Mode() == textMode { + b.WriteString("\n\n") + b.WriteString(m.directoryInputHelp()) + } + + return b.String() +} + +func (m SetupModel) directoryInputHelp() string { + var b strings.Builder + b.WriteString(helpKeyStyle.Render("Enter")) + b.WriteString(helpDescStyle.Render(": Continue ")) + b.WriteString(helpKeyStyle.Render("Tab")) + b.WriteString(helpDescStyle.Render(": Browse")) + return b.String() +} + +func (m SetupModel) viewConfirm() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Confirm Configuration")) + b.WriteString("\n\n") + + b.WriteString(summaryLabelStyle.Render("Repositories: ")) + b.WriteString(summaryValueStyle.Render(m.reposInput.Value())) + b.WriteString("\n") + b.WriteString(summaryLabelStyle.Render("Workspaces: ")) + b.WriteString(summaryValueStyle.Render(m.workspacesInput.Value())) + b.WriteString("\n") + b.WriteString(summaryLabelStyle.Render("Claude: ")) + b.WriteString(summaryValueStyle.Render(m.claudeInput.Value())) + b.WriteString("\n\n") + + if m.err != nil { + b.WriteString(validationErrorStyle.Render("Error: " + m.err.Error())) + b.WriteString("\n\n") + } + + b.WriteString(helpKeyStyle.Render("Enter")) + b.WriteString(helpDescStyle.Render(": Save ")) + b.WriteString(helpKeyStyle.Render("Esc")) + b.WriteString(helpDescStyle.Render(": Back")) + + return b.String() +} + +func (m SetupModel) viewComplete() string { + var b strings.Builder + + b.WriteString(successTitleStyle.Render("Setup Complete!")) + b.WriteString("\n\n") + b.WriteString(subtitleStyle.Render("Your workspace CLI is now configured.")) + b.WriteString("\n\n") + b.WriteString(subtitleStyle.Render("Next steps:")) + b.WriteString("\n") + b.WriteString(subtitleStyle.Render(" 1. Clone repositories to " + m.reposInput.Value())) + b.WriteString("\n") + b.WriteString(subtitleStyle.Render(" 2. Run 'workspace create ' to create a workspace")) + b.WriteString("\n\n") + b.WriteString(helpKeyStyle.Render("Enter")) + b.WriteString(helpDescStyle.Render(": Continue")) + + return b.String() +} + +func RunSetupWizard(cm *config.ConfigManager) (SetupResult, error) { + model := NewSetupModel(cm) + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInputTTY()) + + finalModel, err := p.Run() + if err != nil { + return SetupResult{}, err + } + + m := finalModel.(SetupModel) + if m.quitting { + return SetupResult{Completed: false}, nil + } + + return SetupResult{ + Completed: m.step == stepComplete, + Config: cm.GetConfig(), + }, nil +} + +func RunSetupWizardWithDefaults(cm *config.ConfigManager, reposDir, workspacesDir, claudeDir string) (SetupResult, error) { + model := NewSetupModelWithDefaults(cm, reposDir, workspacesDir, claudeDir) + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInputTTY()) + + finalModel, err := p.Run() + if err != nil { + return SetupResult{}, err + } + + m := finalModel.(SetupModel) + if m.quitting { + return SetupResult{Completed: false}, nil + } + + return SetupResult{ + Completed: m.step == stepComplete, + Config: cm.GetConfig(), + }, nil +} diff --git a/pkg/ui/setup/styles.go b/pkg/ui/setup/styles.go new file mode 100644 index 0000000..5aa4db9 --- /dev/null +++ b/pkg/ui/setup/styles.go @@ -0,0 +1,89 @@ +package setup + +import "github.com/charmbracelet/lipgloss" + +var ( + primaryColor = lipgloss.Color("62") + successColor = lipgloss.Color("46") + warningColor = lipgloss.Color("226") + errorColor = lipgloss.Color("196") + subtleColor = lipgloss.Color("241") + highlightColor = lipgloss.Color("39") + + wizardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(1, 2). + Width(65) + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(1) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + MarginBottom(1) + + stepIndicatorStyle = lipgloss.NewStyle(). + Foreground(subtleColor). + MarginBottom(1) + + inputBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(subtleColor). + Padding(0, 1). + Width(55) + + focusedInputBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(highlightColor). + Padding(0, 1). + Width(55) + + validationSuccessStyle = lipgloss.NewStyle(). + Foreground(successColor) + + validationWarningStyle = lipgloss.NewStyle(). + Foreground(warningColor) + + validationErrorStyle = lipgloss.NewStyle(). + Foreground(errorColor) + + helpStyle = lipgloss.NewStyle(). + Foreground(subtleColor). + MarginTop(1) + + helpKeyStyle = lipgloss.NewStyle(). + Foreground(highlightColor). + Bold(true) + + helpDescStyle = lipgloss.NewStyle(). + Foreground(subtleColor) + + selectedPathStyle = lipgloss.NewStyle(). + Foreground(highlightColor). + Bold(true) + + pickerItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + pickerSelectedStyle = lipgloss.NewStyle(). + Foreground(highlightColor). + Bold(true) + + pickerDirStyle = lipgloss.NewStyle(). + Foreground(primaryColor) + + summaryLabelStyle = lipgloss.NewStyle(). + Foreground(subtleColor) + + summaryValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Bold(true) + + successTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(successColor). + MarginBottom(1) +)