Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
33 changes: 33 additions & 0 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions internal/app/git.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"os"
"os/exec"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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, ""
}
75 changes: 62 additions & 13 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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,
}
},
Expand Down
Loading