v0.13.0 976176a

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

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