diff --git a/README.md b/README.md index 6f09dd7c..c1972c15 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ Press `@` to switch between configured projects without restarting sidecar. All plugins reinitialize with the new project context. State (active plugin, cursor positions) is remembered per project. +## Worktree Switcher + +Press `W` to switch between git worktrees within the current repository. When you switch away from a project and return later, sidecar remembers which worktree you were working in and restores it automatically. + ## Themes Press `#` to open the theme switcher. Choose from built-in themes (default, dracula) or press `Tab` to browse 453 community color schemes derived from iTerm2-Color-Schemes. @@ -189,6 +193,7 @@ See [Theme Creator Guide](docs/guides/theme-creator-guide.md) for custom theme c | ------------------- | -------------------------------- | | `q`, `ctrl+c` | Quit | | `@` | Open project switcher | +| `W` | Open worktree switcher | | `#` | Open theme switcher | | `tab` / `shift+tab` | Navigate plugins | | `1-9` | Focus plugin by number | diff --git a/internal/app/commands.go b/internal/app/commands.go index 5bc1afdb..339e2f62 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -90,6 +90,39 @@ type FocusPluginByIDMsg struct { PluginID string } +// SwitchWorktreeMsg requests switching to a different worktree. +// Used by the worktree switcher modal and workspace plugin "Open in Git Tab" command. +type SwitchWorktreeMsg struct { + WorktreePath string // Absolute path to the worktree +} + +// SwitchWorktree returns a command that requests switching to a worktree by path. +func SwitchWorktree(path string) tea.Cmd { + return func() tea.Msg { + return SwitchWorktreeMsg{WorktreePath: path} + } +} + +// WorktreeDeletedMsg is sent when the current worktree has been deleted. +type WorktreeDeletedMsg struct { + DeletedPath string // Path of the deleted worktree + MainPath string // Path to switch to (main worktree) +} + +// checkWorktreeExists returns a command that checks if the current worktree still exists. +func checkWorktreeExists(workDir string) tea.Cmd { + return func() tea.Msg { + exists, mainPath := CheckCurrentWorktree(workDir) + if !exists && mainPath != "" { + return WorktreeDeletedMsg{ + DeletedPath: workDir, + MainPath: mainPath, + } + } + return nil + } +} + // FocusPlugin returns a command that requests focusing a plugin by ID. func FocusPlugin(pluginID string) tea.Cmd { return func() tea.Msg { diff --git a/internal/app/git.go b/internal/app/git.go index 3039f848..f396f0c8 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -1,6 +1,7 @@ package app import ( + "os" "os/exec" "path/filepath" "strings" @@ -192,3 +193,51 @@ func parseRepoNameFromURL(url string) string { return url } + +// WorktreeExists checks if the given worktree path still exists and is valid. +// Returns false if the directory doesn't exist or is not a valid git worktree. +func WorktreeExists(worktreePath string) bool { + // Check if directory exists + info, err := os.Stat(worktreePath) + if err != nil || !info.IsDir() { + return false + } + + // Verify it's still a valid git worktree by checking for .git file/directory + gitPath := filepath.Join(worktreePath, ".git") + _, err = os.Stat(gitPath) + return err == nil +} + +// CheckCurrentWorktree checks if the current working directory is still a valid worktree. +// Returns (exists, mainPath) where mainPath is the path to switch to if worktree was deleted. +func CheckCurrentWorktree(workDir string) (exists bool, mainPath string) { + if WorktreeExists(workDir) { + return true, "" + } + + // Current worktree doesn't exist - find the main worktree + // We need to use a different approach since workDir doesn't exist + // Try to get the main worktree from the .git file if it still exists elsewhere + + // Look for any worktree that still exists by checking parent directory + parentDir := filepath.Dir(workDir) + entries, err := os.ReadDir(parentDir) + if err != nil { + return false, "" + } + + for _, entry := range entries { + if entry.IsDir() { + candidatePath := filepath.Join(parentDir, entry.Name()) + if WorktreeExists(candidatePath) { + main := GetMainWorktreePath(candidatePath) + if main != "" { + return false, main + } + } + } + } + + return false, "" +} diff --git a/internal/app/model.go b/internal/app/model.go index dedd542a..c496fdbd 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -28,13 +28,14 @@ import ( type ModalKind int const ( - ModalNone ModalKind = iota // No modal open - ModalPalette // Command palette (highest priority) - ModalHelp // Help overlay - ModalDiagnostics // Diagnostics/version info - ModalQuitConfirm // Quit confirmation dialog - ModalProjectSwitcher // Project switcher - ModalThemeSwitcher // Theme switcher (lowest priority) + ModalNone ModalKind = iota // No modal open + ModalPalette // Command palette (highest priority) + ModalHelp // Help overlay + ModalDiagnostics // Diagnostics/version info + ModalQuitConfirm // Quit confirmation dialog + ModalProjectSwitcher // Project switcher + ModalWorktreeSwitcher // Worktree switcher + ModalThemeSwitcher // Theme switcher (lowest priority) ) // activeModal returns the highest-priority open modal. @@ -51,6 +52,8 @@ func (m *Model) activeModal() ModalKind { return ModalQuitConfirm case m.showProjectSwitcher: return ModalProjectSwitcher + case m.showWorktreeSwitcher: + return ModalWorktreeSwitcher case m.showThemeSwitcher: return ModalThemeSwitcher default: @@ -129,6 +132,19 @@ type Model struct { projectAddCommunityCursor int projectAddCommunityScroll int + // Worktree switcher modal + showWorktreeSwitcher bool + worktreeSwitcherCursor int + worktreeSwitcherScroll int + worktreeSwitcherInput textinput.Model + worktreeSwitcherFiltered []WorktreeInfo + worktreeSwitcherAll []WorktreeInfo + worktreeSwitcherModal *modal.Modal + worktreeSwitcherModalWidth int + worktreeSwitcherMouseHandler *mouse.Handler + activeWorktreePath string // Currently active worktree path (empty = main repo) + worktreeCheckCounter int // Counter for periodic worktree existence check + // Theme switcher modal showThemeSwitcher bool themeSwitcherModal *modal.Modal @@ -480,17 +496,50 @@ func (m *Model) switchProject(projectPath string) tea.Cmd { state.SetActivePlugin(oldWorkDir, activePlugin.ID()) } + // Normalize old workdir for comparisons + normalizedOldWorkDir, _ := normalizePath(oldWorkDir) + + // Check if target project has a saved worktree we should restore. + // Only restore if projectPath is the main repo - if user explicitly chose a + // specific worktree path (via worktree switcher), respect that choice. + targetPath := projectPath + if targetMainRepo := GetMainWorktreePath(projectPath); targetMainRepo != "" { + normalizedProject, _ := normalizePath(projectPath) + normalizedTargetMain, _ := normalizePath(targetMainRepo) + + // Only restore saved worktree if switching to the main repo path + if normalizedProject == normalizedTargetMain { + if savedWorktree := state.GetLastWorktreePath(normalizedTargetMain); savedWorktree != "" { + // Don't restore if the saved worktree is where we're coming FROM + // (user is explicitly leaving that worktree) + if savedWorktree != normalizedOldWorkDir { + // Verify saved worktree still exists + if WorktreeExists(savedWorktree) { + targetPath = savedWorktree + } else { + // Stale entry - clear it + state.ClearLastWorktreePath(normalizedTargetMain) + } + } + } + } + + // Save the final target as last active worktree for this repo + normalizedTarget, _ := normalizePath(targetPath) + state.SetLastWorktreePath(normalizedTargetMain, normalizedTarget) + } + // Update the UI state - m.ui.WorkDir = projectPath - m.intro.RepoName = GetRepoName(projectPath) + m.ui.WorkDir = targetPath + m.intro.RepoName = GetRepoName(targetPath) // Apply project-specific theme (or global fallback) - resolved := theme.ResolveTheme(m.cfg, projectPath) + resolved := theme.ResolveTheme(m.cfg, targetPath) theme.ApplyResolved(resolved) // Reinitialize all plugins with the new working directory // This stops all plugins, updates the context, and starts them again - startCmds := m.registry.Reinit(projectPath) + startCmds := m.registry.Reinit(targetPath) // Send WindowSizeMsg to all plugins so they recalculate layout/bounds. // Without this, plugins like td-monitor lose mouse interactivity because @@ -510,7 +559,7 @@ func (m *Model) switchProject(projectPath string) tea.Cmd { } // Restore active plugin for the new workdir if saved, otherwise keep current - newActivePluginID := state.GetActivePlugin(projectPath) + newActivePluginID := state.GetActivePlugin(targetPath) if newActivePluginID != "" { m.FocusPluginByID(newActivePluginID) } @@ -520,7 +569,7 @@ func (m *Model) switchProject(projectPath string) tea.Cmd { tea.Batch(startCmds...), func() tea.Msg { return ToastMsg{ - Message: fmt.Sprintf("Switched to %s", GetRepoName(projectPath)), + Message: fmt.Sprintf("Switched to %s", GetRepoName(targetPath)), Duration: 3 * time.Second, } }, diff --git a/internal/app/update.go b/internal/app/update.go index 297a1822..672c3a81 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -75,6 +75,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleProjectAddModalMouse(msg) } return m.handleProjectSwitcherMouse(msg) + case ModalWorktreeSwitcher: + return m.handleWorktreeSwitcherMouse(msg) case ModalThemeSwitcher: if m.showCommunityBrowser { return m.handleCommunityBrowserMouse(msg) @@ -139,6 +141,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ui.UpdateClock() m.ui.ClearExpiredToast() m.ClearToast() + // Periodically check if current worktree still exists (every 10 seconds) + m.worktreeCheckCounter++ + if m.worktreeCheckCounter >= 10 { + m.worktreeCheckCounter = 0 + return m, tea.Batch(tickCmd(), checkWorktreeExists(m.ui.WorkDir)) + } return m, tickCmd() case UpdateSpinnerTickMsg: @@ -197,6 +205,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Switch to requested plugin return m, m.FocusPluginByID(msg.PluginID) + case SwitchWorktreeMsg: + // Switch to the requested worktree + return m, m.switchWorktree(msg.WorktreePath) + + case WorktreeDeletedMsg: + // Current worktree was deleted - switch to main + return m, tea.Batch( + m.switchWorktree(msg.MainPath), + ShowToast("Worktree deleted, switched to main", 3*time.Second), + ) + case plugin.OpenFileMsg: // Open file in editor using tea.ExecProcess // Most editors support +lineNo syntax for opening at a line @@ -307,6 +326,18 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.resetProjectSwitcher() m.updateContext() return m, nil + case ModalWorktreeSwitcher: + // Esc: clear filter if set, otherwise close + if m.worktreeSwitcherInput.Value() != "" { + m.worktreeSwitcherInput.SetValue("") + m.worktreeSwitcherFiltered = m.worktreeSwitcherAll + m.worktreeSwitcherCursor = 0 + m.worktreeSwitcherScroll = 0 + return m, nil + } + m.resetWorktreeSwitcher() + m.updateContext() + return m, nil case ModalThemeSwitcher: if m.showCommunityBrowser { // Esc in community browser: clear filter or return to built-in view @@ -447,6 +478,89 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // Handle worktree switcher modal keys (Esc handled above) + if m.showWorktreeSwitcher { + worktrees := m.worktreeSwitcherFiltered + + switch msg.Type { + case tea.KeyEnter: + // Select worktree and switch to it + if m.worktreeSwitcherCursor >= 0 && m.worktreeSwitcherCursor < len(worktrees) { + selectedPath := worktrees[m.worktreeSwitcherCursor].Path + m.resetWorktreeSwitcher() + m.updateContext() + return m, m.switchWorktree(selectedPath) + } + return m, nil + + case tea.KeyUp: + m.worktreeSwitcherCursor-- + if m.worktreeSwitcherCursor < 0 { + m.worktreeSwitcherCursor = 0 + } + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + return m, nil + + case tea.KeyDown: + m.worktreeSwitcherCursor++ + if m.worktreeSwitcherCursor >= len(worktrees) { + m.worktreeSwitcherCursor = len(worktrees) - 1 + } + if m.worktreeSwitcherCursor < 0 { + m.worktreeSwitcherCursor = 0 + } + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + return m, nil + } + + // Handle non-text shortcuts + switch msg.String() { + case "ctrl+n": + m.worktreeSwitcherCursor++ + if m.worktreeSwitcherCursor >= len(worktrees) { + m.worktreeSwitcherCursor = len(worktrees) - 1 + } + if m.worktreeSwitcherCursor < 0 { + m.worktreeSwitcherCursor = 0 + } + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + return m, nil + + case "ctrl+p": + m.worktreeSwitcherCursor-- + if m.worktreeSwitcherCursor < 0 { + m.worktreeSwitcherCursor = 0 + } + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + return m, nil + + case "W": + // Close modal with same key + m.resetWorktreeSwitcher() + m.updateContext() + return m, nil + } + + // Forward other keys to text input for filtering + var cmd tea.Cmd + m.worktreeSwitcherInput, cmd = m.worktreeSwitcherInput.Update(msg) + + // Re-filter on input change + m.worktreeSwitcherFiltered = filterWorktrees(m.worktreeSwitcherAll, m.worktreeSwitcherInput.Value()) + m.clearWorktreeSwitcherModal() // Clear modal cache on filter change + // Reset cursor if it's beyond filtered list + if m.worktreeSwitcherCursor >= len(m.worktreeSwitcherFiltered) { + m.worktreeSwitcherCursor = len(m.worktreeSwitcherFiltered) - 1 + } + if m.worktreeSwitcherCursor < 0 { + m.worktreeSwitcherCursor = 0 + } + m.worktreeSwitcherScroll = 0 + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + + return m, cmd + } + // Handle project switcher modal keys (Esc handled above) if m.showProjectSwitcher { // Handle project add sub-mode keys @@ -773,6 +887,25 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateContext() } return m, nil + case "W": + // Toggle worktree switcher modal (capital W) + // Only enable if we're in a git repo with worktrees + worktrees := GetWorktrees(m.ui.WorkDir) + if len(worktrees) <= 1 { + // No worktrees or only main repo - show toast + return m, func() tea.Msg { + return ToastMsg{Message: "No worktrees found", Duration: 2 * time.Second} + } + } + m.showWorktreeSwitcher = !m.showWorktreeSwitcher + if m.showWorktreeSwitcher { + m.activeContext = "worktree-switcher" + m.initWorktreeSwitcher() + } else { + m.resetWorktreeSwitcher() + m.updateContext() + } + return m, nil case "#": // Toggle theme switcher modal m.showThemeSwitcher = !m.showThemeSwitcher diff --git a/internal/app/view.go b/internal/app/view.go index a19d86fc..c069e2e4 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -84,6 +84,8 @@ func (m Model) View() string { return m.renderQuitConfirmOverlay(bg) case ModalProjectSwitcher: return m.renderProjectSwitcherOverlay(bg) + case ModalWorktreeSwitcher: + return m.renderWorktreeSwitcherModal(bg) case ModalThemeSwitcher: return m.renderThemeSwitcherModal(bg) } @@ -588,26 +590,38 @@ func (m Model) renderCommunityBrowserOverlay(content string) string { // renderHeader renders the top bar with title, tabs, and clock. func (m Model) renderHeader() string { - // Calculate final title width (with repo name) - used for tab positioning + // Check if we're in a worktree for the indicator + worktreeIndicator := "" + if wtInfo := m.currentWorktreeInfo(); wtInfo != nil && !wtInfo.IsMain { + // Show worktree branch name as indicator + branchName := wtInfo.Branch + if branchName == "" { + branchName = "worktree" + } + worktreeIndicator = styles.WorktreeIndicator.Render(" [" + branchName + "]") + } + + // Calculate final title width (with repo name and worktree indicator) - used for tab positioning finalTitleWidth := lipgloss.Width(styles.BarTitle.Render(" Sidecar")) if m.intro.RepoName != "" { finalTitleWidth += lipgloss.Width(styles.Subtitle.Render(" / " + m.intro.RepoName)) } + finalTitleWidth += lipgloss.Width(worktreeIndicator) finalTitleWidth += 1 // trailing space - // Title with optional repo name + // Title with optional repo name and worktree indicator var title string if m.intro.Active { // During animation, render into fixed-width container to keep tabs stable - titleContent := styles.BarTitle.Render(" "+m.intro.View()) + m.intro.RepoNameView() + " " + titleContent := styles.BarTitle.Render(" "+m.intro.View()) + m.intro.RepoNameView() + worktreeIndicator + " " title = lipgloss.NewStyle().Width(finalTitleWidth).Render(titleContent) } else { - // Static title with repo name + // Static title with repo name and worktree indicator repoSuffix := "" if m.intro.RepoName != "" { repoSuffix = styles.Subtitle.Render(" / " + m.intro.RepoName) } - title = styles.BarTitle.Render(" Sidecar") + repoSuffix + " " + title = styles.BarTitle.Render(" Sidecar") + repoSuffix + worktreeIndicator + " " } // Plugin tabs (themed) @@ -646,6 +660,14 @@ func (m Model) getTabBounds() []TabBounds { if m.intro.RepoName != "" { titleWidth += lipgloss.Width(styles.Subtitle.Render(" / " + m.intro.RepoName)) } + // Add worktree indicator width if applicable + if wtInfo := m.currentWorktreeInfo(); wtInfo != nil && !wtInfo.IsMain { + branchName := wtInfo.Branch + if branchName == "" { + branchName = "worktree" + } + titleWidth += lipgloss.Width(styles.WorktreeIndicator.Render(" [" + branchName + "]")) + } titleWidth += 1 // trailing space // Calculate tab widths (using themed renderer) diff --git a/internal/app/worktree_switcher_modal.go b/internal/app/worktree_switcher_modal.go new file mode 100644 index 00000000..3e6f410e --- /dev/null +++ b/internal/app/worktree_switcher_modal.go @@ -0,0 +1,437 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/marcus/sidecar/internal/modal" + "github.com/marcus/sidecar/internal/mouse" + "github.com/marcus/sidecar/internal/styles" + "github.com/marcus/sidecar/internal/ui" +) + +const ( + worktreeSwitcherFilterID = "worktree-switcher-filter" + worktreeSwitcherItemPrefix = "worktree-switcher-item-" +) + +// worktreeSwitcherItemID returns the ID for a worktree item at the given index. +func worktreeSwitcherItemID(idx int) string { + return fmt.Sprintf("%s%d", worktreeSwitcherItemPrefix, idx) +} + +// initWorktreeSwitcher initializes the worktree switcher modal. +func (m *Model) initWorktreeSwitcher() { + m.clearWorktreeSwitcherModal() + + ti := textinput.New() + ti.Placeholder = "Filter worktrees..." + ti.Focus() + ti.CharLimit = 50 + ti.Width = 40 + m.worktreeSwitcherInput = ti + + // Load all worktrees + m.worktreeSwitcherAll = GetWorktrees(m.ui.WorkDir) + m.worktreeSwitcherFiltered = m.worktreeSwitcherAll + m.worktreeSwitcherCursor = 0 + m.worktreeSwitcherScroll = 0 + + // Set cursor to current worktree if found + for i, wt := range m.worktreeSwitcherFiltered { + normalizedPath, _ := normalizePath(wt.Path) + normalizedWorkDir, _ := normalizePath(m.ui.WorkDir) + if normalizedPath == normalizedWorkDir { + m.worktreeSwitcherCursor = i + break + } + } +} + +// resetWorktreeSwitcher resets the worktree switcher modal state. +func (m *Model) resetWorktreeSwitcher() { + m.showWorktreeSwitcher = false + m.worktreeSwitcherCursor = 0 + m.worktreeSwitcherScroll = 0 + m.worktreeSwitcherFiltered = nil + m.worktreeSwitcherAll = nil + m.clearWorktreeSwitcherModal() +} + +// clearWorktreeSwitcherModal clears the modal cache. +func (m *Model) clearWorktreeSwitcherModal() { + m.worktreeSwitcherModal = nil + m.worktreeSwitcherModalWidth = 0 + m.worktreeSwitcherMouseHandler = nil +} + +// filterWorktrees filters worktrees by branch name or path. +func filterWorktrees(all []WorktreeInfo, query string) []WorktreeInfo { + if query == "" { + return all + } + q := strings.ToLower(query) + var matches []WorktreeInfo + for _, wt := range all { + if strings.Contains(strings.ToLower(wt.Branch), q) || + strings.Contains(strings.ToLower(filepath.Base(wt.Path)), q) { + matches = append(matches, wt) + } + } + return matches +} + +// worktreeSwitcherEnsureCursorVisible adjusts scroll to keep cursor in view. +func worktreeSwitcherEnsureCursorVisible(cursor, scroll, maxVisible int) int { + if cursor < scroll { + return cursor + } + if cursor >= scroll+maxVisible { + return cursor - maxVisible + 1 + } + return scroll +} + +// ensureWorktreeSwitcherModal builds/rebuilds the worktree switcher modal. +func (m *Model) ensureWorktreeSwitcherModal() { + modalW := 60 + if modalW > m.width-4 { + modalW = m.width - 4 + } + if modalW < 30 { + modalW = 30 + } + + // Only rebuild if modal doesn't exist or width changed + if m.worktreeSwitcherModal != nil && m.worktreeSwitcherModalWidth == modalW { + return + } + m.worktreeSwitcherModalWidth = modalW + + m.worktreeSwitcherModal = modal.New("Switch Worktree", + modal.WithWidth(modalW), + modal.WithHints(false), + ). + AddSection(modal.Input(worktreeSwitcherFilterID, &m.worktreeSwitcherInput, modal.WithSubmitOnEnter(false))). + AddSection(m.worktreeSwitcherCountSection()). + AddSection(modal.Spacer()). + AddSection(m.worktreeSwitcherListSection()). + AddSection(m.worktreeSwitcherHintsSection()) +} + +// worktreeSwitcherCountSection renders the worktree count. +func (m *Model) worktreeSwitcherCountSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + worktrees := m.worktreeSwitcherFiltered + allWorktrees := m.worktreeSwitcherAll + + var countText string + if m.worktreeSwitcherInput.Value() != "" { + countText = fmt.Sprintf("%d of %d worktrees", len(worktrees), len(allWorktrees)) + } else if len(allWorktrees) > 0 { + countText = fmt.Sprintf("%d worktrees", len(allWorktrees)) + } + return modal.RenderedSection{Content: styles.Muted.Render(countText)} + }, nil) +} + +// worktreeSwitcherListSection renders the worktree list with selection. +func (m *Model) worktreeSwitcherListSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + worktrees := m.worktreeSwitcherFiltered + + // No worktrees + if len(worktrees) == 0 { + return modal.RenderedSection{Content: styles.Muted.Render("No worktrees found")} + } + + // Styles + cursorStyle := lipgloss.NewStyle().Foreground(styles.Primary) + nameNormalStyle := lipgloss.NewStyle().Foreground(styles.Secondary) + nameSelectedStyle := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) + nameCurrentStyle := lipgloss.NewStyle().Foreground(styles.Success).Bold(true) + nameCurrentSelectedStyle := lipgloss.NewStyle().Foreground(styles.Success).Bold(true) + mainBadgeStyle := lipgloss.NewStyle().Foreground(styles.Warning) + + // Determine current worktree + normalizedWorkDir, _ := normalizePath(m.ui.WorkDir) + + maxVisible := 8 + visibleCount := len(worktrees) + if visibleCount > maxVisible { + visibleCount = maxVisible + } + scrollOffset := m.worktreeSwitcherScroll + + var sb strings.Builder + focusables := make([]modal.FocusableInfo, 0, visibleCount) + lineOffset := 0 + + // Scroll indicator (top) + if scrollOffset > 0 { + sb.WriteString(styles.Muted.Render(fmt.Sprintf(" ↑ %d more above", scrollOffset))) + sb.WriteString("\n") + lineOffset++ + } + + for i := scrollOffset; i < scrollOffset+visibleCount && i < len(worktrees); i++ { + wt := worktrees[i] + isCursor := i == m.worktreeSwitcherCursor + itemID := worktreeSwitcherItemID(i) + isHovered := itemID == hoverID + + normalizedPath, _ := normalizePath(wt.Path) + isCurrent := normalizedPath == normalizedWorkDir + + // Cursor indicator + if isCursor { + sb.WriteString(cursorStyle.Render("> ")) + } else { + sb.WriteString(" ") + } + + // Determine display name (branch name for worktrees, "main" badge for main repo) + displayName := wt.Branch + if displayName == "" { + displayName = filepath.Base(wt.Path) + } + + // Name styling + var nameStyle lipgloss.Style + if isCurrent { + if isCursor || isHovered { + nameStyle = nameCurrentSelectedStyle + } else { + nameStyle = nameCurrentStyle + } + } else if isCursor || isHovered { + nameStyle = nameSelectedStyle + } else { + nameStyle = nameNormalStyle + } + + sb.WriteString(nameStyle.Render(displayName)) + + // Main badge + if wt.IsMain { + sb.WriteString(" ") + sb.WriteString(mainBadgeStyle.Render("[main]")) + } + + // Current indicator + if isCurrent { + sb.WriteString(styles.Muted.Render(" (current)")) + } + + sb.WriteString("\n") + + // Show path (truncated if needed) + pathDisplay := wt.Path + maxPathLen := contentWidth - 4 + if len(pathDisplay) > maxPathLen { + pathDisplay = "..." + pathDisplay[len(pathDisplay)-maxPathLen+3:] + } + sb.WriteString(styles.Muted.Render(" " + pathDisplay)) + + if i < scrollOffset+visibleCount-1 && i < len(worktrees)-1 { + sb.WriteString("\n") + } + + // Each worktree takes 2 lines (name + path) + focusables = append(focusables, modal.FocusableInfo{ + ID: itemID, + OffsetX: 0, + OffsetY: lineOffset + (i-scrollOffset)*2, + Width: contentWidth, + Height: 2, + }) + } + + // Scroll indicator (bottom) + remaining := len(worktrees) - (scrollOffset + visibleCount) + if remaining > 0 { + sb.WriteString("\n") + sb.WriteString(styles.Muted.Render(fmt.Sprintf(" ↓ %d more below", remaining))) + } + + return modal.RenderedSection{Content: sb.String(), Focusables: focusables} + }, m.worktreeSwitcherListUpdate) +} + +// worktreeSwitcherListUpdate handles key events for the worktree list. +func (m *Model) worktreeSwitcherListUpdate(msg tea.Msg, focusID string) (string, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return "", nil + } + + worktrees := m.worktreeSwitcherFiltered + if len(worktrees) == 0 { + return "", nil + } + + switch keyMsg.String() { + case "up", "k", "ctrl+p": + if m.worktreeSwitcherCursor > 0 { + m.worktreeSwitcherCursor-- + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + m.worktreeSwitcherModalWidth = 0 // Force modal rebuild for scroll + } + return "", nil + + case "down", "j", "ctrl+n": + if m.worktreeSwitcherCursor < len(worktrees)-1 { + m.worktreeSwitcherCursor++ + m.worktreeSwitcherScroll = worktreeSwitcherEnsureCursorVisible(m.worktreeSwitcherCursor, m.worktreeSwitcherScroll, 8) + m.worktreeSwitcherModalWidth = 0 // Force modal rebuild for scroll + } + return "", nil + + case "enter": + if m.worktreeSwitcherCursor >= 0 && m.worktreeSwitcherCursor < len(worktrees) { + return "select", nil + } + return "", nil + } + + return "", nil +} + +// worktreeSwitcherHintsSection renders the help text. +func (m *Model) worktreeSwitcherHintsSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + worktrees := m.worktreeSwitcherFiltered + + var sb strings.Builder + sb.WriteString("\n") + + if len(worktrees) == 0 { + sb.WriteString(styles.KeyHint.Render("esc")) + sb.WriteString(styles.Muted.Render(" clear filter ")) + sb.WriteString(styles.KeyHint.Render("W")) + sb.WriteString(styles.Muted.Render(" close")) + } else { + sb.WriteString(styles.KeyHint.Render("enter")) + sb.WriteString(styles.Muted.Render(" switch ")) + sb.WriteString(styles.KeyHint.Render("↑/↓")) + sb.WriteString(styles.Muted.Render(" navigate ")) + sb.WriteString(styles.KeyHint.Render("esc")) + sb.WriteString(styles.Muted.Render(" cancel")) + } + + return modal.RenderedSection{Content: sb.String()} + }, nil) +} + +// renderWorktreeSwitcherModal renders the worktree switcher modal. +func (m *Model) renderWorktreeSwitcherModal(content string) string { + m.ensureWorktreeSwitcherModal() + if m.worktreeSwitcherModal == nil { + return content + } + + if m.worktreeSwitcherMouseHandler == nil { + m.worktreeSwitcherMouseHandler = mouse.NewHandler() + } + modalContent := m.worktreeSwitcherModal.Render(m.width, m.height, m.worktreeSwitcherMouseHandler) + return ui.OverlayModal(content, modalContent, m.width, m.height) +} + +// handleWorktreeSwitcherMouse handles mouse events for the worktree switcher modal. +func (m Model) handleWorktreeSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + m.ensureWorktreeSwitcherModal() + if m.worktreeSwitcherModal == nil { + return m, nil + } + if m.worktreeSwitcherMouseHandler == nil { + m.worktreeSwitcherMouseHandler = mouse.NewHandler() + } + + action := m.worktreeSwitcherModal.HandleMouse(msg, m.worktreeSwitcherMouseHandler) + + // Check if action is a worktree item click + if strings.HasPrefix(action, worktreeSwitcherItemPrefix) { + var idx int + if _, err := fmt.Sscanf(action, worktreeSwitcherItemPrefix+"%d", &idx); err == nil { + worktrees := m.worktreeSwitcherFiltered + if idx >= 0 && idx < len(worktrees) { + selectedPath := worktrees[idx].Path + m.resetWorktreeSwitcher() + m.updateContext() + return m, m.switchWorktree(selectedPath) + } + } + return m, nil + } + + switch action { + case "cancel": + m.resetWorktreeSwitcher() + m.updateContext() + return m, nil + case "select": + worktrees := m.worktreeSwitcherFiltered + if m.worktreeSwitcherCursor >= 0 && m.worktreeSwitcherCursor < len(worktrees) { + selectedPath := worktrees[m.worktreeSwitcherCursor].Path + m.resetWorktreeSwitcher() + m.updateContext() + return m, m.switchWorktree(selectedPath) + } + return m, nil + } + + return m, nil +} + +// switchWorktree switches all plugins to a new worktree directory. +func (m *Model) switchWorktree(worktreePath string) tea.Cmd { + // Skip if already on this worktree + normalizedPath, _ := normalizePath(worktreePath) + normalizedWorkDir, _ := normalizePath(m.ui.WorkDir) + if normalizedPath == normalizedWorkDir { + return func() tea.Msg { + return ToastMsg{Message: "Already on this worktree", Duration: 2 * time.Second} + } + } + + // Validate that the worktree still exists before switching + if !WorktreeExists(worktreePath) { + return func() tea.Msg { + return ToastMsg{Message: "Worktree no longer exists", Duration: 3 * time.Second, IsError: true} + } + } + + // Use the same switchProject mechanism - it handles reinit, state save/restore + return m.switchProject(worktreePath) +} + +// isInWorktree returns true if the current WorkDir is a git worktree (not the main repo). +func (m *Model) isInWorktree() bool { + worktrees := GetWorktrees(m.ui.WorkDir) + normalizedWorkDir, _ := normalizePath(m.ui.WorkDir) + for _, wt := range worktrees { + normalizedPath, _ := normalizePath(wt.Path) + if normalizedPath == normalizedWorkDir { + return !wt.IsMain + } + } + return false +} + +// currentWorktreeInfo returns the WorktreeInfo for the current WorkDir, or nil if not found. +func (m *Model) currentWorktreeInfo() *WorktreeInfo { + worktrees := GetWorktrees(m.ui.WorkDir) + normalizedWorkDir, _ := normalizePath(m.ui.WorkDir) + for i, wt := range worktrees { + normalizedPath, _ := normalizePath(wt.Path) + if normalizedPath == normalizedWorkDir { + return &worktrees[i] + } + } + return nil +} diff --git a/internal/app/worktree_switcher_test.go b/internal/app/worktree_switcher_test.go new file mode 100644 index 00000000..eace81e8 --- /dev/null +++ b/internal/app/worktree_switcher_test.go @@ -0,0 +1,269 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFilterWorktrees(t *testing.T) { + worktrees := []WorktreeInfo{ + {Path: "/main/repo", Branch: "main", IsMain: true}, + {Path: "/worktrees/feature-auth", Branch: "feature-auth", IsMain: false}, + {Path: "/worktrees/feature-billing", Branch: "feature-billing", IsMain: false}, + {Path: "/worktrees/bugfix-login", Branch: "bugfix-login", IsMain: false}, + } + + tests := []struct { + name string + query string + expected int + }{ + {"empty query returns all", "", 4}, + {"filter by branch name", "feature", 2}, + {"filter by auth", "auth", 1}, + {"filter by billing", "billing", 1}, + {"filter by bugfix", "bugfix", 1}, + {"filter by main", "main", 1}, + {"case insensitive", "FEATURE", 2}, + {"no matches", "nonexistent", 0}, + {"partial match", "log", 1}, // matches "bugfix-login" + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterWorktrees(worktrees, tt.query) + if len(result) != tt.expected { + t.Errorf("filterWorktrees(%q) returned %d results, want %d", tt.query, len(result), tt.expected) + } + }) + } +} + +func TestWorktreeSwitcherEnsureCursorVisible(t *testing.T) { + tests := []struct { + name string + cursor int + scroll int + maxVisible int + expected int + }{ + {"cursor in view", 3, 0, 8, 0}, + {"cursor at top, need to scroll up", 2, 5, 8, 2}, + {"cursor at bottom, need to scroll down", 10, 0, 8, 3}, + {"cursor at edge", 7, 0, 8, 0}, + {"cursor just past edge", 8, 0, 8, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := worktreeSwitcherEnsureCursorVisible(tt.cursor, tt.scroll, tt.maxVisible) + if result != tt.expected { + t.Errorf("worktreeSwitcherEnsureCursorVisible(%d, %d, %d) = %d, want %d", + tt.cursor, tt.scroll, tt.maxVisible, result, tt.expected) + } + }) + } +} + +func TestWorktreeExists(t *testing.T) { + // Create a temp directory to test with + tempDir := t.TempDir() + + // Create a mock .git file + gitPath := filepath.Join(tempDir, ".git") + if err := os.WriteFile(gitPath, []byte("gitdir: /path/to/main/.git/worktrees/test"), 0644); err != nil { + t.Fatalf("failed to create .git file: %v", err) + } + + tests := []struct { + name string + path string + expected bool + }{ + {"valid directory with .git", tempDir, true}, + {"non-existent directory", "/nonexistent/path/12345", false}, + {"file instead of directory", gitPath, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := WorktreeExists(tt.path) + if result != tt.expected { + t.Errorf("WorktreeExists(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestWorktreeSwitcherItemID(t *testing.T) { + tests := []struct { + idx int + expected string + }{ + {0, "worktree-switcher-item-0"}, + {1, "worktree-switcher-item-1"}, + {10, "worktree-switcher-item-10"}, + {99, "worktree-switcher-item-99"}, + } + + for _, tt := range tests { + result := worktreeSwitcherItemID(tt.idx) + if result != tt.expected { + t.Errorf("worktreeSwitcherItemID(%d) = %q, want %q", tt.idx, result, tt.expected) + } + } +} + +func TestCheckCurrentWorktree(t *testing.T) { + // Test with non-existent path + exists, mainPath := CheckCurrentWorktree("/nonexistent/path/that/does/not/exist") + if exists { + t.Error("CheckCurrentWorktree should return false for non-existent path") + } + // mainPath may or may not be found depending on the test environment + _ = mainPath + + // Test with existing path (use temp dir as a valid directory) + tempDir := t.TempDir() + gitPath := filepath.Join(tempDir, ".git") + if err := os.WriteFile(gitPath, []byte("gitdir: /path/to/main/.git/worktrees/test"), 0644); err != nil { + t.Fatalf("failed to create .git file: %v", err) + } + + exists, _ = CheckCurrentWorktree(tempDir) + if !exists { + t.Error("CheckCurrentWorktree should return true for existing directory with .git") + } +} + +func TestWorktreeStatePersistence(t *testing.T) { + // Test the logic for determining target path when switching projects + // This tests the core decision logic without needing a full Model + + tests := []struct { + name string + oldWorkDir string + projectPath string + mainRepoPath string // What GetMainWorktreePath would return + savedWorktree string // Previously saved worktree for this repo + savedWorktreeExists bool + expectedTarget string + description string + }{ + { + name: "switch from worktree to main - should go to main", + oldWorkDir: "/repo/worktrees/feature-a", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-a", // Same as oldWorkDir + savedWorktreeExists: true, + expectedTarget: "/repo/main", // Should NOT restore back to feature-a + description: "When leaving a worktree to go to main, don't restore back to that worktree", + }, + { + name: "switch from different project - should restore saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-b", + savedWorktreeExists: true, + expectedTarget: "/repo/worktrees/feature-b", // Should restore + description: "When coming from a different project, restore the last worktree", + }, + { + name: "switch to main with no saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "", + savedWorktreeExists: false, + expectedTarget: "/repo/main", + description: "No saved worktree means stay on main", + }, + { + name: "switch to main with stale saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/deleted-feature", + savedWorktreeExists: false, // Worktree was deleted + expectedTarget: "/repo/main", + description: "Stale worktree entry should be ignored", + }, + { + name: "explicit worktree selection - should not restore", + oldWorkDir: "/repo/main", + projectPath: "/repo/worktrees/feature-c", // User explicitly chose this + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-d", // Different saved worktree + savedWorktreeExists: true, + expectedTarget: "/repo/worktrees/feature-c", // User's explicit choice + description: "When user explicitly selects a worktree, don't redirect to saved one", + }, + { + name: "switch between worktrees in same repo", + oldWorkDir: "/repo/worktrees/feature-a", + projectPath: "/repo/worktrees/feature-b", // User explicitly chose this + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-a", + savedWorktreeExists: true, + expectedTarget: "/repo/worktrees/feature-b", // User's explicit choice + description: "Switching between worktrees should respect explicit selection", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the target path determination logic from switchProject + targetPath := tt.projectPath + normalizedOldWorkDir := tt.oldWorkDir // In real code this is normalized + + // Only restore if projectPath equals mainRepoPath (switching to main) + if tt.projectPath == tt.mainRepoPath { + if tt.savedWorktree != "" { + // Don't restore if saved worktree is where we're coming from + if tt.savedWorktree != normalizedOldWorkDir { + if tt.savedWorktreeExists { + targetPath = tt.savedWorktree + } + } + } + } + + if targetPath != tt.expectedTarget { + t.Errorf("target = %q, want %q\n %s", targetPath, tt.expectedTarget, tt.description) + } + }) + } +} + +func TestWorktreeStateNotRestoredWhenLeavingSameWorktree(t *testing.T) { + // Specific regression test for the bug where switching from worktree to main + // would save the worktree and immediately restore it + + oldWorkDir := "/projects/sidecar-worktree-switcher" + projectPath := "/projects/sidecar" // Main repo + mainRepoPath := "/projects/sidecar" + savedWorktree := "/projects/sidecar-worktree-switcher" // Same as oldWorkDir + + targetPath := projectPath + + // Simulate the restore logic + if projectPath == mainRepoPath { + if savedWorktree != "" && savedWorktree != oldWorkDir { + // Would restore here, but savedWorktree == oldWorkDir so we skip + targetPath = savedWorktree + } + } + + // The key assertion: we should NOT have restored back to the worktree + if targetPath != mainRepoPath { + t.Errorf("Bug regression: switching from worktree to main restored back to worktree.\n"+ + " oldWorkDir: %s\n"+ + " projectPath: %s\n"+ + " savedWorktree: %s\n"+ + " targetPath: %s (should be %s)", + oldWorkDir, projectPath, savedWorktree, targetPath, mainRepoPath) + } +} diff --git a/internal/keymap/bindings.go b/internal/keymap/bindings.go index b0c73fee..e220e886 100644 --- a/internal/keymap/bindings.go +++ b/internal/keymap/bindings.go @@ -307,6 +307,7 @@ func DefaultBindings() []Binding { {Key: "Y", Command: "approve-all", Context: "workspace-list"}, {Key: "N", Command: "reject", Context: "workspace-list"}, {Key: "K", Command: "kill-shell", Context: "workspace-list"}, + {Key: "O", Command: "open-in-git", Context: "workspace-list"}, {Key: "l", Command: "focus-right", Context: "workspace-list"}, {Key: "right", Command: "focus-right", Context: "workspace-list"}, {Key: "tab", Command: "switch-pane", Context: "workspace-list"}, diff --git a/internal/plugins/workspace/browser.go b/internal/plugins/workspace/browser.go index 598b8822..084ead8a 100644 --- a/internal/plugins/workspace/browser.go +++ b/internal/plugins/workspace/browser.go @@ -5,6 +5,7 @@ import ( "runtime" tea "github.com/charmbracelet/bubbletea" + "github.com/marcus/sidecar/internal/app" ) // openInBrowser opens the URL in the default browser. @@ -25,3 +26,17 @@ func openInBrowser(url string) tea.Cmd { return nil } } + +// openInGitTab opens the selected worktree in the git status tab. +// It switches to the worktree directory and focuses the git-status plugin. +func (p *Plugin) openInGitTab(wt *Worktree) tea.Cmd { + if wt == nil { + return nil + } + // Sequence: switch to worktree first (triggers plugin reinit), then focus git-status plugin. + // Must use Sequence not Batch to avoid deadlock during concurrent plugin reinit + fork/exec. + return tea.Sequence( + app.SwitchWorktree(wt.Path), + app.FocusPlugin("git-status"), + ) +} diff --git a/internal/plugins/workspace/commands.go b/internal/plugins/workspace/commands.go index b3123baf..e4fd43ad 100644 --- a/internal/plugins/workspace/commands.go +++ b/internal/plugins/workspace/commands.go @@ -203,6 +203,7 @@ func (p *Plugin) Commands() []plugin.Command { plugin.Command{ID: "delete-workspace", Name: "Delete", Description: "Delete selected workspace", Context: "workspace-list", Priority: 5}, plugin.Command{ID: "push", Name: "Push", Description: "Push branch to remote", Context: "workspace-list", Priority: 6}, plugin.Command{ID: "merge-workflow", Name: "Merge", Description: "Start merge workflow", Context: "workspace-list", Priority: 7}, + plugin.Command{ID: "open-in-git", Name: "Git", Description: "Open in Git tab", Context: "workspace-list", Priority: 16}, ) // Task linking if wt.TaskID != "" { diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 6961c52b..8d50c9f5 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -768,6 +768,12 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { if wt != nil { return p.startMergeWorkflow(wt) } + case "O": + // Open selected worktree in git tab - switch to worktree and focus git plugin + wt := p.selectedWorktree() + if wt != nil { + return p.openInGitTab(wt) + } default: // Unhandled key in preview pane - flash to indicate attach is needed // Only flash if there's something to attach to (shell or worktree with agent) diff --git a/internal/plugins/workspace/types.go b/internal/plugins/workspace/types.go index 21181300..c45f68a5 100644 --- a/internal/plugins/workspace/types.go +++ b/internal/plugins/workspace/types.go @@ -199,6 +199,7 @@ type Worktree struct { CreatedAt time.Time UpdatedAt time.Time IsOrphaned bool // True if agent file exists but tmux session is gone + IsMain bool // True if this is the primary/main worktree (project root) } // ShellSession represents a tmux shell session (not tied to a git worktree). diff --git a/internal/plugins/workspace/view_list.go b/internal/plugins/workspace/view_list.go index 7d87684c..d152b296 100644 --- a/internal/plugins/workspace/view_list.go +++ b/internal/plugins/workspace/view_list.go @@ -396,8 +396,13 @@ func (p *Plugin) renderWorktreeItem(wt *Worktree, selected bool, width int) stri isSelected := selected isActiveFocus := selected && p.activePane == PaneSidebar - // Status indicator - statusIcon := wt.Status.Icon() + // Status indicator - use special icon for main worktree + var statusIcon string + if wt.IsMain { + statusIcon = "◉" // Bullseye icon for main/primary worktree + } else { + statusIcon = wt.Status.Icon() + } // Check for conflicts hasConflict := p.hasConflict(wt.Name, p.conflicts) @@ -516,17 +521,22 @@ func (p *Plugin) renderWorktreeItem(wt *Worktree, selected bool, width int) stri // Not selected - use colored styles for visual interest var statusStyle lipgloss.Style - switch wt.Status { - case StatusActive: - statusStyle = styles.StatusCompleted // Green - case StatusWaiting: - statusStyle = styles.StatusModified // Yellow/orange (warning) - case StatusDone: - statusStyle = styles.StatusCompleted // Green - case StatusError: - statusStyle = styles.StatusDeleted // Red - default: - statusStyle = styles.Muted // Gray for paused + if wt.IsMain { + // Primary/cyan color for main worktree to stand out + statusStyle = lipgloss.NewStyle().Foreground(styles.Primary) + } else { + switch wt.Status { + case StatusActive: + statusStyle = styles.StatusCompleted // Green + case StatusWaiting: + statusStyle = styles.StatusModified // Yellow/orange (warning) + case StatusDone: + statusStyle = styles.StatusCompleted // Green + case StatusError: + statusStyle = styles.StatusDeleted // Red + default: + statusStyle = styles.Muted // Gray for paused + } } icon := statusStyle.Render(statusIcon) diff --git a/internal/plugins/workspace/worktree.go b/internal/plugins/workspace/worktree.go index acabe677..6568474b 100644 --- a/internal/plugins/workspace/worktree.go +++ b/internal/plugins/workspace/worktree.go @@ -31,13 +31,21 @@ func (p *Plugin) listWorktrees() ([]*Worktree, error) { return nil, fmt.Errorf("git worktree list: %w", err) } - return parseWorktreeList(string(output), p.ctx.WorkDir) + // Get the actual main worktree path (the original repo), not the current workdir + // This ensures IsMain is set correctly regardless of which worktree we're in + mainRepoPath := app.GetMainWorktreePath(p.ctx.WorkDir) + if mainRepoPath == "" { + mainRepoPath = p.ctx.WorkDir // Fallback if detection fails + } + + return parseWorktreeList(string(output), mainRepoPath) } // parseWorktreeList parses porcelain format output. func parseWorktreeList(output, mainWorkdir string) ([]*Worktree, error) { var worktrees []*Worktree var current *Worktree + var mainWorktree *Worktree // Track main worktree to prepend later // Parent directory of main workdir - worktrees are created as siblings parentDir := filepath.Dir(mainWorkdir) @@ -48,26 +56,30 @@ func parseWorktreeList(output, mainWorkdir string) ([]*Worktree, error) { if strings.HasPrefix(line, "worktree ") { if current != nil { - worktrees = append(worktrees, current) + if current.IsMain { + mainWorktree = current + } else { + worktrees = append(worktrees, current) + } } path := strings.TrimPrefix(line, "worktree ") - // Skip main worktree (where git repo lives) - if path == mainWorkdir { - current = nil - continue - } + // Mark main worktree (where git repo lives) with IsMain flag + isMain := path == mainWorkdir // Derive name as relative path from parent dir, not just basename. // This handles nested worktree directories (e.g., repo-prefix/branch-name) // which are created when the branch name contains '/'. name := filepath.Base(path) - if relPath, err := filepath.Rel(parentDir, path); err == nil && relPath != "" { - name = relPath + if !isMain { + if relPath, err := filepath.Rel(parentDir, path); err == nil && relPath != "" { + name = relPath + } } current = &Worktree{ Name: name, Path: path, Status: StatusPaused, CreatedAt: time.Now(), // Will be updated from file stat + IsMain: isMain, } } else if current != nil { if strings.HasPrefix(line, "HEAD ") { @@ -84,7 +96,16 @@ func parseWorktreeList(output, mainWorkdir string) ([]*Worktree, error) { } if current != nil { - worktrees = append(worktrees, current) + if current.IsMain { + mainWorktree = current + } else { + worktrees = append(worktrees, current) + } + } + + // Prepend main worktree to the list so it appears first + if mainWorktree != nil { + worktrees = append([]*Worktree{mainWorktree}, worktrees...) } return worktrees, scanner.Err() diff --git a/internal/plugins/workspace/worktree_test.go b/internal/plugins/workspace/worktree_test.go index bc6b5ec4..62a846dc 100644 --- a/internal/plugins/workspace/worktree_test.go +++ b/internal/plugins/workspace/worktree_test.go @@ -100,6 +100,7 @@ func TestParseWorktreeList(t *testing.T) { wantCount int wantNames []string wantBranch []string + wantIsMain []bool // Track which worktrees should be marked as main }{ { name: "single worktree", @@ -112,9 +113,10 @@ HEAD def456 branch refs/heads/feature `, mainWorkdir: "/home/user/project", - wantCount: 1, - wantNames: []string{"project-feature"}, - wantBranch: []string{"feature"}, + wantCount: 2, // Main + 1 worktree + wantNames: []string{"project", "project-feature"}, + wantBranch: []string{"main", "feature"}, + wantIsMain: []bool{true, false}, }, { name: "multiple worktrees", @@ -131,9 +133,10 @@ HEAD ghi789 branch refs/heads/feature-b `, mainWorkdir: "/home/user/project", - wantCount: 2, - wantNames: []string{"feature-a", "feature-b"}, - wantBranch: []string{"feature-a", "feature-b"}, + wantCount: 3, // Main + 2 worktrees + wantNames: []string{"project", "feature-a", "feature-b"}, + wantBranch: []string{"main", "feature-a", "feature-b"}, + wantIsMain: []bool{true, false, false}, }, { name: "detached head", @@ -146,9 +149,10 @@ HEAD def456 detached `, mainWorkdir: "/home/user/project", - wantCount: 1, - wantNames: []string{"detached"}, - wantBranch: []string{"(detached)"}, + wantCount: 2, // Main + 1 worktree + wantNames: []string{"project", "detached"}, + wantBranch: []string{"main", "(detached)"}, + wantIsMain: []bool{true, false}, }, { name: "empty output", @@ -157,6 +161,7 @@ detached wantCount: 0, wantNames: nil, wantBranch: nil, + wantIsMain: nil, }, // Branch prefix tests - branch name has repo prefix, directory name does not { @@ -170,9 +175,10 @@ HEAD def456 branch refs/heads/project/feature-auth `, mainWorkdir: "/home/user/project", - wantCount: 1, - wantNames: []string{"feature-auth"}, - wantBranch: []string{"project/feature-auth"}, + wantCount: 2, // Main + 1 worktree + wantNames: []string{"project", "feature-auth"}, + wantBranch: []string{"main", "project/feature-auth"}, + wantIsMain: []bool{true, false}, }, { name: "multiple prefixed branches", @@ -189,9 +195,10 @@ HEAD ghi789 branch refs/heads/sidecar/add-feature `, mainWorkdir: "/home/user/sidecar", - wantCount: 2, - wantNames: []string{"fix-bug", "add-feature"}, - wantBranch: []string{"sidecar/fix-bug", "sidecar/add-feature"}, + wantCount: 3, // Main + 2 worktrees + wantNames: []string{"sidecar", "fix-bug", "add-feature"}, + wantBranch: []string{"main", "sidecar/fix-bug", "sidecar/add-feature"}, + wantIsMain: []bool{true, false, false}, }, // Nested worktree directories - when branch name contains '/' and creates nested dirs { @@ -205,10 +212,11 @@ HEAD def456 branch refs/heads/nested-branch `, mainWorkdir: "/home/user/sidecar", - wantCount: 1, - // Name should be full relative path to match session name derivation - wantNames: []string{"sidecar-prefix/nested-branch"}, - wantBranch: []string{"nested-branch"}, + wantCount: 2, // Main + 1 worktree + // Main uses basename, worktrees use full relative path + wantNames: []string{"sidecar", "sidecar-prefix/nested-branch"}, + wantBranch: []string{"main", "nested-branch"}, + wantIsMain: []bool{true, false}, }, { name: "deeply nested worktree directory", @@ -221,10 +229,11 @@ HEAD def456 branch refs/heads/feature/auth/login `, mainWorkdir: "/home/user/project", - wantCount: 1, - // Full relative path from parent dir - wantNames: []string{"project-td-123/feature/auth/login"}, - wantBranch: []string{"feature/auth/login"}, + wantCount: 2, // Main + 1 worktree + // Full relative path from parent dir for worktrees + wantNames: []string{"project", "project-td-123/feature/auth/login"}, + wantBranch: []string{"main", "feature/auth/login"}, + wantIsMain: []bool{true, false}, }, } @@ -246,6 +255,9 @@ branch refs/heads/feature/auth/login if i < len(tt.wantBranch) && wt.Branch != tt.wantBranch[i] { t.Errorf("worktree[%d].Branch = %q, want %q", i, wt.Branch, tt.wantBranch[i]) } + if i < len(tt.wantIsMain) && wt.IsMain != tt.wantIsMain[i] { + t.Errorf("worktree[%d].IsMain = %v, want %v", i, wt.IsMain, tt.wantIsMain[i]) + } } }) } diff --git a/internal/state/state.go b/internal/state/state.go index 203a2b13..5122e850 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -23,6 +23,9 @@ type State struct { FileBrowser map[string]FileBrowserState `json:"fileBrowser,omitempty"` Workspace map[string]WorkspaceState `json:"workspace,omitempty"` ActivePlugin map[string]string `json:"activePlugin,omitempty"` + + // Worktree state: maps main repo path -> last active worktree path + LastWorktreePath map[string]string `json:"lastWorktreePath,omitempty"` } // FileBrowserTabState holds persistent tab state for the file browser. @@ -339,3 +342,39 @@ func SetActivePlugin(workdir, pluginID string) error { mu.Unlock() return Save() } + +// GetLastWorktreePath returns the last active worktree path for a main repo. +func GetLastWorktreePath(mainRepoPath string) string { + mu.RLock() + defer mu.RUnlock() + if current == nil || current.LastWorktreePath == nil { + return "" + } + return current.LastWorktreePath[mainRepoPath] +} + +// SetLastWorktreePath saves the last active worktree path for a main repo. +func SetLastWorktreePath(mainRepoPath, worktreePath string) error { + mu.Lock() + if current == nil { + current = &State{} + } + if current.LastWorktreePath == nil { + current.LastWorktreePath = make(map[string]string) + } + current.LastWorktreePath[mainRepoPath] = worktreePath + mu.Unlock() + return Save() +} + +// ClearLastWorktreePath removes the saved worktree path for a main repo. +func ClearLastWorktreePath(mainRepoPath string) error { + mu.Lock() + if current == nil || current.LastWorktreePath == nil { + mu.Unlock() + return nil + } + delete(current.LastWorktreePath, mainRepoPath) + mu.Unlock() + return Save() +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 4911b6c9..1d66f108 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -524,3 +524,154 @@ func TestSetWorkspaceState_ShellSelection(t *testing.T) { t.Errorf("stored WorkspaceName = %q, want empty", stored.WorkspaceName) } } + +func TestGetLastWorktreePath_Default(t *testing.T) { + originalCurrent := current + defer func() { current = originalCurrent }() + + current = nil + result := GetLastWorktreePath("/main/repo") + if result != "" { + t.Errorf("GetLastWorktreePath() with nil current = %q, want empty", result) + } +} + +func TestGetLastWorktreePath_EmptyMap(t *testing.T) { + originalCurrent := current + defer func() { current = originalCurrent }() + + current = &State{LastWorktreePath: nil} + result := GetLastWorktreePath("/main/repo") + if result != "" { + t.Errorf("GetLastWorktreePath() with nil map = %q, want empty", result) + } +} + +func TestGetLastWorktreePath_Found(t *testing.T) { + originalCurrent := current + defer func() { current = originalCurrent }() + + current = &State{ + LastWorktreePath: map[string]string{ + "/main/repo": "/worktrees/feature-auth", + }, + } + result := GetLastWorktreePath("/main/repo") + if result != "/worktrees/feature-auth" { + t.Errorf("GetLastWorktreePath() = %q, want /worktrees/feature-auth", result) + } +} + +func TestSetLastWorktreePath(t *testing.T) { + tmpDir := t.TempDir() + originalPath := path + originalCurrent := current + defer func() { + path = originalPath + current = originalCurrent + }() + + stateFile := filepath.Join(tmpDir, "state.json") + path = stateFile + current = &State{} + + err := SetLastWorktreePath("/main/repo", "/worktrees/feature-billing") + if err != nil { + t.Fatalf("SetLastWorktreePath() failed: %v", err) + } + + // Verify in memory + if current.LastWorktreePath["/main/repo"] != "/worktrees/feature-billing" { + t.Errorf("stored path = %q, want /worktrees/feature-billing", current.LastWorktreePath["/main/repo"]) + } + + // Verify saved to disk + data, _ := os.ReadFile(stateFile) + var loaded State + _ = json.Unmarshal(data, &loaded) + if loaded.LastWorktreePath["/main/repo"] != "/worktrees/feature-billing" { + t.Errorf("persisted path = %q, want /worktrees/feature-billing", loaded.LastWorktreePath["/main/repo"]) + } +} + +func TestSetLastWorktreePath_InitializesNilState(t *testing.T) { + tmpDir := t.TempDir() + originalPath := path + originalCurrent := current + defer func() { + path = originalPath + current = originalCurrent + }() + + path = filepath.Join(tmpDir, "state.json") + current = nil + + err := SetLastWorktreePath("/main/repo", "/worktrees/feature") + if err != nil { + t.Fatalf("SetLastWorktreePath() failed: %v", err) + } + + if current == nil { + t.Error("SetLastWorktreePath() should initialize current state") + } + if current.LastWorktreePath["/main/repo"] != "/worktrees/feature" { + t.Errorf("path = %q, want /worktrees/feature", current.LastWorktreePath["/main/repo"]) + } +} + +func TestClearLastWorktreePath(t *testing.T) { + tmpDir := t.TempDir() + originalPath := path + originalCurrent := current + defer func() { + path = originalPath + current = originalCurrent + }() + + path = filepath.Join(tmpDir, "state.json") + current = &State{ + LastWorktreePath: map[string]string{ + "/main/repo": "/worktrees/feature", + }, + } + + err := ClearLastWorktreePath("/main/repo") + if err != nil { + t.Fatalf("ClearLastWorktreePath() failed: %v", err) + } + + // Verify removed + if _, exists := current.LastWorktreePath["/main/repo"]; exists { + t.Error("ClearLastWorktreePath() should remove the entry") + } + + // Verify saved to disk + data, _ := os.ReadFile(path) + var loaded State + _ = json.Unmarshal(data, &loaded) + if _, exists := loaded.LastWorktreePath["/main/repo"]; exists { + t.Error("ClearLastWorktreePath() should persist removal") + } +} + +func TestClearLastWorktreePath_NilState(t *testing.T) { + originalCurrent := current + defer func() { current = originalCurrent }() + + current = nil + err := ClearLastWorktreePath("/main/repo") + if err != nil { + t.Fatalf("ClearLastWorktreePath() with nil state should not error: %v", err) + } +} + +func TestClearLastWorktreePath_NilMap(t *testing.T) { + originalCurrent := current + defer func() { current = originalCurrent }() + + current = &State{LastWorktreePath: nil} + err := ClearLastWorktreePath("/main/repo") + if err != nil { + t.Fatalf("ClearLastWorktreePath() with nil map should not error: %v", err) + } +} diff --git a/internal/styles/styles.go b/internal/styles/styles.go index 49bae6d7..80fadc90 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -104,6 +104,11 @@ var ( Subtitle = lipgloss.NewStyle(). Foreground(TextHighlight) + // WorktreeIndicator shows the current worktree branch in the header + WorktreeIndicator = lipgloss.NewStyle(). + Foreground(Warning). + Bold(true) + Body = lipgloss.NewStyle(). Foreground(TextPrimary) diff --git a/internal/styles/themes.go b/internal/styles/themes.go index 201d3b0d..ece56636 100644 --- a/internal/styles/themes.go +++ b/internal/styles/themes.go @@ -917,6 +917,11 @@ func rebuildStyles() { Subtitle = lipgloss.NewStyle(). Foreground(TextHighlight) + // WorktreeIndicator shows the current worktree branch in the header + WorktreeIndicator = lipgloss.NewStyle(). + Foreground(Warning). + Bold(true) + Body = lipgloss.NewStyle(). Foreground(TextPrimary) diff --git a/website/docs/intro.md b/website/docs/intro.md index 5ab018b2..df04dccd 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -178,6 +178,9 @@ These shortcuts work across all plugins: | `q`, `ctrl+c` | Quit sidecar | | `tab` / `shift+tab` | Switch between plugins | | `1-5` | Jump to plugin by number | +| `@` | Open project switcher | +| `W` | Open worktree switcher | +| `#` | Open theme switcher | | `j/k`, `↓/↑` | Navigate items in lists | | `ctrl+d/u` | Page down/up | | `g` / `G` | Jump to top/bottom | @@ -187,6 +190,12 @@ These shortcuts work across all plugins: Each plugin adds its own context-specific shortcuts shown in the footer bar. +### Worktree Switcher + +Press `W` to switch between git worktrees within the current repository. This is useful when you're working with multiple worktrees for parallel development. + +Sidecar remembers your last active worktree per project. When you switch away and later return, sidecar automatically restores the worktree you were working in—no need to manually navigate back. + ## Themes Sidecar ships with built-in themes plus a community theme browser with live previews. diff --git a/website/src/pages/index.js b/website/src/pages/index.js index fe128fcd..e235e948 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -19,6 +19,7 @@ const MINI_FEATURES = [ { icon: 'clipboard', title: 'System Clipboard', description: 'Copy file paths, diffs, commit hashes, and more directly to your clipboard.' }, { icon: 'move', title: 'Vim Navigation', description: 'h/j/k/l, gg/G, Ctrl+d/u, and more. Navigate like you would in vim.' }, { icon: 'git-merge', title: 'Merge Workflow', description: 'Merge PRs, delete branches, and clean up workspaces with guided prompts.' }, + { icon: 'git-fork', title: 'Worktree Switcher', description: 'Switch between git worktrees instantly. Keep multiple branches checked out simultaneously.' }, { icon: 'refresh-cw', title: 'Global Refresh', description: 'Press R to refresh all plugins at once. Git status, files, and tasks update together.' }, { icon: 'sun', title: 'Theme Switching', description: 'Cycle through themes or browse the community gallery. Changes apply instantly.' }, ];