v0.20.0 c741a39

tab.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5
6	"github.com/bnema/dumber/internal/application/usecase"
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/dumber/internal/infrastructure/config"
9	"github.com/bnema/dumber/internal/infrastructure/webkit"
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// TabCoordinator manages tab lifecycle operations.
16type TabCoordinator struct {
17	tabsUC     *usecase.ManageTabsUseCase
18	tabs       *entity.TabList
19	mainWindow *window.MainWindow
20	config     *config.Config
21
22	// Callbacks to avoid circular dependencies
23	onTabCreated       func(ctx context.Context, tab *entity.Tab)
24	onTabSwitched      func(ctx context.Context, tab *entity.Tab)
25	onQuit             func()
26	onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27}
28
29// TabCoordinatorConfig holds configuration for TabCoordinator.
30type TabCoordinatorConfig struct {
31	TabsUC     *usecase.ManageTabsUseCase
32	Tabs       *entity.TabList
33	MainWindow *window.MainWindow
34	Config     *config.Config
35}
36
37// NewTabCoordinator creates a new TabCoordinator.
38func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
39	log := logging.FromContext(ctx)
40	log.Debug().Msg("creating tab coordinator")
41
42	return &TabCoordinator{
43		tabsUC:     cfg.TabsUC,
44		tabs:       cfg.Tabs,
45		mainWindow: cfg.MainWindow,
46		config:     cfg.Config,
47	}
48}
49
50// SetOnTabCreated sets the callback for when a tab is created.
51func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {
52	c.onTabCreated = fn
53}
54
55// SetOnTabSwitched sets the callback for when a tab switch occurs.
56// This is used to swap workspace views in the content area.
57func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {
58	c.onTabSwitched = fn
59}
60
61// SetOnQuit sets the callback for when the last tab is closed.
62func (c *TabCoordinator) SetOnQuit(fn func()) {
63	c.onQuit = fn
64}
65
66// Create creates a new tab with the given initial URL.
67func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
68	log := logging.FromContext(ctx)
69
70	output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
71		TabList:    c.tabs,
72		Name:       "",
73		InitialURL: initialURL,
74	})
75	if err != nil {
76		log.Error().Err(err).Msg("failed to create tab")
77		return nil, err
78	}
79
80	// Set new tab as active (updates domain state and tracks previous)
81	c.tabs.SetActive(output.Tab.ID)
82
83	// Update tab bar
84	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
85		c.mainWindow.TabBar().AddTab(output.Tab)
86		c.mainWindow.TabBar().SetActive(output.Tab.ID)
87	}
88
89	// Update tab bar visibility
90	c.UpdateBarVisibility(ctx)
91
92	// Notify app to create workspace view
93	if c.onTabCreated != nil {
94		c.onTabCreated(ctx, output.Tab)
95	}
96
97	// Switch to the new tab's workspace view
98	if c.onTabSwitched != nil {
99		c.onTabSwitched(ctx, output.Tab)
100	}
101
102	log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
103	return output.Tab, nil
104}
105
106// Close closes the active tab.
107func (c *TabCoordinator) Close(ctx context.Context) error {
108	log := logging.FromContext(ctx)
109
110	activeID := c.tabs.ActiveTabID
111	if activeID == "" {
112		log.Debug().Msg("no active tab to close")
113		return nil
114	}
115
116	wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
117	if err != nil {
118		log.Error().Err(err).Msg("failed to close tab")
119		return err
120	}
121
122	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
123		c.mainWindow.TabBar().RemoveTab(activeID)
124
125		// Switch to new active tab if any
126		if c.tabs.ActiveTabID != "" {
127			c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
128		}
129	}
130
131	// Update tab bar visibility
132	c.UpdateBarVisibility(ctx)
133
134	// Quit if no tabs left
135	if wasLast && c.onQuit != nil {
136		c.onQuit()
137	}
138
139	// Switch workspace view to new active tab (if not last)
140	if !wasLast && c.onTabSwitched != nil {
141		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
142			c.onTabSwitched(ctx, tab)
143		}
144	}
145
146	log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
147	return nil
148}
149
150// Switch switches to a specific tab by ID.
151func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
152	log := logging.FromContext(ctx)
153
154	// Skip if already active
155	if tabID == c.tabs.ActiveTabID {
156		return nil
157	}
158
159	log.Debug().
160		Str("from", string(c.tabs.ActiveTabID)).
161		Str("to", string(tabID)).
162		Msg("switching tab")
163
164	// Update domain state
165	if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {
166		log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
167		return err
168	}
169
170	// Update tab bar UI
171	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
172		c.mainWindow.TabBar().SetActive(tabID)
173	}
174
175	// Invoke callback for workspace view switching
176	if c.onTabSwitched != nil {
177		if tab := c.tabs.Find(tabID); tab != nil {
178			c.onTabSwitched(ctx, tab)
179		}
180	}
181
182	return nil
183}
184
185// SwitchNext switches to the next tab.
186func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
187	log := logging.FromContext(ctx)
188
189	if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {
190		log.Error().Err(err).Msg("failed to switch to next tab")
191		return err
192	}
193
194	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
195		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
196	}
197
198	// Invoke callback for workspace view switching
199	if c.onTabSwitched != nil {
200		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
201			c.onTabSwitched(ctx, tab)
202		}
203	}
204
205	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
206	return nil
207}
208
209// SwitchPrev switches to the previous tab.
210func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
211	log := logging.FromContext(ctx)
212
213	if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
214		log.Error().Err(err).Msg("failed to switch to previous tab")
215		return err
216	}
217
218	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
219		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
220	}
221
222	// Invoke callback for workspace view switching
223	if c.onTabSwitched != nil {
224		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
225			c.onTabSwitched(ctx, tab)
226		}
227	}
228
229	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
230	return nil
231}
232
233// SwitchByIndex switches to a tab by 0-based index.
234func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
235	log := logging.FromContext(ctx)
236
237	if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {
238		log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
239		return err
240	}
241
242	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
243		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
244	}
245
246	// Invoke callback for workspace view switching
247	if c.onTabSwitched != nil {
248		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
249			c.onTabSwitched(ctx, tab)
250		}
251	}
252
253	log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
254	return nil
255}
256
257// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
258func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
259	log := logging.FromContext(ctx)
260
261	if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {
262		log.Error().Err(err).Msg("failed to switch to last active tab")
263		return err
264	}
265
266	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
267		c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
268	}
269
270	// Invoke callback for workspace view switching
271	if c.onTabSwitched != nil {
272		if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
273			c.onTabSwitched(ctx, tab)
274		}
275	}
276
277	log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
278	return nil
279}
280
281// UpdateBarVisibility shows or hides the tab bar based on tab count.
282func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
283	log := logging.FromContext(ctx)
284
285	// Check if feature is enabled
286	hideEnabled := true
287	if c.config != nil {
288		hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
289	}
290
291	if !hideEnabled {
292		log.Debug().Msg("tab bar auto-hide disabled by config")
293		return
294	}
295
296	if c.mainWindow == nil || c.mainWindow.TabBar() == nil {
297		log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
298		return
299	}
300
301	tabCount := c.tabs.Count()
302	shouldShow := tabCount > 1
303
304	log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
305	c.mainWindow.TabBar().SetVisible(shouldShow)
306}
307
308// GetTabBar returns the tab bar component.
309func (c *TabCoordinator) GetTabBar() *component.TabBar {
310	if c.mainWindow != nil {
311		return c.mainWindow.TabBar()
312	}
313	return nil
314}
315
316// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
317func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView)) {
318	c.onAttachPopupToTab = fn
319}
320
321// CreateWithPane creates a new tab with a pre-created pane and WebView.
322// This is used for tabbed popup behavior where the popup pane already exists.
323func (c *TabCoordinator) CreateWithPane(
324	ctx context.Context,
325	pane *entity.Pane,
326	wv *webkit.WebView,
327	initialURL string,
328) (*entity.Tab, error) {
329	log := logging.FromContext(ctx)
330
331	output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
332		TabList:    c.tabs,
333		Name:       pane.Title,
334		Pane:       pane,
335		InitialURL: initialURL,
336	})
337	if err != nil {
338		log.Error().Err(err).Msg("failed to create tab with pane")
339		return nil, err
340	}
341
342	// Set new tab as active
343	c.tabs.SetActive(output.Tab.ID)
344
345	// Update tab bar
346	if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
347		c.mainWindow.TabBar().AddTab(output.Tab)
348		c.mainWindow.TabBar().SetActive(output.Tab.ID)
349	}
350
351	// Update tab bar visibility
352	c.UpdateBarVisibility(ctx)
353
354	// Notify app to create workspace view
355	if c.onTabCreated != nil {
356		c.onTabCreated(ctx, output.Tab)
357	}
358
359	// Attach the popup WebView to the new tab's workspace
360	if c.onAttachPopupToTab != nil {
361		c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)
362	}
363
364	// Switch to the new tab's workspace view
365	if c.onTabSwitched != nil {
366		c.onTabSwitched(ctx, output.Tab)
367	}
368
369	log.Debug().
370		Str("tab_id", string(output.Tab.ID)).
371		Str("pane_id", string(pane.ID)).
372		Msg("tab created with popup pane")
373	return output.Tab, nil
374}

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