Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
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
31 changes: 21 additions & 10 deletions container/internal/services/commit_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,23 +579,29 @@ func (css *CommitSyncService) syncToNiceBranch(commitInfo *CommitInfo) error {
return nil
}

// Check if the nice branch exists
if !css.operations.BranchExists(commitInfo.WorktreePath, niceBranch, false) {
log.Printf("⚠️ Nice branch %s doesn't exist, skipping sync", niceBranch)
// Check if the nice branch exists in the main repository (not the worktree)
repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath)
if err != nil {
log.Printf("⚠️ Failed to find repository for worktree %s: %v", commitInfo.WorktreePath, err)
return nil
}

if !css.operations.BranchExists(repo.Path, niceBranch, false) {
log.Printf("⚠️ Nice branch %s doesn't exist in main repo %s, skipping sync", niceBranch, repo.Path)
return nil
}

// Update the nice branch to point to the same commit as the custom ref
_, err = css.operations.ExecuteGit(commitInfo.WorktreePath, "branch", "-f", niceBranch, commitInfo.CommitHash)
// We need to do this in the main repository since worktrees on custom refs can't directly manipulate regular branches
_, err = css.operations.ExecuteGit(repo.Path, "branch", "-f", niceBranch, commitInfo.CommitHash)
if err != nil {
return fmt.Errorf("failed to update nice branch %s to commit %s: %v", niceBranch, commitInfo.CommitHash[:8], err)
}

log.Printf("🔄 Synced nice branch %s to commit %s from %s", niceBranch, commitInfo.CommitHash[:8], commitInfo.Branch)

// For local repositories, also push the nice branch to the main repository
repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath)
if err == nil && strings.HasPrefix(repo.ID, "local/") {
if strings.HasPrefix(repo.ID, "local/") {
// Push the nice branch to the catnip-live remote (which points to the main repo)
_, pushErr := css.operations.ExecuteGit(commitInfo.WorktreePath, "push", "catnip-live", fmt.Sprintf("%s:%s", niceBranch, niceBranch), "--force-with-lease")
if pushErr != nil {
Expand Down Expand Up @@ -771,13 +777,18 @@ func (css *CommitSyncService) hasUnsyncedNiceBranch(commitInfo *CommitInfo) bool
return false
}

// Check if the nice branch exists
if !css.operations.BranchExists(commitInfo.WorktreePath, niceBranch, false) {
// Check if the nice branch exists in the main repository (not the worktree)
repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath)
if err != nil {
return false
}

if !css.operations.BranchExists(repo.Path, niceBranch, false) {
return false
}

// Get commit hash of the nice branch
niceBranchHash, err := css.operations.GetCommitHash(commitInfo.WorktreePath, niceBranch)
// Get commit hash of the nice branch from the main repository
niceBranchHash, err := css.operations.GetCommitHash(repo.Path, niceBranch)
if err != nil {
return false
}
Expand Down
68 changes: 67 additions & 1 deletion container/internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ func (a *App) Run(ctx context.Context, workDir string, customPorts []string) err
// Initialize viewports
logsViewport := viewport.New(80, 20)
shellViewport := viewport.New(80, 24)
workspaceClaudeTerminal := viewport.New(60, 18)
workspaceRegularTerminal := viewport.New(60, 6)

// Initialize SSE client
sseClient := NewSSEClient("http://localhost:8080/v1/events", nil)
Expand All @@ -107,6 +109,8 @@ func (a *App) Run(ctx context.Context, workDir string, customPorts []string) err
m.logsViewport = logsViewport
m.searchInput = searchInput
m.shellViewport = shellViewport
m.workspaceClaudeTerminal = workspaceClaudeTerminal
m.workspaceRegularTerminal = workspaceRegularTerminal
m.shellSpinner = spinner.New()
m.sseClient = sseClient
m.sseStarted = true // SSE will be started immediately
Expand Down Expand Up @@ -175,6 +179,12 @@ func (m Model) View() string {
result = m.overlayOnContent(result, overlay)
}

// Overlay workspace selector if active
if m.showWorkspaceSelector {
overlay := m.renderWorkspaceSelector()
result = m.overlayOnContent(result, overlay)
}

return result
}

Expand All @@ -189,13 +199,15 @@ func (m Model) renderFooter() string {
}
return footerStyle.Render("Initializing container... Press Ctrl+Q to quit")
case OverviewView:
return footerStyle.Render("Ctrl+L: logs | Ctrl+T: terminal | Ctrl+B: browser | Ctrl+Q: quit")
return footerStyle.Render("Ctrl+L: logs | Ctrl+T: terminal | Ctrl+B: browser | Ctrl+W: workspaces | Ctrl+Q: quit")
case ShellView:
scrollKey := "Alt"
if runtime.GOOS == "darwin" {
scrollKey = "Option"
}
return footerStyle.Render(fmt.Sprintf("Ctrl+O: overview | Ctrl+L: logs | Ctrl+B: browser | Ctrl+Q: quit | %s+↑↓/PgUp/PgDn: scroll", scrollKey))
case WorkspaceView:
return footerStyle.Render("Esc: back | Ctrl+O: overview | Ctrl+L: logs | Ctrl+B: browser | Ctrl+Q: quit")
case LogsView:
if m.searchMode {
// Replace footer with search input
Expand Down Expand Up @@ -297,6 +309,60 @@ func (m Model) renderPortSelector() string {
return boxStyle.Render(title + "\n\n" + menuContent)
}

// renderWorkspaceSelector renders the workspace selection overlay
func (m Model) renderWorkspaceSelector() string {
// Build the menu content
var menuItems []string
for i, workspace := range m.workspaces {
prefix := " "
if i == m.selectedWorkspaceIndex {
prefix = "▶ "
}

// Create status indicator
statusIndicator := "○"
if workspace.IsActive {
statusIndicator = "●"
}

// Format: status name (branch) - changed files count
changeCount := len(workspace.ChangedFiles)
changeText := ""
if changeCount > 0 {
changeText = fmt.Sprintf(" • %d changes", changeCount)
}

item := fmt.Sprintf("%s %s (%s)%s", statusIndicator, workspace.Name, workspace.Branch, changeText)
menuItems = append(menuItems, prefix+item)
}

// Add instructions
instructions := []string{
"",
"↑↓/jk: Navigate • Enter/1-9: Select • Esc: Cancel",
}

content := append(menuItems, instructions...)
menuContent := strings.Join(content, "\n")

// Style the menu box
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2).
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("15"))

titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("39")).
Align(lipgloss.Center)

