Agent Skillsmarcus/sidecar › drag-pane

drag-pane

GitHub

实现双面板布局的拖拽调整大小功能,涵盖鼠标事件处理、命中区域注册、宽度限制及状态持久化。

.claude/skills/drag-pane/SKILL.md marcus/sidecar

Trigger Scenarios

需要为插件添加拖拽调整面板大小的交互 修复面板焦点环与键盘输入目标不一致的问题 统一全局工作区与项目工作区的面板表现逻辑

Install

npx skills add marcus/sidecar --skill drag-pane -g -y
More Options

Non-standard path

npx skills add https://github.com/marcus/sidecar/tree/main/.claude/skills/drag-pane -g -y

Use without installing

npx skills use marcus/sidecar@drag-pane

指定 Agent (Claude Code)

npx skills add marcus/sidecar --skill drag-pane -a claude-code -g -y

安装 repo 全部 skill

npx skills add marcus/sidecar --all -g -y

预览 repo 内 skill

npx skills add marcus/sidecar --list

SKILL.md

Frontmatter
{
    "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 (project) GetWorkspaceSidebarWidth() / SetWorkspaceSidebarWidth() internal/plugins/workspace/view_list.go
Sessions (global workspace) GetWorkspaceSidebarWidth() / SetWorkspaceSidebarWidth() internal/overview/workspaces.go

Windowing parity: project and global workspaces are one feature

The project workspace (internal/plugins/workspace) and the global Workspaces browser shown as Sessions in the navbar (internal/overview) are two projections of the same windowing model. They are not independent surfaces that happen to look similar.

If a change affects panes, splits, drag handles, pane borders, focus chrome, or pane hit regions in one of them, it affects the other. The rule is structural, not a habit to remember:

  • internal/panelayout owns pane-tree structure and geometry.
  • internal/paneframe owns presentation: chrome geometry (Inset, Geometry), the leaf border states (Chrome, WrapLeaf), the drag handle (RenderDividerHandle, DividerHitBox, HandleStateFor), the compositor (Compose, ComposeLeaf, RenderContent), the chrome-aware floors (ChromeFloors), and the one order hit regions are registered in (RegisterRegions), and click-to-focus (LeafAt, FocusLeafAt).
  • Each surface implements paneframe.Host and paneframe.RegionSink in exactly one file: internal/plugins/workspace/pane_host.go and internal/overview/pane_host.go.

Focus is one value, answered from geometry. Host.Focus() draws the ring and Host.SetFocus() moves it; there is no third place a surface may record who is being typed into, so a surface whose live terminal holds the keyboard separately gives it up inside its own focus setter (workspace.setFocusTarget, overview.focusPreviewLeaf). A pointer moves focus through paneframe.FocusLeafAt, which resolves the leaf from its OUTER box, not from the hit region the press landed on — a terminal leaf owns no click-to-focus region, because its presses belong to the live pane and are forwarded to tmux. FocusLeafAt moves focus and nothing else, so the press still reaches whichever region claimed it, and it declines the divider's widened target so a press one cell off a handle resizes without also re-focusing. Hanging focus off the region handlers instead is td-43db92: one focus call per leaf kind, and the ring drawn on a neighbour for the kind nobody remembered.

Host.Layout() must answer the tree the surface last drew, not one it could place. A view that replaces the preview — the kanban board, a modal — draws no tree, and geometry that outlives the frame lets a click on whatever is drawn there move pane focus instead. The project plugin records the layout beside the hit regions it earned (paneFrame/paneFrameDrawn, cleared with the hit map at the top of View); the global browser's previewPeerBox() already refuses when the preview is not drawn.

Do not add a second compositor, a second border rule, or a second divider renderer. If a behaviour belongs to windowing, it goes in paneframe; if it belongs to one surface's content, it goes in that surface's host file. Both surfaces then get it at once.

Tests that hold this: internal/paneframe/paneframe_test.go, internal/plugins/workspace/pane_peer_chrome_test.go, and internal/overview/pane_peer_chrome_test.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

Never hand-roll a divider. Use the shared handle so hover and drag colouring, the one-cell inset at each end, and the theme blend are the same everywhere:

divider := ui.RenderHandle(paneHeight, true, ui.HandleStateFrom(p.hoverDivider, dragging))

Inside a pane tree, go through the frame instead, which picks the axis and the per-split state for you:

handle := paneframe.RenderDividerHandle(divider, host.HandleState(divider.SplitID))
hit := paneframe.DividerHitBox(divider)

Step 8: Add State Persistence

Add plugin-specific functions to internal/state/state.go:

// In State struct
YourPluginSidebarWidth int `json:"yourPluginSidebarWidth,omitempty"`

// Getter
func GetYourPluginSidebarWidth() int {
    mu.RLock()
    defer mu.RUnlock()
    if current == nil { return 0 }
    return current.YourPluginSidebarWidth
}

// Setter
func SetYourPluginSidebarWidth(width int) error {
    mu.Lock()
    if current == nil { current = &State{} }
    current.YourPluginSidebarWidth = width
    mu.Unlock()
    return Save()
}

Critical Rules

Rule 1: Never Reset Width in View()

WRONG:

func (p *Plugin) View(width, height int) string {
    p.sidebarWidth = width * 30 / 100 // BUG: Overwrites drag changes every render!
}

CORRECT: Width is only set when sidebarWidth == 0. All other code paths must not unconditionally overwrite it.

Rule 2: Hit Region X Coordinates

Divider X position = sidebarWidth, NOT sidebarWidth + borderWidth.

When lipgloss renders Width(sidebarWidth), the pane occupies columns 0 to sidebarWidth-1. The divider starts at column sidebarWidth.

Rule 3: Hit Region Priority (Registration Order)

HitMap.Test() checks regions in reverse order - last added = checked first.

The divider region MUST be registered LAST so it takes priority over overlapping pane regions.

// CORRECT ORDER:
p.mouseHandler.HitMap.AddRect(regionSidebar, ...)      // Lowest priority
p.mouseHandler.HitMap.AddRect(regionMainPane, ...)     // Medium priority
p.mouseHandler.HitMap.AddRect(regionPaneDivider, ...)  // HIGHEST priority (last)

Rule 4: Divider Hit Width

Use dividerHitWidth := 3 (wider than the visual 1-character divider) to make clicking easier. Inside a pane tree, call paneframe.DividerHitBox rather than widening by hand: a row divider must widen only upward, or it masks the header row — tabs and close button — of the leaf stacked below it.

Rule 5: Height for Hit Regions

Use p.height for hit region height, not paneHeight or paneHeight + 2.

Performance Optimization: Hit Region Caching

For plugins with many hit regions, use a dirty flag to avoid rebuilding every render:

type Plugin struct {
    hitRegionsDirty bool
    prevWidth       int
    prevHeight      int
    prevScrollOff   int
}

func (p *Plugin) renderTwoPane() string {
    if p.width != p.prevWidth || p.height != p.prevHeight {
        p.hitRegionsDirty = true
        p.prevWidth = p.width
        p.prevHeight = p.height
    }
    if p.scrollOffset != p.prevScrollOff {
        p.hitRegionsDirty = true
        p.prevScrollOff = p.scrollOffset
    }

    // ... render content ...

    if p.hitRegionsDirty {
        p.mouseHandler.HitMap.Clear()
        // Register all hit regions...
        p.hitRegionsDirty = false
    }
    return content
}

Also mark hitRegionsDirty = true when:

  • View mode changes (toggling list/detail)
  • Content changes (items loaded, expanded/collapsed)
  • Sidebar visibility toggles

See internal/plugins/conversations/view_layout.go and plugin_input.go for a complete implementation.

Debugging

If drag is not working, add temporary logging:

func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    log.Printf("CLICK x=%d y=%d region=%v", action.X, action.Y, action.Region)
}

