v0.30.0 eb3068f

browser_window.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"fmt"
6	"sort"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/application/usecase"
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/shared/syncdispatch"
13	"github.com/bnema/dumber/internal/ui/component"
14	"github.com/bnema/dumber/internal/ui/coordinator"
15	"github.com/bnema/dumber/internal/ui/focus"
16	"github.com/bnema/dumber/internal/ui/input"
17	"github.com/bnema/dumber/internal/ui/layout"
18	"github.com/bnema/dumber/internal/ui/window"
19	"github.com/bnema/puregotk/v4/glib"
20)
21
22type browserWindow struct {
23	id                     string
24	initialURL             string
25	tabs                   *entity.TabList // per-window tab list (single source of truth for tab state)
26	mainWindow             *window.MainWindow
27	appToaster             *component.Toaster
28	modeToaster            *component.Toaster
29	touchpadNavIndicator   *component.TouchpadNavigationIndicator
30	borderMgr              *focus.BorderManager
31	sessionManager         *component.SessionManager
32	tabPicker              *component.TabPicker
33	tabPickerWidget        layout.Widget
34	tabPickerPaneID        entity.PaneID
35	insertAccentUC         *usecase.InsertAccentUseCase
36	accentPicker           *component.AccentPicker
37	keyboardHandler        *input.KeyboardHandler
38	globalShortcutHandler  *input.GlobalShortcutHandler
39	permissionDialog       port.PermissionDialogPresenter
40	webrtcIndicator        *component.WebRTCPermissionIndicator
41	historySidebar         *component.HistorySidebar
42	historySidebarReloader historySidebarReloader
43	sidebarVisible         bool
44}
45
46func (bw *browserWindow) detachInputForDestroy() {
47	if bw == nil {
48		return
49	}
50	if bw.keyboardHandler != nil {
51		bw.keyboardHandler.DetachForDestroy()
52	}
53	if bw.globalShortcutHandler != nil {
54		bw.globalShortcutHandler.DetachForDestroy()
55	}
56}
57
58func (bw *browserWindow) teardownForDestroy() {
59	if bw == nil {
60		return
61	}
62	bw.detachInputForDestroy()
63	bw.clearShellState()
64}
65
66func (bw *browserWindow) clearShellState() {
67	if bw == nil {
68		return
69	}
70	// Destroy the history sidebar before releasing the reference so its
71	// context, debounce timer, callbacks, and in-flight goroutines are
72	// cleaned up before the window itself is torn down.
73	if bw.historySidebar != nil {
74		bw.historySidebar.Destroy()
75	}
76	if bw.touchpadNavIndicator != nil {
77		bw.touchpadNavIndicator.Destroy()
78	}
79	bw.appToaster = nil
80	bw.modeToaster = nil
81	bw.touchpadNavIndicator = nil
82	bw.borderMgr = nil
83	bw.sessionManager = nil
84	bw.tabPicker = nil
85	bw.tabPickerWidget = nil
86	bw.tabPickerPaneID = ""
87	bw.insertAccentUC = nil
88	bw.accentPicker = nil
89	bw.keyboardHandler = nil
90	bw.globalShortcutHandler = nil
91	bw.permissionDialog = nil
92	bw.webrtcIndicator = nil
93	bw.historySidebar = nil
94	bw.historySidebarReloader = nil
95}
96
97func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
98	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
99		return
100	}
101
102	bw.initToasterOverlay(a)
103	bw.initTouchpadNavigationIndicator()
104	bw.initBorderOverlay(a)
105	bw.initAccentPicker(ctx, a)
106	bw.initSessionManager(ctx, a)
107	bw.initTabPicker(ctx, a)
108	bw.initHistorySidebar(ctx, a)
109}
110
111func (bw *browserWindow) initToasterOverlay(a *App) {
112	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
113		return
114	}
115
116	bw.appToaster = component.NewToaster(a.widgetFactory)
117	if bw.appToaster != nil {
118		if widget := bw.appToaster.Widget(); widget != nil {
119			if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
120				bw.mainWindow.AddOverlay(gtkWidget)
121			}
122		}
123	}
124
125	bw.modeToaster = component.NewToaster(a.widgetFactory)
126	if bw.modeToaster != nil {
127		if widget := bw.modeToaster.Widget(); widget != nil {
128			if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
129				bw.mainWindow.AddOverlay(gtkWidget)
130			}
131		}
132	}
133}
134
135func (bw *browserWindow) initTouchpadNavigationIndicator() {
136	if bw == nil || bw.mainWindow == nil {
137		return
138	}
139	bw.touchpadNavIndicator = component.NewTouchpadNavigationIndicator()
140	if bw.touchpadNavIndicator == nil {
141		return
142	}
143	if widget := bw.touchpadNavIndicator.Widget(); widget != nil {
144		bw.mainWindow.AddNonMeasuringOverlay(widget)
145	}
146}
147
148func (bw *browserWindow) initBorderOverlay(a *App) {
149	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
150		return
151	}
152
153	bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
154	if bw.borderMgr == nil {
155		return
156	}
157	if widget := bw.borderMgr.Widget(); widget != nil {
158		if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
159			bw.mainWindow.AddOverlay(gtkWidget)
160		}
161	}
162}
163
164func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
165	log := logging.FromContext(ctx)
166	if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
167		log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
168		return
169	}
170
171	bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
172	if bw.accentPicker == nil {
173		log.Warn().Msg("failed to create accent picker")
174		return
175	}
176	if widget := bw.accentPicker.Widget(); widget != nil {
177		if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
178			bw.mainWindow.AddOverlay(gtkWidget)
179		}
180	}
181
182	a.accentFocusProvider = a.deps.AccentFocusProvider
183	bw.insertAccentUC = usecase.NewInsertAccentUseCase(
184		a.accentFocusProvider,
185		bw.accentPicker,
186		func(fn func()) {
187			cb := glib.SourceFunc(func(_ uintptr) bool {
188				fn()
189				return false
190			})
191			glib.IdleAdd(&cb, 0)
192		},
193	)
194
195	log.Debug().Msg("accent picker initialized")
196}
197
198func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
199	log := logging.FromContext(ctx)
200	if bw == nil || a == nil || a.deps == nil || bw.mainWindow == nil {
201		log.Debug().Msg("deps or main window not available, skipping session manager")
202		return
203	}
204	runtimeCfg := a.runtimeConfigSnapshot().UI
205
206	var listSessionsUC *usecase.ListSessionsUseCase
207	var deleteSessionUC *usecase.DeleteSessionUseCase
208	if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
209		listSessionsUC = usecase.NewListSessionsUseCase(
210			a.deps.SessionRepo,
211			a.deps.SessionStateRepo,
212		)
213		deleteSessionUC = usecase.NewDeleteSessionUseCase(
214			a.deps.SessionStateRepo,
215			a.deps.SessionRepo,
216		)
217	}
218
219	bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
220		ListSessionsUC:  listSessionsUC,
221		DeleteSessionUC: deleteSessionUC,
222		CurrentSession:  a.deps.CurrentSessionID,
223		UIScale:         runtimeCfg.DefaultUIScale,
224		OnClose: func() {
225			log.Debug().Msg("session manager closed")
226		},
227		OnOpen: func(sessionID entity.SessionID) {
228			log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
229			if spawner := a.deps.SessionSpawner; spawner == nil {
230				log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
231				return
232			} else if err := spawner.SpawnWithSession(sessionID); err != nil {
233				log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
234			}
235		},
236		OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
237			if bw.appToaster != nil {
238				bw.appToaster.Show(ctx, message, level)
239			}
240		},
241	})
242
243	if bw.sessionManager == nil {
244		log.Warn().Msg("failed to create session manager")
245		return
246	}
247	if widget := bw.sessionManager.Widget(); widget != nil {
248		bw.mainWindow.AddOverlay(widget)
249	}
250	log.Debug().Msg("session manager initialized")
251}
252
253func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
254	log := logging.FromContext(ctx)
255	if bw == nil || a == nil || a.deps == nil {
256		log.Debug().Msg("deps/config not available, skipping tab picker")
257		return
258	}
259	runtimeCfg := a.runtimeConfigSnapshot().UI
260
261	bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
262		UIScale: runtimeCfg.DefaultUIScale,
263		OnClose: func() {
264			log.Debug().Msg("tab picker closed")
265		},
266		OnSelect: func(item component.TabPickerItem) {
267			cb := glib.SourceFunc(func(_ uintptr) bool {
268				var targetID entity.TabID
269				if !item.IsNew {
270					targetID = item.TabID
271				}
272				if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
273					log.Warn().Err(err).Msg("move pane to tab failed")
274				}
275				return false
276			})
277			glib.IdleAdd(&cb, 0)
278		},
279	})
280
281	if bw.tabPicker == nil {
282		log.Warn().Msg("failed to create tab picker")
283		return
284	}
285
286	log.Debug().Msg("tab picker initialized")
287}
288
289func (bw *browserWindow) ensureTabs() {
290	if bw != nil && bw.tabs == nil {
291		bw.tabs = entity.NewTabList()
292	}
293}
294
295func (a *App) hasBrowserWindow(target *browserWindow) bool {
296	if a == nil || target == nil || a.browserWindows == nil {
297		return false
298	}
299	bw, ok := a.browserWindows[target.id]
300	return ok && bw == target
301}
302
303func (a *App) registerBrowserWindow(bw *browserWindow) {
304	if bw == nil {
305		return
306	}
307	bw.ensureTabs()
308	if a.browserWindows == nil {
309		a.browserWindows = make(map[string]*browserWindow)
310	}
311	a.browserWindows[bw.id] = bw
312
313	// Track registration order, guarding against duplicates.
314	for _, id := range a.browserWindowOrder {
315		if id == bw.id {
316			return
317		}
318	}
319	a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
320}
321
322func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
323	if tab == nil {
324		return
325	}
326	if ctx == nil {
327		ctx = context.Background()
328	}
329
330	a.releaseFloatingSessionsForTab(ctx, tab.ID)
331	if a.contentCoord != nil && tab.Workspace != nil {
332		for _, pane := range tab.Workspace.AllPanes() {
333			if pane == nil {
334				continue
335			}
336			a.contentCoord.ReleaseWebView(ctx, pane.ID)
337		}
338	}
339	delete(a.workspaceViews, tab.ID)
340	delete(a.windowForTab, tab.ID)
341	if a.tabs != nil && a.tabs.Find(tab.ID) != nil {
342		a.tabs.Remove(tab.ID)
343	}
344}
345
346func (a *App) removeBrowserWindow(id string) {
347	if id == "" || a.browserWindows == nil {
348		return
349	}
350	removed := a.browserWindows[id]
351	wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
352
353	a.removeBrowserWindowOrder(id)
354	a.releaseBrowserWindowTabs(context.Background(), id, removed)
355	if removed != nil {
356		removed.teardownForDestroy()
357	}
358	if a.contentCoord != nil {
359		a.contentCoord.ClearPopupNamedContextsForWindow(id)
360	}
361	delete(a.browserWindows, id)
362
363	fallback := a.deterministicBrowserWindowFallback()
364	a.updateLastFocusedWindowAfterRemoval(id, fallback)
365	if wasMainWindow {
366		a.clearMainBrowserWindowAfterRemoval(fallback)
367	}
368}
369
370func (a *App) removeBrowserWindowOrder(id string) {
371	for i, wid := range a.browserWindowOrder {
372		if wid == id {
373			a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
374			return
375		}
376	}
377}
378
379func (a *App) releaseBrowserWindowTabs(ctx context.Context, windowID string, bw *browserWindow) {
380	ownedTabs := a.collectBrowserWindowTabs(windowID, bw)
381	for _, tabID := range sortedTabIDs(ownedTabs) {
382		a.releaseTabWorkspace(ctx, ownedTabs[tabID])
383	}
384	a.cleanupStaleTabMappingsForWindow(windowID)
385}
386
387func (a *App) collectBrowserWindowTabs(windowID string, bw *browserWindow) map[entity.TabID]*entity.Tab {
388	ownedTabs := make(map[entity.TabID]*entity.Tab)
389	if bw != nil && bw.tabs != nil {
390		for _, tab := range bw.tabs.Tabs {
391			if tab != nil {
392				ownedTabs[tab.ID] = tab
393			}
394		}
395	}
396	for tabID, owner := range a.windowForTab {
397		if owner == nil || owner.id != windowID {
398			continue
399		}
400		if tab := tabFromBrowserWindow(owner, tabID); tab != nil {
401			ownedTabs[tabID] = tab
402			continue
403		}
404		if a.tabs != nil {
405			ownedTabs[tabID] = a.tabs.Find(tabID)
406		}
407	}
408	return ownedTabs
409}
410
411func tabFromBrowserWindow(bw *browserWindow, tabID entity.TabID) *entity.Tab {
412	if bw == nil || bw.tabs == nil {
413		return nil
414	}
415	return bw.tabs.Find(tabID)
416}
417
418func sortedTabIDs(tabs map[entity.TabID]*entity.Tab) []entity.TabID {
419	ids := make([]entity.TabID, 0, len(tabs))
420	for tabID := range tabs {
421		ids = append(ids, tabID)
422	}
423	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
424	return ids
425}
426
427func (a *App) cleanupStaleTabMappingsForWindow(windowID string) {
428	for tabID, bw := range a.windowForTab {
429		if bw == nil || bw.id != windowID {
430			continue
431		}
432		delete(a.workspaceViews, tabID)
433		delete(a.windowForTab, tabID)
434		if a.tabs != nil && a.tabs.Find(tabID) != nil {
435			a.tabs.Remove(tabID)
436		}
437	}
438}
439
440func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *browserWindow) {
441	if a.lastFocusedWindowID != removedID {
442		return
443	}
444	if fallback != nil {
445		a.lastFocusedWindowID = fallback.id
446		return
447	}
448	a.lastFocusedWindowID = ""
449}
450
451func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
452	a.clearResizeModeBorder()
453	if fallback != nil {
454		a.activateBrowserWindow(fallback)
455		return
456	}
457	a.lastFocusedWindowID = ""
458	a.mainWindow = nil
459	a.keyboardHandler = nil
460	a.globalShortcutHandler = nil
461	if a.tabCoord != nil {
462		a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
463	}
464}
465
466func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) {
467	if bw == nil {
468		return
469	}
470	bw.teardownForDestroy()
471	if a.contentCoord != nil {
472		a.contentCoord.ClearPopupNamedContextsForWindow(bw.id)
473	}
474	fallback := a.deterministicBrowserWindowFallback()
475	if a.lastFocusedWindowID == bw.id {
476		if fallback != nil {
477			a.lastFocusedWindowID = fallback.id
478		} else {
479			a.lastFocusedWindowID = ""
480		}
481	}
482	if bw.mainWindow == a.mainWindow {
483		a.clearResizeModeBorder()
484		if fallback != nil {
485			a.activateBrowserWindow(fallback)
486			return
487		}
488		a.lastFocusedWindowID = ""
489		a.mainWindow = nil
490		a.keyboardHandler = nil
491		a.globalShortcutHandler = nil
492		if a.tabCoord != nil {
493			a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
494		}
495	}
496}
497
498func (a *App) deterministicBrowserWindowFallback() *browserWindow {
499	if a.browserWindows == nil {
500		return nil
501	}
502	for _, id := range a.browserWindowOrder {
503		bw := a.browserWindows[id]
504		if id == "" || bw == nil || bw.mainWindow == nil {
505			continue
506		}
507		return bw
508	}
509	ids := make([]string, 0, len(a.browserWindows))
510	for id, bw := range a.browserWindows {
511		if id == "" || bw == nil || bw.mainWindow == nil {
512			continue
513		}
514		ids = append(ids, id)
515	}
516	sort.Strings(ids)
517	if len(ids) == 0 {
518		return nil
519	}
520	return a.browserWindows[ids[0]]
521}
522
523func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
524	log := logging.FromContext(ctx)
525	log.Debug().
526		Str("url_host", logging.SafeURLHost(url)).
527		Msg("ui: open fresh window dispatch requested")
528
529	dispatch := a.dispatchOnMainThread
530	if dispatch == nil {
531		dispatch = func(label string, fn func()) syncdispatch.SyncDispatchResult {
532			if fn != nil {
533				fn()
534			}
535			return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
536		}
537	}
538
539	var openErr error
540	var windowCountBefore int
541	var windowCountAfter int
542	var hasTabCoord bool
543	var hasTabsUC bool
544	result := dispatch("ui.open_fresh_window", func() {
545		windowCountBefore = len(a.browserWindows)
546		hasTabCoord = a.tabCoord != nil
547		hasTabsUC = a.tabsUC != nil
548		log.Debug().
549			Str("url_host", logging.SafeURLHost(url)).
550			Int("window_count_before", windowCountBefore).
551			Bool("has_tab_coord", hasTabCoord).
552			Bool("has_tabs_uc", hasTabsUC).
553			Msg("ui: open fresh window main-thread work started")
554		openErr = a.openFreshWindow(ctx, url)
555		windowCountAfter = len(a.browserWindows)
556	})
557	if !result.Completed() {
558		log.Warn().
559			Str("url_host", logging.SafeURLHost(url)).
560			Dur("elapsed", result.Elapsed).
561			Str("dispatch_status", string(result.Status)).
562			Msg("ui: open fresh window skipped after main-thread dispatch did not complete")
563		return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
564	}
565	if openErr != nil {
566		log.Warn().Err(openErr).
567			Str("url_host", logging.SafeURLHost(url)).
568			Dur("elapsed", result.Elapsed).
569			Str("dispatch_status", string(result.Status)).
570			Int("window_count_after", windowCountAfter).
571			Msg("ui: open fresh window failed")
572		return openErr
573	}
574
575	log.Debug().
576		Str("url_host", logging.SafeURLHost(url)).
577		Dur("elapsed", result.Elapsed).
578		Str("dispatch_status", string(result.Status)).
579		Int("window_count_after", windowCountAfter).
580		Msg("ui: open fresh window completed")
581	return nil
582}

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