v0.11.0 dc50343

workspace_navigation.go RawHistoryFolder

1// workspace_navigation.go - Navigation and focus management for workspace panes
2package browser
3
4import (
5	"fmt"
6	"log"
7	"math"
8	"strings"
9	"time"
10
11	"github.com/bnema/dumber/pkg/webkit"
12)
13
14const (
15	// focusThrottleInterval prevents rapid focus changes that could cause infinite loops
16	focusThrottleInterval = 100 * time.Millisecond
17)
18
19// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
20func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
21	if node == nil || node.pane == nil || node.pane.webView == nil {
22		return
23	}
24
25	detail := map[string]any{
26		"active":    active,
27		"webview":   fmt.Sprintf("%p", node.pane.webView),
28		"webviewId": node.pane.webView.ID(),
29		"hasGUI":    node.pane.HasGUI(),
30		"timestamp": time.Now().UnixMilli(),
31	}
32
33	if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
34		log.Printf("[workspace] failed to dispatch focus event: %v", err)
35	} else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
36		log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
37	}
38}
39
40// focusAfterPaneMode restores focus when a pane mode operation completes without
41// stealing focus from the active pane inside a stack that just spawned a sibling.
42func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
43	wm.focusRespectingStack(node, "focus-after-pane-mode")
44}
45
46// focusRespectingStack handles focus while respecting stacked pane hierarchies
47func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
48	if node == nil {
49		return
50	}
51
52	if stack := node.parent; stack != nil && stack.isStacked {
53		activeIndex := stack.activeStackIndex
54		if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
55			activePane := stack.stackedPanes[activeIndex]
56			if activePane != nil && activePane != node {
57				if reason != "" {
58					log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
59				}
60				wm.SetActivePane(activePane, SourceKeyboard)
61				return
62			}
63		}
64	}
65
66	wm.SetActivePane(node, SourceKeyboard)
67}
68
69// focusByView changes focus to the pane containing the specified WebView
70func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
71	if wm == nil || view == nil {
72		return
73	}
74
75	// Throttle focus changes to prevent infinite loops
76	wm.focusThrottleMutex.Lock()
77	if time.Since(wm.lastFocusChange) < focusThrottleInterval {
78		wm.focusThrottleMutex.Unlock()
79		return
80	}
81	wm.lastFocusChange = time.Now()
82	wm.focusThrottleMutex.Unlock()
83
84	if node, ok := wm.viewToNode[view]; ok {
85		if wm.GetActiveNode() != node {
86			wm.focusRespectingStack(node, "focus-by-view")
87		}
88	}
89}
90
91// ensureHover sets up hover handlers for a pane node
92func (wm *WorkspaceManager) ensureHover(node *paneNode) {
93	if wm == nil || node == nil || !node.isLeaf {
94		return
95	}
96	if node.container == 0 || node.hoverToken != 0 {
97		return
98	}
99
100	token := webkit.WidgetAddHoverHandler(node.container, func() {
101		if wm == nil {
102			return
103		}
104		wm.SetActivePane(node, SourceMouse)
105	})
106	node.hoverToken = token
107	if token == 0 {
108		log.Printf("[workspace] failed to attach hover handler")
109	}
110}
111
112// detachHover removes hover handlers from a pane node
113func (wm *WorkspaceManager) detachHover(node *paneNode) {
114	if wm == nil || node == nil || node.hoverToken == 0 {
115		return
116	}
117
118	if node.container == 0 || !webkit.WidgetIsValid(node.container) {
119		node.hoverToken = 0
120		return
121	}
122
123	token := node.hoverToken
124	node.hoverToken = 0
125
126	if webkit.WidgetIsValid(node.container) {
127		webkit.WidgetRemoveHoverHandler(node.container, token)
128	}
129}
130
131// detachFocus removes the GTK focus controller attached to a pane, if any.
132func (wm *WorkspaceManager) detachFocus(node *paneNode) {
133	if wm == nil || node == nil || node.focusControllerToken == 0 {
134		return
135	}
136
137	if wm.focusStateMachine != nil {
138		wm.focusStateMachine.detachGTKController(node, node.focusControllerToken)
139	}
140
141	node.focusControllerToken = 0
142}
143
144// FocusNeighbor moves focus to the nearest pane in the requested direction using the
145// actual widget geometry to determine adjacency. For stacked panes, "up" and "down"
146// navigate within the stack.
147func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
148	if wm == nil {
149		return false
150	}
151	switch strings.ToLower(direction) {
152	case "up", "down":
153		// Check if current pane is part of a stack and handle stack navigation
154		if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
155			return true
156		}
157		// Fall back to regular adjacency navigation
158		return wm.focusAdjacent(strings.ToLower(direction))
159	case "left", "right":
160		return wm.focusAdjacent(strings.ToLower(direction))
161	default:
162		log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction)
163		return false
164	}
165}
166
167// navigateStack handles navigation within a stacked pane container.
168func (wm *WorkspaceManager) navigateStack(direction string) bool {
169	currentFocused := wm.GetActiveNode()
170	if currentFocused == nil {
171		return false
172	}
173
174	// Find the stack container this pane belongs to
175	var stackNode *paneNode
176	current := currentFocused
177
178	// Check if current pane is directly in a stack
179	if current.parent != nil && current.parent.isStacked {
180		stackNode = current.parent
181	} else {
182		// Current pane might be the stack container itself if it was the first pane converted to stack
183		if current.isStacked {
184			stackNode = current
185		}
186	}
187
188	if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
189		return false // Not in a stack or stack has only one pane
190	}
191
192	// Find current pane's index in the stack
193	currentIndex := -1
194	for i, pane := range stackNode.stackedPanes {
195		if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
196			currentIndex = i
197			break
198		}
199	}
200
201	if currentIndex == -1 {
202		log.Printf("[workspace] navigateStack: current pane not found in stack")
203		return false
204	}
205
206	// Calculate new index based on direction
207	var newIndex int
208	switch direction {
209	case "up":
210		newIndex = currentIndex - 1
211		if newIndex < 0 {
212			newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
213		}
214	case "down":
215		newIndex = currentIndex + 1
216		if newIndex >= len(stackNode.stackedPanes) {
217			newIndex = 0 // Wrap to first
218		}
219	default:
220		return false
221	}
222
223	if newIndex == currentIndex {
224		return false // No change
225	}
226
227	// Update active stack index and visibility
228	stackNode.activeStackIndex = newIndex
229	wm.stackedPaneManager.UpdateStackVisibility(stackNode)
230
231	// Focus the new active pane
232	newActivePane := stackNode.stackedPanes[newIndex]
233	wm.SetActivePane(newActivePane, SourceStackNav)
234
235	log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
236		direction, currentIndex, newIndex, len(stackNode.stackedPanes))
237	return true
238}
239
240// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
241func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
242	currentFocused := wm.GetActiveNode()
243	if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 {
244		return false
245	}
246
247	if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
248		wm.SetActivePane(neighbor, SourceKeyboard)
249		return true
250	}
251
252	currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container)
253	if !ok {
254		log.Printf("[workspace] unable to compute bounds for active pane")
255		return false
256	}
257
258	cx := currentBounds.X + currentBounds.Width/2.0
259	cy := currentBounds.Y + currentBounds.Height/2.0
260
261	leaves := wm.collectLeaves()
262	bestScore := math.MaxFloat64
263	var best *paneNode
264	var debugCandidates []string
265
266	for _, candidate := range leaves {
267		if candidate == nil || candidate == currentFocused || candidate.container == 0 {
268			continue
269		}
270
271		bounds, ok := webkit.WidgetGetBounds(candidate.container)
272		if !ok {
273			continue
274		}
275		tx := bounds.X + bounds.Width/2.0
276		ty := bounds.Y + bounds.Height/2.0
277
278		dx := tx - cx
279		dy := ty - cy
280
281		var score float64
282		switch direction {
283		case "left":
284			if dx >= -focusEpsilon {
285				continue
286			}
287			score = math.Abs(dx)*1000 + math.Abs(dy)
288		case "right":
289			if dx <= focusEpsilon {
290				continue
291			}
292			score = math.Abs(dx)*1000 + math.Abs(dy)
293		case "up":
294			if dy >= -focusEpsilon {
295				continue
296			}
297			score = math.Abs(dy)*1000 + math.Abs(dx)
298		case "down":
299			if dy <= focusEpsilon {
300				continue
301			}
302			score = math.Abs(dy)*1000 + math.Abs(dx)
303		}
304
305		if direction == "up" || direction == "down" {
306			debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
307		}
308
309		if score < bestScore {
310			bestScore = score
311			best = candidate
312		}
313	}
314
315	if best != nil {
316		wm.SetActivePane(best, SourceKeyboard)
317		return true
318	}
319
320	if len(debugCandidates) > 0 {
321		log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
322	}
323	return false
324}
325
326// structuralNeighbor finds neighbors based on the tree structure rather than geometry
327func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
328	if node == nil || node.container == 0 {
329		return nil
330	}
331
332	refBounds, ok := webkit.WidgetGetBounds(node.container)
333	if !ok {
334		return nil
335	}
336	cx := refBounds.X + refBounds.Width/2.0
337	cy := refBounds.Y + refBounds.Height/2.0
338	axisVertical := direction == "up" || direction == "down"
339
340	for parent := node.parent; parent != nil; parent = parent.parent {
341		switch direction {
342		case "up":
343			if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
344				if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
345					return leaf
346				}
347			}
348		case "down":
349			if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
350				if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
351					return leaf
352				}
353			}
354		case "left":
355			if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
356				if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
357					return leaf
358				}
359			}
360		case "right":
361			if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
362				if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
363					return leaf
364				}
365			}
366		}
367		node = parent
368	}
369	return nil
370}
371
372// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
373func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
374	leaves := wm.collectLeavesFrom(node)
375	bestScore := math.MaxFloat64
376	var best *paneNode
377	for _, leaf := range leaves {
378		if leaf == nil || leaf.container == 0 {
379			continue
380		}
381
382		bounds, ok := webkit.WidgetGetBounds(leaf.container)
383		if !ok {
384			continue
385		}
386		tx := bounds.X + bounds.Width/2.0
387		ty := bounds.Y + bounds.Height/2.0
388		dx := tx - cx
389		dy := ty - cy
390		var score float64
391		switch direction {
392		case "left":
393			if dx >= -focusEpsilon {
394				continue
395			}
396			score = math.Abs(dx)*1000 + math.Abs(dy)
397		case "right":
398			if dx <= focusEpsilon {
399				continue
400			}
401			score = math.Abs(dx)*1000 + math.Abs(dy)
402		case "up":
403			if dy >= -focusEpsilon {
404				continue
405			}
406			score = math.Abs(dy)*1000 + math.Abs(dx)
407		case "down":
408			if dy <= focusEpsilon {
409				continue
410			}
411			score = math.Abs(dy)*1000 + math.Abs(dx)
412		default:
413			continue
414		}
415		if score < bestScore {
416			bestScore = score
417			best = leaf
418		}
419	}
420	if best == nil {
421		return wm.boundaryFallback(node, direction)
422	}
423	return best
424}
425
426// boundaryFallback provides fallback navigation when geometric search fails
427func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
428	return wm.boundaryFallbackWithDepth(node, direction, 0)
429}
430
431// boundaryFallbackWithDepth provides recursive fallback with depth protection
432func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
433	// Prevent infinite recursion - max tree depth should be reasonable
434	const maxDepth = 50
435	if depth > maxDepth {
436		log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
437		return nil
438	}
439
440	if node == nil {
441		return nil
442	}
443	if node.isLeaf {
444		return node
445	}
446	switch direction {
447	case "up":
448		if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
449			return leaf
450		}
451		return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
452	case "down":
453		if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
454			return leaf
455		}
456		return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
457	case "left":
458		if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
459			return leaf
460		}
461		return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
462	case "right":
463		if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
464			return leaf
465		}
466		return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
467	default:
468		return nil
469	}
470}

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