title := titleStyle.Render("📁 Select Workspace")

return boxStyle.Render(title + "\n\n" + menuContent)
}

// overlayOnContent centers an overlay on top of the main content
func (m Model) overlayOnContent(content, overlay string) string {
// Use lipgloss.Place to properly center the overlay
Expand Down
63 changes: 63 additions & 0 deletions container/internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package tui

import (
"context"
"encoding/json"
"net/http"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/vanpelt/catnip/internal/models"
)

// Ticker commands
Expand Down Expand Up @@ -114,6 +117,23 @@ func (m *Model) fetchHealthStatus() tea.Cmd {
}
}

func (m *Model) fetchWorkspaces() tea.Cmd {
return func() tea.Msg {
// If quit was requested, don't execute this command
if m.quitRequested {
debugLog("fetchWorkspaces: quit requested, skipping")
return nil
}

workspaces, err := fetchWorkspacesFromAPI()
if err != nil {
debugLog("fetchWorkspaces: error: %v", err)
return workspacesMsg{} // Return empty list on error
}
return workspacesMsg(workspaces)
}
}

// Batch commands for initialization
func (m *Model) initCommands() tea.Cmd {
var commands []tea.Cmd
Expand All @@ -132,6 +152,7 @@ func (m *Model) initCommands() tea.Cmd {
m.fetchRepositoryInfo(),
m.fetchHealthStatus(),
m.fetchPorts(),
m.fetchWorkspaces(),
m.fetchContainerInfo(),
m.shellSpinner.Tick,
tick(),
Expand All @@ -141,3 +162,45 @@ func (m *Model) initCommands() tea.Cmd {

return tea.Batch(commands...)
}

// fetchWorkspacesFromAPI fetches workspaces from the container API
func fetchWorkspacesFromAPI() ([]WorkspaceInfo, error) {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://localhost:8080/v1/git/worktrees")
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, err // Return empty if API call fails
}

var worktrees []models.Worktree
if err := json.NewDecoder(resp.Body).Decode(&worktrees); err != nil {
return nil, err
}

// Convert models.Worktree to WorkspaceInfo
var workspaces []WorkspaceInfo
for _, wt := range worktrees {
workspace := WorkspaceInfo{
ID: wt.ID,
Name: wt.Name,
Path: wt.Path,
Branch: wt.Branch,
IsActive: string(wt.ClaudeActivityState) != "inactive", // Use Claude activity as active indicator
ChangedFiles: []string{}, // TODO: Get changed files from git status if needed
Ports: []PortInfo{}, // TODO: Map ports if available in worktree model
}

// Add indicator if worktree is dirty (has uncommitted changes)
if wt.IsDirty {
workspace.ChangedFiles = []string{"(uncommitted changes)"} // Placeholder
}

workspaces = append(workspaces, workspace)
}

return workspaces, nil
}
5 changes: 4 additions & 1 deletion container/internal/tui/components/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const (
// Browser shortcuts
KeyOpenBrowser = "ctrl+b"

// Workspace shortcuts
KeyWorkspace = "ctrl+w"

// Common keys
KeyEscape = "esc"
KeyEnter = "enter"
Expand Down Expand Up @@ -84,7 +87,7 @@ const (
// IsGlobalNavigationKey checks if a key is a global navigation command
func IsGlobalNavigationKey(key string) bool {
switch key {
case KeyQuit, KeyQuitAlt, KeyOverview, KeyLogs, KeyShell, KeyOpenBrowser:
case KeyQuit, KeyQuitAlt, KeyOverview, KeyLogs, KeyShell, KeyOpenBrowser, KeyWorkspace:
return true
}
return false
Expand Down
5 changes: 5 additions & 0 deletions container/internal/tui/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type logsMsg []string
type portsMsg []string
type errMsg error
type healthStatusMsg bool
type workspacesMsg []WorkspaceInfo

// Shell-related messages
type shellOutputMsg struct {
Expand All @@ -26,6 +27,9 @@ type shellErrorMsg struct {
sessionID string
err error
}
type shellConnectedMsg struct {
sessionID string
}

// SSE event messages
type sseConnectedMsg struct{}
Expand All @@ -46,3 +50,4 @@ type sseContainerStatusMsg struct {
status string
message string
}
type sseWorktreeUpdatedMsg struct{}
28 changes: 28 additions & 0 deletions container/internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const (
LogsView
// ShellView represents the shell terminal interface
ShellView
// WorkspaceView represents the workspace-specific view
WorkspaceView
)

// View interface that all views must implement
Expand Down Expand Up @@ -51,6 +53,17 @@ type PortInfo struct {
Protocol string
}

// WorkspaceInfo represents information about a workspace
type WorkspaceInfo struct {
ID string
Name string
Path string
Branch string
IsActive bool
ChangedFiles []string
Ports []PortInfo
}

// Model represents the main application state
type Model struct {
// Core dependencies
Expand Down Expand Up @@ -115,6 +128,19 @@ type Model struct {
showPortSelector bool
selectedPortIndex int

// Workspace selector overlay
showWorkspaceSelector bool
selectedWorkspaceIndex int
currentWorkspace *WorkspaceInfo
workspaces []WorkspaceInfo
waitingToShowWorkspaces bool

// Workspace view state
workspaceClaudeTerminal viewport.Model
workspaceRegularTerminal viewport.Model
workspaceClaudeTerminalEmulator *TerminalEmulator
workspaceLastOutputLength int

// SSE connection state
sseConnected bool
sseStarted bool
Expand Down Expand Up @@ -155,6 +181,7 @@ func NewModelWithInitialization(containerService *services.ContainerService, con
logs: []string{},
filteredLogs: []string{},
ports: []PortInfo{},
workspaces: []WorkspaceInfo{},
lastUpdate: time.Now(),
shellSessions: make(map[string]*PTYClient),
views: make(map[ViewType]View),
Expand All @@ -165,6 +192,7 @@ func NewModelWithInitialization(containerService *services.ContainerService, con
m.views[OverviewView] = NewOverviewView()
m.views[LogsView] = NewLogsView()
m.views[ShellView] = NewShellView()
m.views[WorkspaceView] = NewWorkspaceView()

return m
}
Expand Down
10 changes: 9 additions & 1 deletion container/internal/tui/pty_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ func (p *PTYClient) readLoop() {
return
}

if messageType == websocket.BinaryMessage || messageType == websocket.TextMessage {
// Handle different message types properly
switch messageType {
case websocket.BinaryMessage:
// Binary messages are PTY output - pass to terminal
if p.onMessage != nil {
p.onMessage(message)
}
case websocket.TextMessage:
// Text messages are JSON control messages - process them separately
debugLog("PTYClient received JSON control message: %s", string(message))
// TODO: Handle JSON messages for state updates (read-only mode, etc.)
// For now, we just filter them out from terminal display
}
}
}
Expand Down
Loading
Loading