Skip to content
Development
Skill

/drag-pane

Drag-and-drop pane resizing implementation for two-pane plugin layouts. Covers mouse event handling via the internal/mouse package, hit region registration, drag delta calculation, width clamping, state persistence, and pane layout management. Use when working on pane resizing,

From plugin
sidecar
1k17 skills
Install
$ npx -y skills add marcus/sidecar --skill drag-pane --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/drag-pane

Context preview

The summary Claude sees to decide when to auto-load this skill.

Drag-and-drop pane resizing implementation for two-pane plugin layouts. Covers mouse event handling via the internal/mouse package, hit region registration, drag delta calculation, width clamping, state persistence, and pane layout management. Use when working on pane resizing,

SKILL.md

drag-pane.SKILL.md
name: drag-pane
description: >
  Drag-and-drop pane resizing implementation for two-pane plugin layouts.
  Covers mouse event handling via the internal/mouse package, hit region
  registration, drag delta calculation, width clamping, state persistence,
  and pane layout management. Use when working on pane resizing, drag
  interactions, layout management, or adding drag-to-resize to a new plugin.

Drag-to-Resize Pane Implementation

Overview

Add drag-to-resize support for two-pane plugin layouts (sidebar + main content). Users click and drag the divider between panes to resize them.

Prerequisites

  • Plugin already has a two-pane layout (sidebar + main content)
  • State persistence functions exist in `internal/state/state.go` (each plugin has its own getter/setter)
  • Familiarity with `internal/mouse` package

Existing Implementations

| Plugin | State Functions | Mouse File | |--------|----------------|------------| | FileBrowser | `GetFileBrowserTreeWidth()` / `SetFileBrowserTreeWidth()` | `internal/plugins/filebrowser/mouse.go` | | GitStatus | `GetGitStatusSidebarWidth()` / `SetGitStatusSidebarWidth()` | `internal/plugins/gitstatus/mouse.go` | | Conversations | `GetConversationsSideWidth()` / `SetConversationsSideWidth()` | `internal/plugins/conversations/mouse.go` | | Workspace | `GetWorkspaceSidebarWidth()` / `SetWorkspaceSidebarWidth()` | `internal/plugins/workspace/view_list.go` |

Implementation Steps

Step 1: Add Mouse Handler to Plugin Struct

import "github.com/marcus/sidecar/internal/mouse"

type Plugin struct {
    // ... other fields
    mouseHandler *mouse.Handler
    sidebarWidth int  // Current sidebar width (persisted)
}

func New() *Plugin {
    return &Plugin{
        mouseHandler: mouse.NewHandler(),
    }
}

Step 2: Define Hit Region Constants

const (
    regionSidebar     = "sidebar"
    regionMainPane    = "main-pane"
    regionPaneDivider = "pane-divider"
    dividerWidth      = 1  // Visual divider width
)

Step 3: Initialize Width on First Render (NOT in Init)

**Important:** Do NOT load width in `Init()` - plugin dimensions (`p.width`) are not available yet. Initialize lazily on first render:

func (p *Plugin) renderTwoPane() string {
    p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render

    if p.sidebarWidth == 0 {
        p.sidebarWidth = state.GetYourPluginSidebarWidth()
        if p.sidebarWidth == 0 {
            available := p.width - dividerWidth
            p.sidebarWidth = available * 30 / 100 // Default 30%
        }
    }
    // ... rest of render
}

Step 4: Handle MouseMsg in Update

func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseMsg:
        return p.handleMouse(msg)
    }
}

Step 5: Create mouse.go with Handlers

func (p *Plugin) handleMouse(msg tea.MouseMsg) (*Plugin, tea.Cmd) {
    action := p.mouseHandler.HandleMouse(msg)
    switch action.Type {
    case mouse.ActionClick:
        return p.handleMouseClick(action)
    case mouse.ActionDrag:
        return p.handleMouseDrag(action)
    case mouse.ActionDragEnd:
        return p.handleMouseDragEnd()
    }
    return p, nil
}

func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    if action.Region == nil {
        return p, nil
    }
    switch action.Region.ID {
    case regionSidebar:
        p.activePane = PaneSidebar
    case regionMainPane:
        p.activePane = PaneMain
    case regionPaneDivider:
        p.mouseHandler.StartDrag(action.X, action.Y, regionPaneDivider, p.sidebarWidth)
    }
    return p, nil
}

func (p *Plugin) handleMouseDrag(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    if p.mouseHandler.DragRegion() != regionPaneDivider {
        return p, nil
    }
    startValue := p.mouseHandler.DragStartValue()
    newWidth := startValue + action.DragDX

    // Clamp to bounds
    // NOTE: Offset varies by plugin (border styling differences):
    // GitStatus: -5, FileBrowser: -6, Conversations: -5, Workspace: just dividerWidth
    available := p.width - 5 - dividerWidth
    minWidth := 25
    maxWidth := available - 40
    if newWidth < minWidth {
        newWidth = minWidth
    } else if newWidth > maxWidth {
        newWidth = maxWidth
    }
    p.sidebarWidth = newWidth
    return p, nil
}

func (p *Plugin) handleMouseDragEnd() (*Plugin, tea.Cmd) {
    _ = state.SetYourPluginSidebarWidth(p.sidebarWidth)
    return p, nil
}

Step 6: Register Hit Regions in Render

**This is where most bugs occur.** Follow this pattern exactly:

func (p *Plugin) renderTwoPane() string {
    p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render

    available := p.width - 5 - dividerWidth
    sidebarWidth := p.sidebarWidth
    if sidebarWidth == 0 {
        sidebarWidth = available * 30 / 100
    }
    if sidebarWidth < 25 {
        sidebarWidth = 25
    }
    if sidebarWidth > available-40 {
        sidebarWidth = available - 40
    }
    mainWidth := available - sidebarWidth
    p.sidebarWidth = sidebarWidth

    // ... render panes and divider ...

    // CRITICAL: Register in priority order (last = highest priority)
    p.mouseHandler.HitMap.AddRect(regionSidebar, 0, 0, sidebarWidth, p.height, nil)
    mainX := sidebarWidth + dividerWidth
    p.mouseHandler.HitMap.AddRect(regionMainPane, mainX, 0, mainWidth, p.height, nil)
    // Divider LAST = highest priority
    dividerX := sidebarWidth
    dividerHitWidth := 3 // Wider than visual for easier clicking
    p.mouseHandler.HitMap.AddRect(regionPaneDivider, dividerX, 0, dividerHitWidth, p.height, nil)

    return content
}

Step 7: Render Visible Divider

func (p *Plugin) renderDivider(height int) string {
    dividerStyle := lipgloss.NewStyle().
        Foreground(styles.BorderNormal).
        MarginTop(1) // Aligns with pane content (below top border)

    var sb strings.Builder
    for i := 0; i < height; i++
Read more
Ships withsidecar

You might never open your editor again. Status: Ready for daily use. Please report any issues you encounter.

Get the whole plugin
Stats
1,044
Stars
79
Forks
Active
Maintenance
Go
Language
MIT
License
25m ago
Last commit
7mo ago
Created

Repo: marcus/sidecar

Other skills on sidecar.