Common issues:

  • Region is nil or wrong pane: Check X coordinate calculation and registration order
  • Drag starts but width does not change: Check that handleMouseDrag is being called
  • Width resets after drag: Search for code that sets sidebarWidth unconditionally

Version History

  • b3ba2b5 Current 2026-08-20 01:11

    修复了全局工作区中焦点环跟随指针并正确接管键盘的问题;统一了全局工作区与项目工作区的面板框架和展示逻辑。

  • 0ddc6b7 2026-07-25 07:17

Same Skill Collection

.claude/skills/create-adapter/SKILL.md
.claude/skills/create-modal/SKILL.md
.claude/skills/create-plugin/SKILL.md
.claude/skills/create-prompt/SKILL.md
.claude/skills/create-theme/SKILL.md
.claude/skills/feature-flags/SKILL.md
.claude/skills/inline-editor/SKILL.md
.claude/skills/keyboard-shortcuts/SKILL.md
.claude/skills/merge-strategy/SKILL.md
.claude/skills/profile-memory/SKILL.md
.claude/skills/project-switching/SKILL.md
.claude/skills/release-sidecar/SKILL.md
.claude/skills/shell-integration/SKILL.md
.claude/skills/sidecar-website/SKILL.md
.claude/skills/ui-features/SKILL.md
.claude/skills/worktree-switching/SKILL.md

Metadata

Files
0
Version
b3ba2b5
Hash
aad5a0ea
Indexed
2026-07-25 07:17

Главная - Вики-сайт
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-20 13:40
浙ICP备14020137号-1 $Гость$