v0.11.0 dc50343

focus_state_machine.go RawHistoryFolder

1// focus_state_machine.go - Centralized focus state management with queue-based serialization
2package browser
3
4import (
5	"container/heap"
6	"fmt"
7	"log"
8	"runtime/debug"
9	"sync"
10	"time"
11
12	"github.com/bnema/dumber/pkg/webkit"
13)
14
15// FocusState represents the current state of the focus management system
16type FocusState string
17
18const (
19	StateInitializing  FocusState = "initializing"  // System startup, determining initial focus
20	StateIdle          FocusState = "idle"          // No focus transition in progress, system stable
21	StateTransitioning FocusState = "transitioning" // Processing a focus change request
22	StateFocused       FocusState = "focused"       // Focus successfully applied, awaiting next request
23	StateReconciling   FocusState = "reconciling"   // Fixing inconsistencies detected in CSS/focus state
24)
25
26// FocusSource identifies where a focus request originated
27type FocusSource string
28
29const (
30	SourceKeyboard     FocusSource = "keyboard"     // User keyboard navigation
31	SourceMouse        FocusSource = "mouse"        // Mouse hover/click
32	SourceProgrammatic FocusSource = "programmatic" // API call
33	SourceSystem       FocusSource = "system"       // System initialization
34	SourceStackNav     FocusSource = "stack-nav"    // Stack navigation
35	SourceSplit        FocusSource = "split"        // Pane split operation
36	SourceClose        FocusSource = "close"        // Pane close operation
37)
38
39// Priority levels for focus requests
40const (
41	PriorityLow    = 10
42	PriorityNormal = 50
43	PriorityHigh   = 90
44	PriorityUrgent = 100
45)
46
47// FocusRequest represents a request to change focus to a specific pane
48type FocusRequest struct {
49	ID         string                 // Unique request identifier
50	TargetNode *paneNode              // Target pane to focus
51	Source     FocusSource            // Where the request originated
52	Priority   int                    // Request priority (higher = more urgent)
53	Timestamp  time.Time              // When the request was created
54	Context    map[string]interface{} // Additional metadata
55}
56
57// FocusTransition records a completed focus change for debugging
58type FocusTransition struct {
59	From      *paneNode   // Previous focus target
60	To        *paneNode   // New focus target
61	State     FocusState  // State during transition
62	Source    FocusSource // Request source
63	Timestamp time.Time   // When transition occurred
64	Success   bool        // Whether transition succeeded
65	Error     error       // Error if transition failed
66	Stack     []byte      // Stack trace for debugging
67}
68
69// RingBuffer provides a thread-safe circular buffer for focus history
70type RingBuffer[T any] struct {
71	buffer []T
72	head   int
73	tail   int
74	size   int
75	cap    int
76	mu     sync.RWMutex
77}
78
79// NewRingBuffer creates a new ring buffer with the specified capacity
80func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
81	return &RingBuffer[T]{
82		buffer: make([]T, capacity),
83		cap:    capacity,
84	}
85}
86
87// Add inserts an item into the ring buffer
88func (rb *RingBuffer[T]) Add(item T) {
89	rb.mu.Lock()
90	defer rb.mu.Unlock()
91
92	rb.buffer[rb.head] = item
93	rb.head = (rb.head + 1) % rb.cap
94
95	if rb.size < rb.cap {
96		rb.size++
97	} else {
98		rb.tail = (rb.tail + 1) % rb.cap
99	}
100}
101
102// GetAll returns all items in the ring buffer in chronological order
103func (rb *RingBuffer[T]) GetAll() []T {
104	rb.mu.RLock()
105	defer rb.mu.RUnlock()
106
107	if rb.size == 0 {
108		return nil
109	}
110
111	result := make([]T, rb.size)
112	for i := 0; i < rb.size; i++ {
113		idx := (rb.tail + i) % rb.cap
114		result[i] = rb.buffer[idx]
115	}
116	return result
117}
118
119// FocusPriorityQueue implements a priority queue for focus requests
120type FocusPriorityQueue []*FocusRequest
121
122func (pq FocusPriorityQueue) Len() int { return len(pq) }
123
124func (pq FocusPriorityQueue) Less(i, j int) bool {
125	// Higher priority value = higher priority (processed first)
126	return pq[i].Priority > pq[j].Priority
127}
128
129func (pq FocusPriorityQueue) Swap(i, j int) {
130	pq[i], pq[j] = pq[j], pq[i]
131}
132
133func (pq *FocusPriorityQueue) Push(x interface{}) {
134	*pq = append(*pq, x.(*FocusRequest))
135}
136
137func (pq *FocusPriorityQueue) Pop() interface{} {
138	old := *pq
139	n := len(old)
140	item := old[n-1]
141	*pq = old[0 : n-1]
142	return item
143}
144
145// RequestDeduplicator prevents duplicate requests from flooding the queue
146type RequestDeduplicator struct {
147	mu         sync.RWMutex
148	recentSigs map[string]time.Time
149	ttl        time.Duration
150}
151
152// NewRequestDeduplicator creates a new request deduplicator
153func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
154	return &RequestDeduplicator{
155		recentSigs: make(map[string]time.Time),
156		ttl:        ttl,
157	}
158}
159
160// IsDuplicate checks if a request is a duplicate within the TTL window
161func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
162	rd.mu.Lock()
163	defer rd.mu.Unlock()
164
165	// Clean up expired signatures
166	now := time.Now()
167	for sig, timestamp := range rd.recentSigs {
168		if now.Sub(timestamp) > rd.ttl {
169			delete(rd.recentSigs, sig)
170		}
171	}
172
173	// Generate signature for this request
174	sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175
176	// Check if this signature exists
177	if _, exists := rd.recentSigs[sig]; exists {
178		return true
179	}
180
181	// Record this signature
182	rd.recentSigs[sig] = now
183	return false
184}
185
186// FocusStateMachine manages all focus operations through a centralized state machine
187type FocusStateMachine struct {
188	mu               sync.RWMutex
189	wm               *WorkspaceManager            // Reference to workspace manager
190	currentState     FocusState                   // Current state of the focus system
191	activePane       *paneNode                    // Currently focused pane
192	requestQueue     chan FocusRequest            // Queue for serializing focus requests
193	priorityQueue    *FocusPriorityQueue          // Priority queue for request ordering
194	pendingRequests  chan struct{}                // Signal channel for pending requests
195	historyRing      *RingBuffer[FocusTransition] // History of focus transitions
196	validators       []FocusValidator             // Request validators
197	settlingTimer    *time.Timer                  // Timer for state settling
198	queueProcessor   *sync.WaitGroup              // Wait group for queue processor
199	shutdownChan     chan struct{}                // Shutdown signal
200	requestIDCounter uint64                       // Counter for generating request IDs
201
202	// Request coalescing for rapid focus changes
203	lastRequestTime  time.Time            // Time of last request
204	coalescingWindow time.Duration        // Window for coalescing requests
205	deduplicator     *RequestDeduplicator // Request deduplication
206
207	// Performance metrics tracking
208	metrics         FocusMetrics    // Performance metrics
209	processTimes    []time.Duration // Recent process times for averaging
210	maxProcessTimes int             // Max number of process times to keep
211
212	// Debug configuration
213	debugEnabled   bool // Enable focus debug logging
214	metricsEnabled bool // Enable metrics tracking
215	// Reconciliation loop prevention
216	lastReconcileTime    time.Time // Last reconciliation timestamp
217	reconcileCount       int       // Recent reconciliation count
218	maxReconcileAttempts int       // Max reconcile attempts per second
219}
220
221// FocusValidator validates focus requests before processing
222type FocusValidator func(request FocusRequest) error
223
224// Predefined errors
225var (
226	ErrFocusQueueFull        = fmt.Errorf("focus request queue is full")
227	ErrInvalidTarget         = fmt.Errorf("invalid focus target")
228	ErrInvalidStackOperation = fmt.Errorf("invalid stack operation")
229	ErrStateMachineShutdown  = fmt.Errorf("focus state machine is shutting down")
230)
231
232// NewFocusStateMachine creates a new focus state machine
233func NewFocusStateMachine(wm *WorkspaceManager) *FocusStateMachine {
234	pq := &FocusPriorityQueue{}
235	heap.Init(pq)
236
237	fsm := &FocusStateMachine{
238		wm:               wm,
239		currentState:     StateInitializing,
240		requestQueue:     make(chan FocusRequest, 100), // Buffered channel with capacity 100
241		priorityQueue:    pq,
242		pendingRequests:  make(chan struct{}, 1),             // Signal for priority queue processing
243		historyRing:      NewRingBuffer[FocusTransition](50), // Keep last 50 transitions
244		validators:       make([]FocusValidator, 0),
245		queueProcessor:   &sync.WaitGroup{},
246		shutdownChan:     make(chan struct{}),
247		coalescingWindow: 50 * time.Millisecond,                          // 50ms coalescing window
248		deduplicator:     NewRequestDeduplicator(200 * time.Millisecond), // 200ms dedup TTL
249		maxProcessTimes:  100,                                            // Keep last 100 process times for averaging
250		// Debug flags will be set via ConfigureDebug()
251		debugEnabled:   false,
252		metricsEnabled: false,
253		// Reconciliation loop prevention
254		maxReconcileAttempts: 3, // Max 3 reconciliation attempts per second
255	}
256
257	// Add default validators
258	fsm.addDefaultValidators()
259
260	return fsm
261}
262
263// ConfigureDebug configures debug settings for the focus state machine
264func (fsm *FocusStateMachine) ConfigureDebug(focusDebug, _ bool, metricsEnabled bool) {
265	fsm.mu.Lock()
266	defer fsm.mu.Unlock()
267
268	fsm.debugEnabled = focusDebug
269	fsm.metricsEnabled = metricsEnabled
270
271	if focusDebug {
272		log.Printf("[FSM] Focus debug logging enabled")
273	}
274	if metricsEnabled {
275		log.Printf("[FSM] Focus metrics tracking enabled")
276	}
277}
278
279// Initialize sets up the focus state machine and determines initial focus
280func (fsm *FocusStateMachine) Initialize() error {
281	fsm.mu.Lock()
282	defer fsm.mu.Unlock()
283
284	log.Printf("[FSM] Initializing focus state machine")
285	fsm.currentState = StateInitializing
286
287	// Determine initial focus
288	leaves := fsm.wm.collectLeaves()
289	if len(leaves) == 0 {
290		log.Printf("[FSM] No panes found during initialization")
291		fsm.currentState = StateIdle
292		return nil
293	}
294
295	initialPane := fsm.determineInitialFocus(leaves)
296	if initialPane == nil {
297		log.Printf("[FSM] Could not determine initial focus target")
298		fsm.currentState = StateIdle
299		return nil
300	}
301
302	// Phase 3: Apply initial focus and CSS
303	if err := fsm.applyInitialFocus(initialPane); err != nil {
304		log.Printf("[FSM] Failed to apply initial focus: %v", err)
305		fsm.currentState = StateIdle
306		return err
307	}
308
309	// Phase 4: Attach GTK focus controllers to all panes
310	fsm.attachGTKControllersToAllPanes()
311
312	// Phase 5: Start queue processor
313	fsm.queueProcessor.Add(1)
314	go fsm.processQueue()
315
316	// Phase 6: Start CSS verification loop
317	go fsm.startCSSVerificationLoop()
318
319	// Phase 7: Transition to idle state
320	fsm.currentState = StateIdle
321
322	log.Printf("[FSM] Focus state machine initialized successfully with pane %p", initialPane)
323	return nil
324}
325
326// Shutdown gracefully shuts down the focus state machine
327func (fsm *FocusStateMachine) Shutdown() {
328	fsm.mu.Lock()
329	defer fsm.mu.Unlock()
330
331	log.Printf("[FSM] Shutting down focus state machine")
332
333	// Signal shutdown
334	close(fsm.shutdownChan)
335
336	// Wait for queue processor to finish
337	fsm.queueProcessor.Wait()
338
339	// Clean up timers
340	if fsm.settlingTimer != nil {
341		fsm.settlingTimer.Stop()
342	}
343
344	log.Printf("[FSM] Focus state machine shutdown complete")
345}
346
347// RequestFocus queues a focus change request with priority and deduplication
348func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
349	if node == nil {
350		return ErrInvalidTarget
351	}
352
353	request := FocusRequest{
354		ID:         fsm.generateRequestID(),
355		TargetNode: node,
356		Source:     source,
357		Priority:   fsm.calculatePriority(source),
358		Timestamp:  time.Now(),
359		Context:    make(map[string]interface{}),
360	}
361
362	// Track total requests
363	if fsm.metricsEnabled {
364		fsm.mu.Lock()
365		fsm.metrics.TotalRequests++
366		fsm.mu.Unlock()
367	}
368
369	// Check for duplicates
370	if fsm.deduplicator.IsDuplicate(request) {
371		if fsm.metricsEnabled {
372			fsm.mu.Lock()
373			fsm.metrics.DuplicateRequests++
374			fsm.mu.Unlock()
375		}
376		if fsm.debugEnabled {
377			log.Printf("[FSM] Duplicate request ignored: %s -> %p", request.Source, request.TargetNode)
378		}
379		return nil
380	}
381
382	// Check for coalescing opportunity
383	if fsm.shouldCoalesce(request) {
384		if fsm.metricsEnabled {
385			fsm.mu.Lock()
386			fsm.metrics.CoalescedRequests++
387			fsm.mu.Unlock()
388		}
389		if fsm.debugEnabled {
390			log.Printf("[FSM] Request coalesced: %s -> %p", request.Source, request.TargetNode)
391		}
392		return nil
393	}
394
395	// Add to priority queue instead of simple channel
396	fsm.mu.Lock()
397	heap.Push(fsm.priorityQueue, &request)
398	fsm.lastRequestTime = request.Timestamp
399
400	// Track queue depth metrics
401	currentDepth := fsm.priorityQueue.Len()
402	if currentDepth > fsm.metrics.MaxQueueDepth {
403		fsm.metrics.MaxQueueDepth = currentDepth
404	}
405	fsm.mu.Unlock()
406
407	// Signal that a request is pending
408	select {
409	case fsm.pendingRequests <- struct{}{}:
410	default: // Channel is already signaled
411	}
412
413	return nil
414}
415
416// GetActivePane returns the currently active pane
417func (fsm *FocusStateMachine) GetActivePane() *paneNode {
418	fsm.mu.RLock()
419	defer fsm.mu.RUnlock()
420	return fsm.activePane
421}
422
423// InvalidateActivePane clears the active pane if it matches the provided node.
424// This is used when panes are destroyed so the focus state machine doesn't keep
425// references to invalid widgets.
426func (fsm *FocusStateMachine) InvalidateActivePane(node *paneNode) {
427	if node == nil {
428		return
429	}
430
431	fsm.mu.Lock()
432	defer fsm.mu.Unlock()
433
434	if fsm.activePane != node {
435		return
436	}
437
438	log.Printf("[FSM] Active pane invalidated: %p", node)
439	fsm.activePane = nil
440
441	if fsm.wm != nil && fsm.wm.app != nil {
442		fsm.wm.app.activePane = nil
443	}
444
445	if fsm.currentState == StateFocused {
446		fsm.currentState = StateIdle
447	}
448}
449
450// GetCurrentState returns the current state of the focus system
451func (fsm *FocusStateMachine) GetCurrentState() FocusState {
452	fsm.mu.RLock()
453	defer fsm.mu.RUnlock()
454	return fsm.currentState
455}
456
457// AddValidator adds a focus request validator
458func (fsm *FocusStateMachine) AddValidator(validator FocusValidator) {
459	fsm.mu.Lock()
460	defer fsm.mu.Unlock()
461	fsm.validators = append(fsm.validators, validator)
462}
463
464// Private methods
465
466// generateRequestID creates a unique request identifier
467func (fsm *FocusStateMachine) generateRequestID() string {
468	fsm.requestIDCounter++
469	return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), fsm.requestIDCounter)
470}
471
472// calculatePriority determines request priority based on source
473func (fsm *FocusStateMachine) calculatePriority(source FocusSource) int {
474	switch source {
475	case SourceKeyboard:
476		return PriorityHigh
477	case SourceMouse:
478		return PriorityNormal
479	case SourceStackNav:
480		return PriorityHigh
481	case SourceSystem:
482		return PriorityUrgent
483	case SourceSplit, SourceClose:
484		return PriorityHigh
485	default:
486		return PriorityLow
487	}
488}
489
490// addDefaultValidators adds standard focus request validators
491func (fsm *FocusStateMachine) addDefaultValidators() {
492	// Validate target node exists and is a leaf
493	fsm.validators = append(fsm.validators, func(request FocusRequest) error {
494		if request.TargetNode == nil {
495			return ErrInvalidTarget
496		}
497		if !request.TargetNode.isLeaf {
498			return fmt.Errorf("target node is not a leaf pane")
499		}
500		if request.TargetNode.pane == nil || request.TargetNode.pane.webView == nil {
501			return fmt.Errorf("target node has no valid webview")
502		}
503		return nil
504	})
505
506	// Validate target node still exists in workspace
507	fsm.validators = append(fsm.validators, func(request FocusRequest) error {
508		if fsm.wm.viewToNode[request.TargetNode.pane.webView] != request.TargetNode {
509			return fmt.Errorf("target node no longer exists in workspace")
510		}
511		return nil
512	})
513}
514
515// validateRequest runs all validators on a focus request
516func (fsm *FocusStateMachine) validateRequest(request FocusRequest) error {
517	for _, validator := range fsm.validators {
518		if err := validator(request); err != nil {
519			return err
520		}
521	}
522	return nil
523}
524
525// determineInitialFocus selects the initial focus target based on priority rules
526func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
527	if len(leaves) == 0 {
528		return nil
529	}
530
531	// Priority 1: First pane with user content (not about:blank)
532	for _, leaf := range leaves {
533		if leaf.pane != nil && leaf.pane.webView != nil {
534			url := leaf.pane.webView.GetCurrentURL()
535			if url != "" && url != "about:blank" {
536				log.Printf("[FSM] Selected pane with content as initial focus: %s", url)
537				return leaf
538			}
539		}
540	}
541
542	// Priority 2: Top-left pane (geometrically)
543	if topLeft := fsm.findTopLeftPane(leaves); topLeft != nil {
544		log.Printf("[FSM] Selected top-left pane as initial focus")
545		return topLeft
546	}
547
548	// Priority 3: First pane in tree order
549	log.Printf("[FSM] Selected first pane as initial focus")
550	return leaves[0]
551}
552
553// findTopLeftPane finds the geometrically top-left pane
554func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
555	var bestPane *paneNode
556	var bestScore float64 = 1e9
557
558	for _, leaf := range leaves {
559		if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) {
560			continue
561		}
562
563		bounds, ok := webkit.WidgetGetBounds(leaf.container)
564		if !ok {
565			continue
566		}
567
568		// Score based on distance from top-left corner (0,0)
569		score := bounds.X + bounds.Y
570		if score < bestScore {
571			bestScore = score
572			bestPane = leaf
573		}
574	}
575
576	return bestPane
577}
578
579// applyInitialFocus applies focus to the initial target without going through the queue
580func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
581	if node == nil {
582		return ErrInvalidTarget
583	}
584
585	log.Printf("[FSM] Applying initial focus to pane %p", node)
586
587	// Apply GTK focus
588	if err := fsm.applyGTKFocus(node); err != nil {
589		return fmt.Errorf("failed to apply GTK focus: %w", err)
590	}
591
592	// Apply initial visual border
593	if fsm.wm != nil {
594		ctx := fsm.wm.determineBorderContext(node)
595		fsm.wm.applyActivePaneBorder(ctx)
596	}
597
598	// Update state
599	fsm.activePane = node
600
601	// Record transition
602	transition := FocusTransition{
603		From:      nil,
604		To:        node,
605		State:     StateInitializing,
606		Source:    SourceSystem,
607		Timestamp: time.Now(),
608		Success:   true,
609		Stack:     debug.Stack(),
610	}
611	fsm.historyRing.Add(transition)
612
613	// Notify workspace manager
614	if fsm.wm != nil && fsm.wm.app != nil && node.pane != nil {
615		fsm.wm.app.activePane = node.pane
616	}
617
618	return nil
619}
620
621// applyGTKFocus applies GTK focus to the specified pane
622func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
623	if node.pane == nil || node.pane.webView == nil {
624		return fmt.Errorf("pane has no valid webview")
625	}
626
627	viewWidget := node.pane.webView.Widget()
628	if viewWidget == 0 {
629		return fmt.Errorf("webview has no valid widget")
630	}
631
632	webkit.WidgetGrabFocus(viewWidget)
633	return nil
634}
635
636// processQueue processes focus requests from the priority queue
637func (fsm *FocusStateMachine) processQueue() {
638	defer fsm.queueProcessor.Done()
639
640	log.Printf("[FSM] Focus request queue processor started")
641
642	for {
643		select {
644		case <-fsm.pendingRequests:
645			// Process all pending requests from priority queue
646			fsm.processPendingRequests()
647		case request := <-fsm.requestQueue:
648			// Legacy channel for backward compatibility
649			fsm.handleFocusRequest(request)
650		case <-fsm.shutdownChan:
651			log.Printf("[FSM] Focus request queue processor shutting down")
652			return
653		}
654	}
655}
656
657// processPendingRequests processes all requests in the priority queue
658func (fsm *FocusStateMachine) processPendingRequests() {
659	for {
660		fsm.mu.Lock()
661		if fsm.priorityQueue.Len() == 0 {
662			fsm.mu.Unlock()
663			break
664		}
665
666		// Get highest priority request
667		request := heap.Pop(fsm.priorityQueue).(*FocusRequest)
668		fsm.mu.Unlock()
669
670		// Check if a higher priority request has arrived while processing
671		if fsm.hasHigherPriorityRequest(*request) {
672			// Requeue current request and process higher priority one
673			fsm.requeue(*request)
674			continue
675		}
676
677		fsm.handleFocusRequest(*request)
678	}
679}
680
681// handleFocusRequest processes a single focus request
682func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
683	startTime := time.Now()
684
685	// Validate request
686	if err := fsm.validateRequest(request); err != nil {
687		fsm.mu.Lock()
688		fsm.metrics.FailedRequests++
689		fsm.mu.Unlock()
690		log.Printf("[FSM] Invalid focus request %s: %v", request.ID, err)
691		return
692	}
693
694	// Check if we're already focused on the target
695	fsm.mu.RLock()
696	currentActive := fsm.activePane
697	fsm.mu.RUnlock()
698
699	if currentActive == request.TargetNode {
700		log.Printf("[FSM] Focus request %s: already focused on target", request.ID)
701		fsm.recordProcessTime(time.Since(startTime))
702		return
703	}
704
705	log.Printf("[FSM] Processing focus request %s: %s -> %p",
706		request.ID, request.Source, request.TargetNode)
707
708	// Execute focus change
709	if err := fsm.executeFocusChange(request); err != nil {
710		fsm.mu.Lock()
711		fsm.metrics.FailedRequests++
712		fsm.mu.Unlock()
713		log.Printf("[FSM] Focus request %s failed: %v", request.ID, err)
714	} else {
715		fsm.mu.Lock()
716		fsm.metrics.SuccessfulRequests++
717		fsm.mu.Unlock()
718	}
719
720	fsm.recordProcessTime(time.Since(startTime))
721}
722
723// executeFocusChange executes a focus change with proper state management
724func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
725	fsm.mu.Lock()
726	defer fsm.mu.Unlock()
727
728	// Transition to processing state
729	oldState := fsm.currentState
730	fsm.currentState = StateTransitioning
731
732	oldPane := fsm.activePane
733	newPane := request.TargetNode
734
735	// Record transition start
736	transition := FocusTransition{
737		From:      oldPane,
738		To:        newPane,
739		Source:    request.Source,
740		Timestamp: request.Timestamp,
741		Stack:     debug.Stack(),
742	}
743
744	// Apply GTK focus
745	if err := fsm.applyGTKFocus(newPane); err != nil {
746		// Rollback state
747		fsm.currentState = oldState
748		transition.Success = false
749		transition.Error = err
750		fsm.historyRing.Add(transition)
751		return fmt.Errorf("failed to apply GTK focus: %w", err)
752	}
753
754	// Apply visual border changes
755	if fsm.wm != nil {
756		// Remove border from old pane
757		if oldPane != nil {
758			fsm.wm.removeActivePaneBorder(oldPane)
759		}
760		// Add border to new pane
761		ctx := fsm.wm.determineBorderContext(newPane)
762		fsm.wm.applyActivePaneBorder(ctx)
763	}
764
765	// Update state
766	fsm.activePane = newPane
767	fsm.currentState = StateFocused
768
769	// Record successful transition
770	transition.Success = true
771	fsm.historyRing.Add(transition)
772
773	// Notify workspace manager
774	if fsm.wm != nil && fsm.wm.app != nil && newPane.pane != nil {
775		fsm.wm.app.activePane = newPane.pane
776	}
777
778	// Start settling timer
779	fsm.startSettlingTimer()
780
781	log.Printf("[FSM] Focus change completed: %p -> %p", oldPane, newPane)
782	return nil
783}
784
785// startSettlingTimer starts a timer to transition back to idle state
786func (fsm *FocusStateMachine) startSettlingTimer() {
787	if fsm.settlingTimer != nil {
788		fsm.settlingTimer.Stop()
789	}
790
791	fsm.settlingTimer = time.AfterFunc(50*time.Millisecond, func() {
792		fsm.mu.Lock()
793		if fsm.currentState == StateFocused {
794			fsm.currentState = StateIdle
795		}
796		fsm.mu.Unlock()
797	})
798}
799
800// startCSSVerificationLoop starts the CSS verification background loop
801func (fsm *FocusStateMachine) startCSSVerificationLoop() {
802	ticker := time.NewTicker(100 * time.Millisecond)
803
804	go func() {
805		defer ticker.Stop()
806
807		for {
808			select {
809			case <-ticker.C:
810				fsm.verifyCSSState()
811			case <-fsm.shutdownChan:
812				return
813			}
814		}
815	}()
816}
817
818// verifyCSSState checks and corrects CSS class inconsistencies
819func (fsm *FocusStateMachine) verifyCSSState() {
820	fsm.mu.RLock()
821	activePane := fsm.activePane
822	fsm.mu.RUnlock()
823
824	if activePane == nil || fsm.wm == nil {
825		return
826	}
827
828	var issues []string
829
830	if len(issues) > 0 {
831		// Prevent infinite reconciliation loops
832		fsm.mu.Lock()
833		now := time.Now()
834		if now.Sub(fsm.lastReconcileTime) < time.Second {
835			fsm.reconcileCount++
836		} else {
837			fsm.reconcileCount = 1
838			fsm.lastReconcileTime = now
839		}
840
841		if fsm.reconcileCount > fsm.maxReconcileAttempts {
842			log.Printf("[FSM] WARNING: Reconciliation loop detected, skipping (attempt %d)", fsm.reconcileCount)
843			fsm.mu.Unlock()
844			return
845		}
846		fsm.mu.Unlock()
847
848		log.Printf("[FSM] CSS inconsistencies found: %v", issues)
849		log.Printf("[FSM] Triggering CSS reconciliation (attempt %d)", fsm.reconcileCount)
850
851		// Track reconciliation in metrics
852		fsm.mu.Lock()
853		fsm.metrics.ReconciliationCount++
854		fsm.mu.Unlock()
855	}
856}
857
858// shouldCoalesce checks if a request should be coalesced with recent requests
859func (fsm *FocusStateMachine) shouldCoalesce(request FocusRequest) bool {
860	fsm.mu.RLock()
861	defer fsm.mu.RUnlock()
862
863	// Only coalesce if we have a recent request and it's to the same target
864	if time.Since(fsm.lastRequestTime) > fsm.coalescingWindow {
865		return false
866	}
867
868	// Check if there's already a pending request to the same target
869	for i := 0; i < fsm.priorityQueue.Len(); i++ {
870		pending := (*fsm.priorityQueue)[i]
871		if pending.TargetNode == request.TargetNode {
872			log.Printf("[FSM] Coalescing request to same target: %p", request.TargetNode)
873			return true
874		}
875	}
876
877	return false
878}
879
880// hasHigherPriorityRequest checks if there's a higher priority request in the queue
881func (fsm *FocusStateMachine) hasHigherPriorityRequest(current FocusRequest) bool {
882	fsm.mu.RLock()
883	defer fsm.mu.RUnlock()
884
885	if fsm.priorityQueue.Len() == 0 {
886		return false
887	}
888
889	// Check the highest priority request (at top of heap)
890	highest := (*fsm.priorityQueue)[0]
891	return highest.Priority > current.Priority
892}
893
894// requeue adds a request back to the priority queue
895func (fsm *FocusStateMachine) requeue(request FocusRequest) {
896	fsm.mu.Lock()
897	defer fsm.mu.Unlock()
898
899	heap.Push(fsm.priorityQueue, &request)
900	log.Printf("[FSM] Requeued request due to higher priority: %s", request.ID)
901
902	// Signal that a request is pending
903	select {
904	case fsm.pendingRequests <- struct{}{}:
905	default: // Channel is already signaled
906	}
907}
908
909// attachGTKController attaches a GTK focus controller to a pane node
910func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
911	if node == nil || node.pane == nil || node.pane.webView == nil {
912		return
913	}
914
915	if node.focusControllerToken != 0 {
916		log.Printf("[FSM] Controller already attached to pane %p", node)
917		return
918	}
919
920	widget := node.pane.webView.Widget()
921	if widget == 0 || !webkit.WidgetIsValid(widget) {
922		return
923	}
924
925	// Create focus enter/leave callbacks for this specific node
926	onEnter := func() {
927		log.Printf("[FSM] GTK focus enter: %p", node)
928		// Don't automatically change focus on GTK enter - let user interactions drive this
929		// This prevents infinite loops with our own focus changes
930	}
931
932	onLeave := func() {
933		log.Printf("[FSM] GTK focus leave: %p", node)
934		// Similarly, don't react to GTK leave events automatically
935	}
936
937	// Add the focus controller and store the token for cleanup
938	token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
939	if token != 0 {
940		node.focusControllerToken = token
941		log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
942	}
943}
944
945// detachGTKController removes a GTK focus controller from a pane node
946func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) {
947	if node == nil || node.pane == nil || node.pane.webView == nil || token == 0 {
948		return
949	}
950
951	if node.focusControllerToken != token {
952		log.Printf("[FSM] Token mismatch, skipping detach for pane %p", node)
953		return
954	}
955
956	node.focusControllerToken = 0
957
958	widget := node.pane.webView.Widget()
959	if widget == 0 || !webkit.WidgetIsValid(widget) {
960		return
961	}
962
963	webkit.WidgetRemoveFocusController(widget, token)
964	log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
965}
966
967// attachGTKControllersToAllPanes attaches focus controllers to all existing panes
968func (fsm *FocusStateMachine) attachGTKControllersToAllPanes() {
969	if fsm.wm == nil {
970		return
971	}
972
973	leaves := fsm.wm.collectLeaves()
974	for _, leaf := range leaves {
975		fsm.attachGTKController(leaf)
976	}
977
978	log.Printf("[FSM] Attached GTK focus controllers to %d panes", len(leaves))
979}
980
981// FocusDebugInfo contains comprehensive debug information about the focus system
982type FocusDebugInfo struct {
983	CurrentState      FocusState             `json:"current_state"`
984	ActivePane        *paneNode              `json:"active_pane,omitempty"`
985	QueueLength       int                    `json:"queue_length"`
986	PriorityQueueSize int                    `json:"priority_queue_size"`
987	History           []FocusTransition      `json:"history"`
988	CSSState          map[string]interface{} `json:"css_state"`
989	ValidationErrors  []string               `json:"validation_errors"`
990	Metrics           FocusMetrics           `json:"metrics"`
991	RecentRequests    []string               `json:"recent_requests"`
992}
993
994// FocusMetrics tracks performance metrics for the focus system
995type FocusMetrics struct {
996	TotalRequests       uint64        `json:"total_requests"`
997	SuccessfulRequests  uint64        `json:"successful_requests"`
998	FailedRequests      uint64        `json:"failed_requests"`
999	CoalescedRequests   uint64        `json:"coalesced_requests"`
1000	DuplicateRequests   uint64        `json:"duplicate_requests"`
1001	AverageProcessTime  time.Duration `json:"average_process_time"`
1002	MaxQueueDepth       int           `json:"max_queue_depth"`
1003	ReconciliationCount uint64        `json:"reconciliation_count"`
1004}
1005
1006// DumpState returns comprehensive debug information about the focus system
1007func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
1008	fsm.mu.RLock()
1009	defer fsm.mu.RUnlock()
1010
1011	info := FocusDebugInfo{
1012		CurrentState:      fsm.currentState,
1013		ActivePane:        fsm.activePane,
1014		QueueLength:       len(fsm.requestQueue),
1015		PriorityQueueSize: fsm.priorityQueue.Len(),
1016		History:           fsm.historyRing.GetAll(),
1017		ValidationErrors:  fsm.getValidationErrors(),
1018		RecentRequests:    fsm.getRecentRequestSignatures(),
1019		Metrics:           fsm.GetMetrics(),
1020	}
1021
1022	return info
1023}
1024
1025// ForceValidation manually triggers CSS consistency check and validation
1026func (fsm *FocusStateMachine) ForceValidation() []string {
1027	log.Printf("[FSM] Force validation requested")
1028
1029	// Trigger CSS verification
1030	fsm.verifyCSSState()
1031
1032	// Run all validators on current state
1033	var issues []string
1034
1035	fsm.mu.RLock()
1036	activePane := fsm.activePane
1037	fsm.mu.RUnlock()
1038
1039	if activePane != nil {
1040		dummyRequest := FocusRequest{
1041			ID:         "validation-check",
1042			TargetNode: activePane,
1043			Source:     SourceSystem,
1044			Priority:   PriorityLow,
1045			Timestamp:  time.Now(),
1046		}
1047
1048		if err := fsm.validateRequest(dummyRequest); err != nil {
1049			issues = append(issues, fmt.Sprintf("Active pane validation failed: %v", err))
1050		}
1051	}
1052
1053	log.Printf("[FSM] Force validation completed: %d issues found", len(issues))
1054	return issues
1055}
1056
1057// GetQueueStatus returns detailed information about pending requests
1058func (fsm *FocusStateMachine) GetQueueStatus() map[string]interface{} {
1059	fsm.mu.RLock()
1060	defer fsm.mu.RUnlock()
1061
1062	status := map[string]interface{}{
1063		"channel_queue_length":  len(fsm.requestQueue),
1064		"priority_queue_length": fsm.priorityQueue.Len(),
1065		"channel_queue_cap":     cap(fsm.requestQueue),
1066		"processing_state":      fsm.currentState,
1067		"last_request_time":     fsm.lastRequestTime,
1068		"coalescing_window":     fsm.coalescingWindow,
1069	}
1070
1071	// Add priority queue details
1072	if fsm.priorityQueue.Len() > 0 {
1073		priorities := make([]int, 0, fsm.priorityQueue.Len())
1074		targets := make([]string, 0, fsm.priorityQueue.Len())
1075
1076		for i := 0; i < fsm.priorityQueue.Len(); i++ {
1077			req := (*fsm.priorityQueue)[i]
1078			priorities = append(priorities, req.Priority)
1079			targets = append(targets, fmt.Sprintf("%p", req.TargetNode))
1080		}
1081
1082		status["pending_priorities"] = priorities
1083		status["pending_targets"] = targets
1084	}
1085
1086	return status
1087}
1088
1089// ClearHistory clears the focus transition history (useful for debugging sessions)
1090func (fsm *FocusStateMachine) ClearHistory() {
1091	fsm.mu.Lock()
1092	defer fsm.mu.Unlock()
1093
1094	fsm.historyRing = NewRingBuffer[FocusTransition](50)
1095	log.Printf("[FSM] Focus transition history cleared")
1096}
1097
1098// GetMetrics returns performance metrics for the focus system
1099func (fsm *FocusStateMachine) GetMetrics() FocusMetrics {
1100	fsm.mu.RLock()
1101	defer fsm.mu.RUnlock()
1102
1103	// Copy current metrics with calculated average
1104	metrics := fsm.metrics
1105	metrics.AverageProcessTime = fsm.calculateAverageProcessTime()
1106
1107	return metrics
1108}
1109
1110// recordProcessTime adds a process time to the metrics and maintains the sliding window
1111func (fsm *FocusStateMachine) recordProcessTime(duration time.Duration) {
1112	fsm.mu.Lock()
1113	defer fsm.mu.Unlock()
1114
1115	// Add to the list
1116	fsm.processTimes = append(fsm.processTimes, duration)
1117
1118	// Maintain sliding window
1119	if len(fsm.processTimes) > fsm.maxProcessTimes {
1120		fsm.processTimes = fsm.processTimes[1:]
1121	}
1122}
1123
1124// calculateAverageProcessTime calculates the average of recent process times
1125func (fsm *FocusStateMachine) calculateAverageProcessTime() time.Duration {
1126	if len(fsm.processTimes) == 0 {
1127		return 0
1128	}
1129
1130	var total time.Duration
1131	for _, t := range fsm.processTimes {
1132		total += t
1133	}
1134
1135	return total / time.Duration(len(fsm.processTimes))
1136}
1137
1138// getValidationErrors checks for various validation issues
1139func (fsm *FocusStateMachine) getValidationErrors() []string {
1140	var errors []string
1141
1142	// Check if we have an active pane but it's not valid
1143	if fsm.activePane != nil {
1144		if !fsm.activePane.isLeaf {
1145			errors = append(errors, "active pane is not a leaf node")
1146		}
1147		if fsm.activePane.pane == nil || fsm.activePane.pane.webView == nil {
1148			errors = append(errors, "active pane has no valid webview")
1149		}
1150		if fsm.wm != nil && fsm.wm.viewToNode[fsm.activePane.pane.webView] != fsm.activePane {
1151			errors = append(errors, "active pane not found in workspace view map")
1152		}
1153	}
1154
1155	// Check for orphaned requests in queue
1156	if fsm.wm != nil {
1157		validNodes := make(map[*paneNode]bool)
1158		for _, node := range fsm.wm.viewToNode {
1159			validNodes[node] = true
1160		}
1161
1162		for i := 0; i < fsm.priorityQueue.Len(); i++ {
1163			req := (*fsm.priorityQueue)[i]
1164			if !validNodes[req.TargetNode] {
1165				errors = append(errors, fmt.Sprintf("orphaned request for invalid node: %s", req.ID))
1166			}
1167		}
1168	}
1169
1170	return errors
1171}
1172
1173// getRecentRequestSignatures returns recent request signatures for debugging
1174func (fsm *FocusStateMachine) getRecentRequestSignatures() []string {
1175	if fsm.deduplicator == nil {
1176		return nil
1177	}
1178
1179	fsm.deduplicator.mu.RLock()
1180	defer fsm.deduplicator.mu.RUnlock()
1181
1182	sigs := make([]string, 0, len(fsm.deduplicator.recentSigs))
1183	for sig, timestamp := range fsm.deduplicator.recentSigs {
1184		sigs = append(sigs, fmt.Sprintf("%s (age: %v)", sig, time.Since(timestamp)))
1185	}
1186
1187	return sigs
1188}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close