v0.29.0 21b3150

tab.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/port"
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/window"
13)
14
15// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17type TabTarget struct {
18	Tabs       *entity.TabList
19	MainWindow *window.MainWindow
20}
21
22// TabCoordinator manages tab lifecycle operations within a target window.
23// Public methods accept an explicit TabTarget so callers control which window's
24// tabs are mutated. The currentTarget field provides a fallback for callers that
25// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
26type TabCoordinator struct {
27	tabsUC                  *usecase.ManageTabsUseCase
28	currentTarget           TabTarget
29	mainWindow              *window.MainWindow // retained for GetTabBar and SetMainWindow
30	hideTabBarWhenSingleTab bool
31
32	// Callbacks to avoid circular dependencies
33	onTabCreated         func(ctx context.Context, target TabTarget, tab *entity.Tab)
34	onTabSwitched        func(ctx context.Context, target TabTarget, tab *entity.Tab)
35	onQuit               func()
36	onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
37	onAttachPopupToTab   func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
38	onStateChanged       func()                                                                            // For session snapshots
39}
40
41// TabCoordinatorConfig holds configuration for TabCoordinator.
42type TabCoordinatorConfig struct {
43	TabsUC                  *usecase.ManageTabsUseCase
44	Tabs                    *entity.TabList // Initial tab list for the default target
45	MainWindow              *window.MainWindow
46	HideTabBarWhenSingleTab bool
47}
48
49// NewTabCoordinator creates a new TabCoordinator.
50func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
51	log := logging.FromContext(ctx)
52	log.Debug().Msg("creating tab coordinator")
53
54	return &TabCoordinator{
55		tabsUC: cfg.TabsUC,
56		currentTarget: TabTarget{
57			Tabs:       cfg.Tabs,
58			MainWindow: cfg.MainWindow,
59		},
60		mainWindow:              cfg.MainWindow,
61		hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
62	}
63}
64
65// SetCurrentTarget updates the fallback target used by methods that don't receive
66// an explicit TabTarget. This should be called whenever the active browser window changes.
67func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
68	c.currentTarget = target
69	if target.MainWindow != nil {
70		c.mainWindow = target.MainWindow
71	}
72}
73
74// CurrentTarget returns the currently active fallback target.
75func (c *TabCoordinator) CurrentTarget() TabTarget {
76	return c.currentTarget
77}
78
79// SetOnTabCreated sets the callback for when a tab is created.
80func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
81	c.onTabCreated = fn
82}
83
84// SetOnTabSwitched sets the callback for when a tab switch occurs.
85// This is used to swap workspace views in the content area.
86func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
87	c.onTabSwitched = fn
88}
89
90// SetOnQuit sets the callback for when the last tab is closed.
91func (c *TabCoordinator) SetOnQuit(fn func()) {
92	c.onQuit = fn
93}
94
95// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
96func (c *TabCoordinator) SetOnStateChanged(fn func()) {
97	c.onStateChanged = fn
98}
99
100// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
101// window is closed. This allows the app to close the window gracefully.
102func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
103	c.onCurrentWindowEmpty = fn
104}
105
106// SetMainWindow updates only the main window reference for coordinator setup.
107//
108// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
109// currentTarget.Tabs and is safe only during app-level setup.
110func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
111	oldMainWindow := c.mainWindow
112	c.mainWindow = mainWindow
113	if c.currentTarget.MainWindow != mainWindow {
114		c.currentTarget = TabTarget{
115			Tabs:       c.currentTarget.Tabs,
116			MainWindow: mainWindow,
117		}
118	}
119	if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
120		logging.FromContext(context.Background()).Debug().
121			Int("tab_count", c.currentTarget.Tabs.Count()).
122			Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
123			Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
124			Msg("SetMainWindow: changing mainWindow while tabs exist")
125	}
126}
127
128// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
129func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
130	c.onAttachPopupToTab = fn
131}
132
133// notifyStateChanged triggers the state changed callback if set.
134func (c *TabCoordinator) notifyStateChanged() {
135	if c.onStateChanged != nil {
136		c.onStateChanged()
137	}
138}
139
140// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
141// or -1 if not found.
142func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
143	for i, tab := range tabs {
144		if tab != nil && tab.ID == id {
145			return i
146		}
147	}
148	return -1
149}
150
151// Create creates a new tab in the given target with the given initial URL.
152func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
153	return c.create(ctx, target, initialURL, true)
154}
155
156func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
157	log := logging.FromContext(ctx)
158
159	if target.Tabs == nil {
160		return nil, fmt.Errorf("target.Tabs is nil")
161	}
162
163	output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
164		TabList:    target.Tabs,
165		Name:       "",
166		InitialURL: initialURL,
167	})
168	if err != nil {
169		log.Error().Err(err).Msg("failed to create tab")
170		return nil, err
171	}
172
173	if activate {
174		// Set new tab as active (updates domain state and tracks previous)
175		target.Tabs.SetActive(output.Tab.ID)
176	}
177
178	// Notify app before adding the tab to the visible tab bar.
179	// The app assigns window ownership and the window-scoped default name here,
180	// and TabBar.AddTab reads tab.Title() immediately when creating the button.
181	if c.onTabCreated != nil {
182		c.onTabCreated(ctx, target, output.Tab)
183	}
184
185	// Update tab bar
186	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
187		target.MainWindow.TabBar().AddTab(output.Tab)
188		if activate {
189			target.MainWindow.TabBar().SetActive(output.Tab.ID)
190		}
191	}
192
193	// Update tab bar visibility
194	c.UpdateBarVisibility(ctx, target)
195
196	// Switch to the new tab's workspace view when activation is requested.
197	if activate && c.onTabSwitched != nil {
198		c.onTabSwitched(ctx, target, output.Tab)
199	}
200
201	// Notify state change for session snapshots
202	c.notifyStateChanged()
203
204	log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
205	return output.Tab, nil
206}
207
208// Close closes the active tab in the given target.
209// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
210// The caller (App) is responsible for deciding whether all windows are empty
211// and the process should quit. onQuit is NOT fired by Close.
212func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
213	log := logging.FromContext(ctx)
214
215	if target.Tabs == nil {
216		return fmt.Errorf("target.Tabs is nil")
217	}
218
219	activeID := target.Tabs.ActiveTabID
220	if activeID == "" {
221		log.Debug().Msg("no active tab to close")
222		return nil
223	}
224
225	tabs := target.Tabs.Tabs
226
227	// Compute the next tab to activate BEFORE closing
228	activeIdx := indexOfTab(tabs, activeID)
229
230	var nextID entity.TabID
231	if activeIdx >= 0 && len(tabs) > 1 {
232		if activeIdx < len(tabs)-1 {
233			nextID = tabs[activeIdx+1].ID
234		} else {
235			nextID = tabs[activeIdx-1].ID
236		}
237	}
238
239	wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
240	if err != nil {
241		log.Error().Err(err).Msg("failed to close tab")
242		return err
243	}
244
245	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
246		target.MainWindow.TabBar().RemoveTab(activeID)
247	}
248
249	// Update tab bar visibility
250	c.UpdateBarVisibility(ctx, target)
251
252	// Target TabList became empty — signal window-empty.
253	// App decides whether to quit (all windows empty) or only close this window.
254	if wasLast {
255		c.notifyStateChanged()
256		if c.onCurrentWindowEmpty != nil {
257			c.onCurrentWindowEmpty(ctx, target)
258		}
259		log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
260		return nil
261	}
262
263	// If there's a sibling tab in the same window, switch to it
264	if nextID != "" {
265		if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
266			log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
267			return err
268		}
269
270		if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
271			target.MainWindow.TabBar().SetActive(nextID)
272		}
273
274		if c.onTabSwitched != nil {
275			if tab := target.Tabs.Find(nextID); tab != nil {
276				c.onTabSwitched(ctx, target, tab)
277			}
278		}
279
280		// Notify state change for session snapshots
281		c.notifyStateChanged()
282
283		log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
284		return nil
285	}
286
287	return c.recoverCloseWithoutSibling(ctx, target, activeID)
288}
289
290func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
291	log := logging.FromContext(ctx)
292	// Defensive corrupted-state guard: only treat the target as empty if it is
293	// actually empty after close; otherwise recover by activating a surviving tab.
294	c.notifyStateChanged()
295	if len(target.Tabs.Tabs) == 0 {
296		if c.onCurrentWindowEmpty != nil {
297			c.onCurrentWindowEmpty(ctx, target)
298		}
299		log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
300		return nil
301	}
302	for _, tab := range target.Tabs.Tabs {
303		if tab != nil {
304			return c.Switch(ctx, target, tab.ID)
305		}
306	}
307	return fmt.Errorf("tab closed but no surviving tab could be activated")
308}
309
310// Switch switches to a specific tab by ID in the given target.
311func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
312	log := logging.FromContext(ctx)
313
314	if target.Tabs == nil {
315		return fmt.Errorf("target.Tabs is nil")
316	}
317
318	// Skip if already active
319	if tabID == target.Tabs.ActiveTabID {
320		return nil
321	}
322
323	// Verify the tab exists in this target
324	tab := target.Tabs.Find(tabID)
325	if tab == nil {
326		log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
327		return nil
328	}
329
330	log.Debug().
331		Str("from", string(target.Tabs.ActiveTabID)).
332		Str("to", string(tabID)).
333		Msg("switching tab")
334
335	// Update domain state
336	if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
337		log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
338		return err
339	}
340
341	// Update tab bar UI
342	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
343		target.MainWindow.TabBar().SetActive(tabID)
344	}
345
346	// Invoke callback for workspace view switching
347	if c.onTabSwitched != nil {
348		c.onTabSwitched(ctx, target, tab)
349	}
350
351	c.notifyStateChanged()
352
353	return nil
354}
355
356// switchRelative switches to the tab delta positions away within the target.
357// delta of +1 switches next, -1 switches previous, wrapping within the list.
358func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
359	if target.Tabs == nil {
360		return fmt.Errorf("target.Tabs is nil")
361	}
362	tabs := target.Tabs.Tabs
363	if len(tabs) <= 1 {
364		return nil
365	}
366
367	current := indexOfTab(tabs, target.Tabs.ActiveTabID)
368	if current < 0 {
369		current = 0
370	} else {
371		current = (current + delta + len(tabs)) % len(tabs)
372	}
373
374	return c.Switch(ctx, target, tabs[current].ID)
375}
376
377// SwitchNext switches to the next tab within the given target.
378func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
379	return c.switchRelative(ctx, target, 1)
380}
381
382// SwitchPrev switches to the previous tab within the given target.
383func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
384	return c.switchRelative(ctx, target, -1)
385}
386
387// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
388// creating new tabs as needed to reach the requested index.
389// If the requested index is beyond the current tab count and initialURL is empty,
390// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
391func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
392	log := logging.FromContext(ctx)
393
394	if target.Tabs == nil {
395		return fmt.Errorf("target.Tabs is nil")
396	}
397	if index < 0 {
398		log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
399		return nil
400	}
401	if target.Tabs.Count() <= index && initialURL == "" {
402		return fmt.Errorf("initial URL is required to create missing tabs")
403	}
404	// Intermediate tabs are created inactive (create(..., false)) and the final
405	// missing tab is created active (create(..., true)) before SwitchByIndex.
406	for target.Tabs.Count() < index {
407		if _, err := c.create(ctx, target, initialURL, false); err != nil {
408			return err
409		}
410	}
411	if target.Tabs.Count() <= index {
412		if _, err := c.create(ctx, target, initialURL, true); err != nil {
413			return err
414		}
415	}
416	return c.SwitchByIndex(ctx, target, index)
417}
418
419// SwitchByIndex switches to a tab by 0-based index within the given target.
420func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
421	log := logging.FromContext(ctx)
422
423	if target.Tabs == nil {
424		return fmt.Errorf("target.Tabs is nil")
425	}
426	tabs := target.Tabs.Tabs
427	if index < 0 || index >= len(tabs) {
428		log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
429		return nil
430	}
431
432	return c.Switch(ctx, target, tabs[index].ID)
433}
434
435// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
436// within the given target.
437func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
438	if target.Tabs == nil {
439		return nil
440	}
441
442	prevID := target.Tabs.PreviousActiveTabID
443	if prevID == "" || prevID == target.Tabs.ActiveTabID {
444		return nil
445	}
446
447	tab := target.Tabs.Find(prevID)
448	if tab == nil {
449		target.Tabs.PreviousActiveTabID = ""
450		return nil
451	}
452
453	return c.Switch(ctx, target, prevID)
454}
455
456// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
457func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
458	log := logging.FromContext(ctx)
459
460	if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
461		log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
462		return
463	}
464
465	// Check if feature is enabled
466	if !c.hideTabBarWhenSingleTab {
467		log.Debug().Msg("tab bar auto-hide disabled by config")
468		target.MainWindow.TabBar().SetAutoHidden(false)
469		target.MainWindow.SetTabBarContentInsetVisible(true)
470		return
471	}
472
473	tabCount := target.MainWindow.TabBar().Count()
474	shouldShow := tabCount > 1
475
476	log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
477	target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
478	target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
479}
480
481// GetTabBar returns the tab bar component from the current main window.
482func (c *TabCoordinator) GetTabBar() *component.TabBar {
483	if c.mainWindow != nil {
484		return c.mainWindow.TabBar()
485	}
486	return nil
487}
488
489// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
490// This is used for tabbed popup behavior where the popup pane already exists.
491func (c *TabCoordinator) CreateWithPane(
492	ctx context.Context,
493	target TabTarget,
494	pane *entity.Pane,
495	wv port.WebView,
496	initialURL string,
497) (*entity.Tab, error) {
498	log := logging.FromContext(ctx)
499
500	if target.Tabs == nil {
501		return nil, fmt.Errorf("target.Tabs is nil")
502	}
503
504	output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
505		TabList:    target.Tabs,
506		Name:       pane.Title,
507		Pane:       pane,
508		InitialURL: initialURL,
509	})
510	if err != nil {
511		log.Error().Err(err).Msg("failed to create tab with pane")
512		return nil, err
513	}
514
515	// Set new tab as active
516	target.Tabs.SetActive(output.Tab.ID)
517
518	// Notify app before adding tab to the visible tab bar.
519	if c.onTabCreated != nil {
520		c.onTabCreated(ctx, target, output.Tab)
521	}
522
523	// Update tab bar
524	if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
525		target.MainWindow.TabBar().AddTab(output.Tab)
526		target.MainWindow.TabBar().SetActive(output.Tab.ID)
527	}
528
529	// Update tab bar visibility
530	c.UpdateBarVisibility(ctx, target)
531
532	// Attach the popup WebView to the new tab's workspace
533	if c.onAttachPopupToTab != nil {
534		c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)
535	}
536
537	// Switch to the new tab's workspace view
538	if c.onTabSwitched != nil {
539		c.onTabSwitched(ctx, target, output.Tab)
540	}
541
542	// Notify state change for session snapshots
543	c.notifyStateChanged()
544
545	log.Debug().
546		Str("tab_id", string(output.Tab.ID)).
547		Str("pane_id", string(pane.ID)).
548		Msg("tab created with popup pane")
549	return output.Tab, nil
550}

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