-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Summary
Implement comprehensive mouse event handling for the Yellowwood file tree, including left-click navigation/opening, right-click context menus, and scroll wheel support. This provides a complete mouse-driven workflow alongside keyboard navigation, making Yellowwood feel native to mouse users while maintaining keyboard ergonomics.
Context
Yellowwood must support both mouse and keyboard workflows seamlessly. Mouse support includes:
- Left-click on folders: Toggle expansion/collapse
- Left-click on files: Open with default editor or select (configurable)
- Right-click: Open context menu (where terminals support it)
- Scroll wheel: Scroll the tree viewport
- Click handling: Translate terminal mouse coordinates to tree row selection
Current State:
src/types/index.tsdefinesYellowwoodConfigwithui.leftClickAction(not yet in types - will be added)- No mouse handling exists yet
- Ink 6.5 supports mouse events via Box component props
- Terminal mouse support varies (most modern terminals support it)
How This Will Be Used:
// In TreeView component
const { handleClick, handleScroll } = useMouse({
fileTree: flattenedTree,
selectedPath,
onSelect,
onToggle,
onOpen,
onContextMenu,
config,
});
return (
<Box flexDirection="column" onMouseDown={handleClick} onScroll={handleScroll}>
{visibleNodes.map((node, index) => (
<TreeNode key={node.path} node={node} index={index} />
))}
</Box>
);Why Ink mouse handling?
- Ink provides mouse event abstractions over terminal escape sequences
- Works with terminals that support mouse reporting (most modern terminals)
- Provides coordinates, button info, and scroll events
- Falls back gracefully when mouse not supported
- No need to parse raw escape sequences
Terminal Compatibility:
- ✅ iTerm2, Ghostty, kitty, WezTerm, Windows Terminal, Alacritty
- ✅ VS Code integrated terminal
⚠️ Some terminals may require mouse reporting mode enabled- ❌ Very old terminals (pre-xterm mouse support)
Deliverables
Code Changes
Files to Create:
src/hooks/useMouse.ts- Mouse event handling hook
Hook Interface:
interface UseMouseOptions {
fileTree: TreeNode[]; // Flattened tree for index-to-node mapping
selectedPath: string | null; // Currently selected path
scrollOffset: number; // Current scroll offset
viewportHeight: number; // Number of visible rows
onSelect: (path: string) => void; // Select a file/folder
onToggle: (path: string) => void; // Toggle folder expansion
onOpen: (path: string) => void; // Open file with editor
onContextMenu: (path: string, position: { x: number; y: number }) => void;
config: YellowwoodConfig; // For ui.leftClickAction setting
}
interface UseMouseReturn {
handleClick: (event: MouseEvent) => void;
handleScroll: (event: ScrollEvent) => void;
handleRightClick: (event: MouseEvent) => void;
}
export function useMouse(options: UseMouseOptions): UseMouseReturnImplementation Details
Step-by-Step Implementation:
-
Set up hook structure:
- Import types and Ink mouse event types
- Define options and return interfaces
- Set up event handler functions
-
Implement coordinate-to-row mapping:
- Mouse events provide terminal coordinates (x, y)
- Convert y coordinate to tree row index
- Account for scroll offset (y + scrollOffset)
- Account for header rows (y - headerHeight)
- Bounds check to prevent out-of-range access
-
Implement left-click handling:
- Get clicked row index from coordinates
- Look up TreeNode in flattened tree
- Check node type (file vs directory)
- For directories: always toggle expansion
- For files: respect config.ui.leftClickAction
- 'open': Call onOpen(path)
- 'select': Call onSelect(path)
- Handle clicks on empty space (no-op)
-
Implement right-click handling:
- Get clicked row and node
- Call onContextMenu with path and mouse position
- Position used to place context menu near cursor
- Keyboard fallback: 'm' key can trigger context menu at current selection
-
Implement scroll handling:
- Scroll events provide delta (positive or negative)
- Translate to viewport offset changes
- Clamp to valid range (0 to maxScroll)
- Caller updates scroll state and re-renders
-
Add terminal compatibility detection:
- Check if terminal supports mouse events
- Provide fallback message if mouse not supported
- Log warning once on first attempt if unsupported
Complete Implementation:
import { useCallback } from 'react';
import type { TreeNode, YellowwoodConfig } from '../types/index.js';
// Ink's mouse event types (from Ink 6.5)
interface MouseEvent {
x: number; // Column (0-based)
y: number; // Row (0-based)
button: 'left' | 'right' | 'middle';
shift: boolean;
ctrl: boolean;
meta: boolean;
}
interface ScrollEvent {
x: number;
y: number;
deltaY: number; // Scroll amount (positive = down, negative = up)
}
interface UseMouseOptions {
fileTree: TreeNode[]; // Flattened tree (visible rows)
selectedPath: string | null;
scrollOffset: number; // Current scroll position
viewportHeight: number; // Visible rows
headerHeight: number; // Rows before tree starts (Header component)
onSelect: (path: string) => void;
onToggle: (path: string) => void;
onOpen: (path: string) => void;
onContextMenu: (path: string, position: { x: number; y: number }) => void;
onScrollChange: (newOffset: number) => void;
config: YellowwoodConfig;
}
interface UseMouseReturn {
handleClick: (event: MouseEvent) => void;
handleScroll: (event: ScrollEvent) => void;
}
/**
* Mouse event handling hook for tree navigation.
*
* Provides handlers for:
* - Left-click: Select/open files, toggle folders
* - Right-click: Context menu
* - Scroll: Navigate tree viewport
*
* @param options - Configuration and callbacks
* @returns Event handlers for mouse interactions
*/
export function useMouse(options: UseMouseOptions): UseMouseReturn {
const {
fileTree,
selectedPath,
scrollOffset,
viewportHeight,
headerHeight = 1,
onSelect,
onToggle,
onOpen,
onContextMenu,
onScrollChange,
config,
} = options;
/**
* Convert mouse y coordinate to tree row index.
* Accounts for header offset and scroll position.
*/
const getRowIndexFromY = useCallback((y: number): number | null => {
// Subtract header rows to get relative tree row
const relativeY = y - headerHeight;
// Check if click is in header or above tree
if (relativeY < 0) {
return null;
}
// Add scroll offset to get absolute tree index
const rowIndex = relativeY + scrollOffset;
// Bounds check
if (rowIndex < 0 || rowIndex >= fileTree.length) {
return null;
}
return rowIndex;
}, [headerHeight, scrollOffset, fileTree.length]);
/**
* Handle left and right mouse clicks.
*/
const handleClick = useCallback((event: MouseEvent) => {
const rowIndex = getRowIndexFromY(event.y);
// Click on empty space or header
if (rowIndex === null) {
return;
}
const node = fileTree[rowIndex];
if (!node) {
return;
}
// Right-click: context menu
if (event.button === 'right') {
onContextMenu(node.path, { x: event.x, y: event.y });
return;
}
// Left-click behavior depends on node type and config
if (event.button === 'left') {
if (node.type === 'directory') {
// Directories: always toggle expansion
onToggle(node.path);
} else {
// Files: respect config.ui.leftClickAction
const action = config.ui?.leftClickAction || 'open';
if (action === 'open') {
onOpen(node.path);
} else {
onSelect(node.path);
}
}
}
// Middle-click: ignore for now (future: could open in new pane)
}, [getRowIndexFromY, fileTree, onToggle, onOpen, onSelect, onContextMenu, config]);
/**
* Handle scroll wheel events.
*/
const handleScroll = useCallback((event: ScrollEvent) => {
// deltaY is typically in increments of mouse wheel notches
// Positive = scroll down (increase offset)
// Negative = scroll up (decrease offset)
const scrollLines = Math.sign(event.deltaY) * 3; // Scroll 3 lines per notch
const newOffset = scrollOffset + scrollLines;
// Calculate max scroll (total rows - viewport height)
const maxScroll = Math.max(0, fileTree.length - viewportHeight);
// Clamp to valid range
const clampedOffset = Math.max(0, Math.min(newOffset, maxScroll));
if (clampedOffset !== scrollOffset) {
onScrollChange(clampedOffset);
}
}, [scrollOffset, fileTree.length, viewportHeight, onScrollChange]);
return {
handleClick,
handleScroll,
};
}Mouse Event Flow:
-
User clicks in terminal:
- Terminal sends mouse event escape sequence
- Ink captures and parses into MouseEvent
- Ink calls onMouseDown prop on Box component
- Our handleClick receives normalized event
-
Coordinate mapping:
- Event.y is absolute terminal row (0-based)
- Subtract headerHeight to get tree-relative row
- Add scrollOffset to get index in flattened tree
- Look up TreeNode at that index
-
Action dispatch:
- Folder + left-click → onToggle
- File + left-click + config='open' → onOpen
- File + left-click + config='select' → onSelect
- Any + right-click → onContextMenu
-
Scroll handling:
- Scroll wheel sends ScrollEvent with deltaY
- Convert to line-based scrolling (3 lines per notch)
- Clamp to valid range (0 to maxScroll)
- Call onScrollChange to update state
Configuration Integration:
The ui.leftClickAction config option (to be added to types):
// In src/types/index.ts - add to YellowwoodConfig
export interface YellowwoodConfig {
// ... existing fields ...
ui?: {
leftClickAction?: 'open' | 'select'; // Default: 'open'
compactMode?: boolean;
showStatusBar?: boolean;
};
}
// Update DEFAULT_CONFIG
export const DEFAULT_CONFIG: YellowwoodConfig = {
// ... existing fields ...
ui: {
leftClickAction: 'open',
compactMode: true,
showStatusBar: true,
},
};Error Handling:
-
Invalid coordinates:
- Out of bounds y coordinate → return null, no-op
- Click on header or empty space → return null, no-op
-
Mouse not supported:
- Ink handles detection
- Events simply won't fire
- Keyboard navigation still works (graceful degradation)
-
Rapid clicking:
- Each click processes independently
- No debouncing needed (clicks are intentional)
- Double-click = two single clicks (intended)
-
Scroll overflow:
- Clamping prevents scroll beyond tree bounds
- Negative offset clamped to 0
- Excessive offset clamped to maxScroll
Performance Considerations:
- Mouse events are frequent (every click, scroll notch)
useCallbackprevents handler recreation on every render- Row index calculation is O(1)
- Node lookup in flattened tree is O(1) (array access)
- No expensive operations in event handlers
Tests
Unit Tests:
Create tests/hooks/test_useMouse.ts:
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMouse } from '../../src/hooks/useMouse.js';
import type { TreeNode, YellowwoodConfig } from '../../src/types/index.js';
import { DEFAULT_CONFIG } from '../../src/types/index.js';
describe('useMouse', () => {
const mockTree: TreeNode[] = [
{ name: 'src', path: 'src', type: 'directory', depth: 0, expanded: true },
{ name: 'App.tsx', path: 'src/App.tsx', type: 'file', depth: 1 },
{ name: 'utils', path: 'src/utils', type: 'directory', depth: 1, expanded: false },
{ name: 'README.md', path: 'README.md', type: 'file', depth: 0 },
];
const createOptions = (overrides = {}) => ({
fileTree: mockTree,
selectedPath: null,
scrollOffset: 0,
viewportHeight: 10,
headerHeight: 1,
onSelect: vi.fn(),
onToggle: vi.fn(),
onOpen: vi.fn(),
onContextMenu: vi.fn(),
onScrollChange: vi.fn(),
config: DEFAULT_CONFIG,
...overrides,
});
describe('handleClick', () => {
it('toggles folder on left-click', () => {
const onToggle = vi.fn();
const { result } = renderHook(() => useMouse(createOptions({ onToggle })));
// Click on first row (src folder) after 1-row header
result.current.handleClick({
x: 0,
y: 1, // Header is row 0, first tree row is 1
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onToggle).toHaveBeenCalledWith('src');
});
it('opens file on left-click when config is "open"', () => {
const onOpen = vi.fn();
const config = {
...DEFAULT_CONFIG,
ui: { leftClickAction: 'open' as const },
};
const { result } = renderHook(() => useMouse(createOptions({ onOpen, config })));
// Click on second row (App.tsx file)
result.current.handleClick({
x: 0,
y: 2,
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onOpen).toHaveBeenCalledWith('src/App.tsx');
});
it('selects file on left-click when config is "select"', () => {
const onSelect = vi.fn();
const config = {
...DEFAULT_CONFIG,
ui: { leftClickAction: 'select' as const },
};
const { result } = renderHook(() => useMouse(createOptions({ onSelect, config })));
// Click on file
result.current.handleClick({
x: 0,
y: 2,
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onSelect).toHaveBeenCalledWith('src/App.tsx');
});
it('opens context menu on right-click', () => {
const onContextMenu = vi.fn();
const { result } = renderHook(() => useMouse(createOptions({ onContextMenu })));
result.current.handleClick({
x: 10,
y: 2,
button: 'right',
shift: false,
ctrl: false,
meta: false,
});
expect(onContextMenu).toHaveBeenCalledWith('src/App.tsx', { x: 10, y: 2 });
});
it('ignores clicks on header', () => {
const onToggle = vi.fn();
const onOpen = vi.fn();
const onSelect = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({ onToggle, onOpen, onSelect }))
);
// Click on row 0 (header)
result.current.handleClick({
x: 0,
y: 0,
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onToggle).not.toHaveBeenCalled();
expect(onOpen).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it('ignores clicks beyond tree length', () => {
const onToggle = vi.fn();
const { result } = renderHook(() => useMouse(createOptions({ onToggle })));
// Click on row 100 (way beyond tree)
result.current.handleClick({
x: 0,
y: 100,
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onToggle).not.toHaveBeenCalled();
});
it('accounts for scroll offset when mapping coordinates', () => {
const onToggle = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({ onToggle, scrollOffset: 2 }))
);
// Click on row 1 (first tree row on screen)
// With scrollOffset=2, this is actually tree index 2 (src/utils folder)
result.current.handleClick({
x: 0,
y: 1,
button: 'left',
shift: false,
ctrl: false,
meta: false,
});
expect(onToggle).toHaveBeenCalledWith('src/utils');
});
});
describe('handleScroll', () => {
it('scrolls down on positive deltaY', () => {
const onScrollChange = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({ onScrollChange, scrollOffset: 0 }))
);
result.current.handleScroll({
x: 0,
y: 5,
deltaY: 1, // One notch down
});
// Should scroll down by 3 lines (default)
expect(onScrollChange).toHaveBeenCalledWith(3);
});
it('scrolls up on negative deltaY', () => {
const onScrollChange = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({ onScrollChange, scrollOffset: 5 }))
);
result.current.handleScroll({
x: 0,
y: 5,
deltaY: -1, // One notch up
});
// Should scroll up by 3 lines
expect(onScrollChange).toHaveBeenCalledWith(2); // 5 - 3 = 2
});
it('clamps scroll to 0 minimum', () => {
const onScrollChange = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({ onScrollChange, scrollOffset: 1 }))
);
result.current.handleScroll({
x: 0,
y: 5,
deltaY: -5, // Large negative delta
});
// Should clamp to 0
expect(onScrollChange).toHaveBeenCalledWith(0);
});
it('clamps scroll to max scroll', () => {
const onScrollChange = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({
onScrollChange,
scrollOffset: 0,
viewportHeight: 2,
// fileTree has 4 items, viewport is 2, so maxScroll = 2
}))
);
result.current.handleScroll({
x: 0,
y: 5,
deltaY: 10, // Large positive delta
});
// Should clamp to maxScroll = 4 - 2 = 2
expect(onScrollChange).toHaveBeenCalledWith(2);
});
it('does not call onScrollChange if offset unchanged', () => {
const onScrollChange = vi.fn();
const { result } = renderHook(() =>
useMouse(createOptions({
onScrollChange,
scrollOffset: 0,
viewportHeight: 10,
}))
);
// Try to scroll up when already at top
result.current.handleScroll({
x: 0,
y: 5,
deltaY: -1,
});
// Should not call because clamping results in same offset
expect(onScrollChange).not.toHaveBeenCalled();
});
});
});Test Coverage:
- ✓ Left-click on folder toggles expansion
- ✓ Left-click on file opens (when config='open')
- ✓ Left-click on file selects (when config='select')
- ✓ Right-click opens context menu with position
- ✓ Clicks on header are ignored
- ✓ Clicks beyond tree bounds are ignored
- ✓ Scroll offset is accounted for in coordinate mapping
- ✓ Scroll down increases offset
- ✓ Scroll up decreases offset
- ✓ Scroll clamped to minimum (0)
- ✓ Scroll clamped to maximum (tree length - viewport)
- ✓ No-op scroll doesn't trigger state update
Integration Testing:
Manual testing with real terminal:
# Build and run
npm run dev
# Test mouse interactions:
# 1. Left-click folders - should toggle
# 2. Left-click files - should open in editor
# 3. Right-click - should show context menu
# 4. Scroll wheel - should scroll tree
# 5. Click header - should do nothing
# 6. Scroll at top/bottom - should clamp
# Test in different terminals:
# - iTerm2 / Ghostty / kitty
# - VS Code integrated terminal
# - Windows Terminal
# - SSH session (mouse may not work)Technical Specifications
Footprint:
- Modules touched: mouse-handler
- Files created:
src/hooks/useMouse.ts,tests/hooks/test_useMouse.ts - Files modified:
src/types/index.ts(adduiconfig section) - Dependencies: None (uses Ink's built-in mouse support)
Ink Mouse Event Types:
// From Ink 6.5
interface MouseEvent {
x: number; // Column (0-indexed)
y: number; // Row (0-indexed)
button: 'left' | 'right' | 'middle';
shift: boolean; // Modifier keys
ctrl: boolean;
meta: boolean;
}
interface ScrollEvent {
x: number;
y: number;
deltaY: number; // Scroll delta (positive = down)
}Box Component Mouse Props:
<Box
onMouseDown={(event: MouseEvent) => void}
onScroll={(event: ScrollEvent) => void}
>Size Estimate:
- Size: S (2 points)
- Effort: 4-6 hours total
- Research Ink mouse API: 30 min
- Implementation: 2 hours
- Testing: 1.5-2 hours
- Manual testing across terminals: 1 hour
Dependencies
Strict Dependencies (Blocking):
- Implement configuration loading system #1 (Configuration loading) - Needs
config.ui.leftClickAction
Flexible Dependencies (Informational):
- Implement useKeyboard hook for input handling #7 (useKeyboard hook) - Related but independent
- Implement TreeNode component #14 (TreeNode component) - Will use these handlers
- Implement worktree slash commands #28 (TreeView enhancements) - Will integrate mouse handlers
Assumptions:
- Ink 6.5 is installed with mouse support (✓ verified)
- Terminal supports mouse reporting (most modern terminals do)
- Flattened tree is provided by caller (TreeView component)
- Config includes
ui.leftClickActionoption
Acceptance Criteria
-
useMousehook provideshandleClickandhandleScrollhandlers - Left-click on folders toggles expansion
- Left-click on files opens them (when config.ui.leftClickAction='open')
- Left-click on files selects them (when config.ui.leftClickAction='select')
- Right-click triggers context menu with correct path and position
- Scroll wheel scrolls viewport up/down
- Scroll clamped to valid range (0 to maxScroll)
- Header clicks are ignored (no action)
- Out-of-bounds clicks are ignored
- Scroll offset correctly maps screen coordinates to tree indices
- Multi-row header offset is handled correctly
- All unit tests pass (12+ test cases)
- Manual testing in 3+ terminals confirms functionality
- Mouse events don't interfere with keyboard navigation
Implementation Guidance
Recommended Approach:
-
Research Ink mouse API (30 min)
- Read Ink documentation on mouse events
- Test mouse events in simple Ink app
- Understand coordinate system (0-indexed)
- Test in multiple terminals
-
Add ui config to types (15 min)
- Update
YellowwoodConfiginterface - Add
uiobject withleftClickAction - Update
DEFAULT_CONFIG
- Update
-
Implement coordinate mapping (30 min)
- Create
getRowIndexFromYhelper - Account for header offset
- Account for scroll offset
- Add bounds checking
- Create
-
Implement handleClick (1 hour)
- Handle left-click for folders (toggle)
- Handle left-click for files (open or select)
- Handle right-click (context menu)
- Test each path
-
Implement handleScroll (30 min)
- Convert deltaY to line scroll
- Calculate new offset
- Clamp to valid range
- Call onScrollChange
-
Write comprehensive tests (1.5 hours)
- Test each click type
- Test coordinate mapping
- Test scroll clamping
- Test config variations
-
Manual verification (1 hour)
- Test in multiple terminals
- Verify smooth scrolling
- Verify click accuracy
- Test edge cases (empty tree, small tree)
Patterns to Follow:
- useCallback: Wrap all handlers to prevent recreation
- Bounds checking: Always validate coordinates before array access
- Graceful degradation: Handle missing mouse support (no-op)
- Type safety: Use Ink's MouseEvent and ScrollEvent types
- Immutability: Don't mutate event objects
Patterns to Avoid:
- Don't track mouse position state (events are stateless)
- Don't implement double-click detection (two singles is fine)
- Don't add drag-and-drop (not in MVP)
- Don't implement hover effects (terminal limitations)
- Don't debounce clicks (each click is intentional)
Code Quality Checklist:
- All functions have JSDoc comments
- All handlers use useCallback
- Coordinate mapping is well-documented
- TypeScript strict mode passes
- No console.log (only console.warn for unsupported terminals)
- Use .js extensions in imports
Edge Cases & Gotchas
Terminal coordinate systems:
- Ink provides 0-indexed coordinates (top-left is 0,0)
- Some terminals use 1-indexed (xterm)
- Ink normalizes this - we always get 0-indexed
- No special handling needed
Header height variability:
- Header might be multiple rows
- Always pass headerHeight as parameter
- Don't hardcode to 1
Scroll offset boundaries:
- Empty tree: maxScroll = 0
- Small tree (fits in viewport): maxScroll = 0
- Large tree: maxScroll = treeLength - viewportHeight
- Always clamp before updating state
Mouse not supported:
- Some terminals don't support mouse reporting
- Ink detects this - events won't fire
- Provide keyboard fallbacks
- Don't show error (graceful degradation)
Terminal focus required:
- Mouse events only fire when terminal has focus
- Obvious to user (terminal window is active)
- No special handling needed
Click accuracy at edges:
- Last row of viewport
- First row after header
- Test edge cases explicitly
Rapid scrolling:
- User scrolls quickly with wheel
- Each notch is separate event
- State updates accumulate correctly
- No throttling needed (terminal limits rate)
Context menu positioning:
- Pass actual mouse coordinates
- Context menu component uses them for placement
- Might adjust if near screen edge (future)
Multi-column layouts:
- x coordinate currently unused
- Could detect clicks on git status column (future)
- For now, any x in row triggers action
Right-click on macOS:
- Two-finger tap or Ctrl+click
- Terminals handle translation
- We just check event.button === 'right'
Scroll direction:
- Positive deltaY = scroll down (increase offset)
- Negative deltaY = scroll up (decrease offset)
- This matches natural scroll direction
Flattened tree changes:
- Expansion/collapse changes flattened tree
- Indices shift
- Hook gets new tree on each render
- Old click handlers are discarded (useCallback dependencies)
Resources
External Documentation:
- Ink Mouse Events - Mouse handling in Ink
- Terminal Mouse Reporting - How terminals send mouse events
- React useCallback - Memoizing callbacks
Internal References:
- SPEC.md section 5.2 "Mouse Support" - Full requirements
- SPEC.md section 5.5 "Keyboard Navigation" - Keyboard fallbacks
src/types/index.ts:6-16- TreeNode interfacesrc/types/index.ts:24-44- YellowwoodConfig interface
Similar Implementations:
- VS Code tree view mouse handling
- ranger (CLI file manager) mouse support
- Midnight Commander mouse support
Notes
Why this matters:
Many developers prefer mouse workflows, especially when working in narrow panes. Supporting mouse makes Yellowwood feel native and modern, not just keyboard-only.
Design philosophy:
- Mouse and keyboard are equal citizens (not mouse-first or keyboard-first)
- All mouse actions have keyboard equivalents
- Mouse provides spatial navigation, keyboard provides fast navigation
- Graceful degradation when mouse unavailable
Terminal compatibility:
Testing across terminals is critical. Priority terminals:
- Ghostty - Target terminal for narrow panes
- iTerm2 - Popular macOS terminal
- kitty - Fast, feature-rich
- VS Code integrated - Common for development
- Windows Terminal - Windows users
Future enhancements (NOT in this issue):
- Drag-and-drop file operations
- Double-click to open (distinct from single-click)
- Hover effects (if terminal supports)
- Click-and-drag selection (multi-select)
- Mouse gesture support (swipe to scroll)
- Horizontal scrolling for long filenames
- Click on git status column to filter
- Click on worktree indicator to switch
- Touch support for terminals (iPad, etc.)
Performance considerations:
- Mouse events are frequent
- useCallback prevents handler recreation
- Coordinate mapping is O(1)
- No DOM-style event bubbling
- Terminal rate-limits events naturally
Accessibility:
- Mouse is optional, not required
- All actions have keyboard equivalents
- Provides choice for users with different preferences
- No mouse-only features
Integration notes:
This hook will be used by:
- TreeView component (Implement worktree slash commands #28) - Main integration point
- ContextMenu component (Implement ContextMenu component #23) - Receives right-click events
- Future: Other components with clickable elements
Keep hook stateless - caller manages all state (selection, scroll, expansion). Hook only translates events to actions.
Testing strategy:
- Unit tests cover logic and edge cases
- Integration tests verify terminal compatibility
- Manual testing essential for feel/smoothness
- Test rapid clicking/scrolling
- Test with large trees (100+ files)
Common pitfalls:
- Forgetting to account for scroll offset
- Hardcoding header height
- Not clamping scroll values
- Assuming mouse always works
- Not using useCallback (performance)
- Mutating event objects