detached 15ee0d7

fix(ui): allow independent dumber process launches

Switch GTK application flags to non-unique mode so launching dumber again creates an independent process/window instead of re-activating the existing instance and adding a new tab.
commit
15ee0d7aad57875e3aa0d5080183df9a6de88f2d
author
brice · · 2026-02-09 06:41 UTC
parent
72ba574
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +555 -21
M internal/ui/app.go +540 -21
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011
1112 "github.com/bnema/dumber/internal/application/port"
@@ -44,8 +45,16 @@ const (
4445 AppID = "com.github.bnema.dumber"
4546 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
4647 crashReportToastDurationMs = 5000
48+ floatingPaneFallbackWidth = 1200
49+ floatingPaneFallbackHeight = 800
50+ floatingPaneIDPrefix = "floating-pane:"
51+ floatingSessionIDDefault = "default"
4752 )
4853
54+func gtkApplicationFlags() gio.ApplicationFlags {
55+ return gio.GApplicationNonUniqueValue
56+}
57+
4958 // App wraps the GTK Application and manages the browser lifecycle.
5059 type App struct {
5160 deps *Dependencies
@@ -81,9 +90,12 @@ type App struct {
8190 resizeModeBorderTarget layout.Widget
8291
8392 // Omnibox configuration (omnibox is created per workspace view)
84- omniboxCfg component.OmniboxConfig
93+ omniboxCfg component.OmniboxConfig
94+ omniboxNavigateFn func(url string)
8595 // Find bar configuration (find bar is created per workspace view)
8696 findBarCfg component.FindBarConfig
97+ // Floating pane sessions keyed by tab and profile session ID.
98+ floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
8799
88100 // Web content (managed by ContentCoordinator)
89101 pool *webkit.WebViewPool
@@ -130,6 +142,25 @@ type App struct {
130142 cancel context.CancelCauseFunc
131143 }
132144
145+type floatingWorkspaceSession struct {
146+ paneID entity.PaneID
147+ pane *component.FloatingPane
148+ webView *webkit.WebView
149+ overlay layout.OverlayWidget
150+ widget layout.Widget
151+ omnibox *component.Omnibox
152+ omniboxWidget layout.Widget
153+ resizeWatcherActive bool
154+ resizeTickID uint
155+ appliedWidth int
156+ appliedHeight int
157+}
158+
159+type floatingSessionKey struct {
160+ tabID entity.TabID
161+ sessionID string
162+}
163+
133164 // New creates a new App with the given dependencies.
134165 func New(deps *Dependencies) (*App, error) {
135166 if err := deps.Validate(); err != nil {
@@ -138,17 +169,18 @@ func New(deps *Dependencies) (*App, error) {
138169 ctx, cancel := context.WithCancelCause(deps.Ctx)
139170
140171 app := &App{
141- deps: deps,
142- tabs: entity.NewTabList(),
143- tabsUC: deps.TabsUC,
144- panesUC: deps.PanesUC,
145- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
146- pool: deps.Pool,
147- injector: deps.Injector,
148- router: deps.MessageRouter,
149- settings: deps.Settings,
150- configManager: config.GetManager(),
151- cancel: cancel,
172+ deps: deps,
173+ tabs: entity.NewTabList(),
174+ tabsUC: deps.TabsUC,
175+ panesUC: deps.PanesUC,
176+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
177+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
178+ pool: deps.Pool,
179+ injector: deps.Injector,
180+ router: deps.MessageRouter,
181+ settings: deps.Settings,
182+ configManager: config.GetManager(),
183+ cancel: cancel,
152184 }
153185 if app.router == nil {
154186 app.router = webkit.NewMessageRouter(ctx)
@@ -208,7 +240,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
208240 }
209241
210242 appID := AppID
211- a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)
243+ a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
212244 if a.gtkApp == nil {
213245 log.Error().Msg("failed to create GTK application")
214246 return 1
@@ -582,6 +614,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
582614 // Create keyboard handler and wire to dispatcher.
583615 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
584616 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
617+ if action == input.ActionClosePane {
618+ if a.closeActiveFloatingPane(ctx) {
619+ return nil
620+ }
621+ }
585622 return a.kbDispatcher.Dispatch(ctx, action)
586623 })
587624 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
@@ -656,7 +693,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
656693 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
657694 UIScale: a.deps.Config.DefaultUIScale,
658695 OnNavigate: func(url string) {
659- if err := a.navCoord.Navigate(ctx, url); err != nil {
696+ if err := a.navigateFromOmnibox(ctx, url); err != nil {
660697 log.Error().Err(err).Str("url", url).Msg("navigation failed")
661698 }
662699 },
@@ -967,6 +1004,7 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
9671004 }
9681005
9691006 func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1007+ a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
9701008 delete(a.workspaceViews, sourceTabID)
9711009 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
9721010 a.mainWindow.TabBar().RemoveTab(sourceTabID)
@@ -1309,6 +1347,17 @@ func (a *App) onShutdown(ctx context.Context) {
13091347 if a.faviconAdapter != nil {
13101348 a.faviconAdapter.Close()
13111349 }
1350+ tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1351+ for key := range a.floatingSessions {
1352+ tabIDSet[key.tabID] = struct{}{}
1353+ }
1354+ tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1355+ for tabID := range tabIDSet {
1356+ tabIDs = append(tabIDs, tabID)
1357+ }
1358+ for _, tabID := range tabIDs {
1359+ a.releaseFloatingSessionsForTab(ctx, tabID)
1360+ }
13121361 if a.deps.Pool != nil {
13131362 a.deps.Pool.Close(ctx)
13141363 }
@@ -1484,6 +1533,7 @@ func (a *App) initCoordinators(ctx context.Context) {
14841533 // Wire pane URI updates for session snapshots (searches all tabs)
14851534 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
14861535 a.updatePaneURIInAllTabs(paneID, url)
1536+ a.updateFloatingSessionURI(paneID, url)
14871537 // Mark dirty so snapshot captures the new URI
14881538 a.MarkDirty()
14891539 })
@@ -1522,6 +1572,12 @@ func (a *App) initCoordinators(ctx context.Context) {
15221572 a.deps.ZoomUC,
15231573 a.deps.CopyURLUC,
15241574 )
1575+ a.wireKeyboardActions()
1576+
1577+ log.Debug().Msg("coordinators initialized")
1578+}
1579+
1580+func (a *App) wireKeyboardActions() {
15251581 a.kbDispatcher.SetOnQuit(a.Quit)
15261582 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
15271583 a.ToggleFindBar(ctx)
@@ -1545,13 +1601,17 @@ func (a *App) initCoordinators(ctx context.Context) {
15451601 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
15461602 return a.HandleMovePaneToNextTab(ctx)
15471603 })
1604+ a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1605+ return a.ToggleFloatingPane(ctx)
1606+ })
1607+ a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1608+ return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1609+ })
15481610
15491611 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
15501612 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
15511613 return a.kbDispatcher.Dispatch(ctx, action)
15521614 })
1553-
1554- log.Debug().Msg("coordinators initialized")
15551615 }
15561616
15571617 func (a *App) wireWebRTCPermissionIndicator() {
@@ -1889,6 +1949,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
18891949
18901950 // Store in map
18911951 a.workspaceViews[tab.ID] = wsView
1952+ for key, session := range a.floatingSessions {
1953+ if key.tabID != tab.ID || session == nil || session.pane == nil {
1954+ continue
1955+ }
1956+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1957+ if session.widget != nil {
1958+ wsView.AddWorkspaceOverlayWidget(session.widget)
1959+ session.widget.SetVisible(session.pane.IsVisible())
1960+ }
1961+ }
1962+ a.syncFloatingFocus()
18921963
18931964 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
18941965 }
@@ -1929,12 +2000,11 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
19292000 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
19302001 // Used by the accent picker to insert accented characters into web content.
19312002 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1932- ws := a.activeWorkspace()
1933- if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
2003+ if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
19342004 return nil
19352005 }
19362006
1937- wv := a.contentCoord.GetWebView(ws.ActivePaneID)
2007+ wv := a.contentCoord.ActiveWebView(context.Background())
19382008 if wv == nil {
19392009 return nil
19402010 }
@@ -2006,14 +2076,464 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
20062076
20072077 // Update window title with the new active pane's title
20082078 a.updateWindowTitleFromActivePane()
2079+ a.syncFloatingFocus()
20092080
20102081 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
20112082 }
20122083
2084+func normalizeFloatingSessionID(sessionID string) string {
2085+ sessionID = strings.TrimSpace(sessionID)
2086+ if sessionID == "" {
2087+ return floatingSessionIDDefault
2088+ }
2089+ return sessionID
2090+}
2091+
2092+func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2093+ return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2094+}
2095+
2096+func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2097+ return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2098+}
2099+
2100+func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2101+ if overlay == nil {
2102+ return
2103+ }
2104+
2105+ overlay.AddCssClass("floating-pane-container")
2106+ overlay.AddCssClass("pane-border")
2107+ overlay.AddCssClass("pane-active")
2108+}
2109+
2110+func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2111+ if a.deps != nil && a.deps.Config != nil {
2112+ return a.deps.Config.Workspace.FloatingPane
2113+ }
2114+ return config.Get().Workspace.FloatingPane
2115+}
2116+
2117+func (a *App) ensureFloatingSession(
2118+ ctx context.Context,
2119+ tabID entity.TabID,
2120+ sessionID string,
2121+ wsView *component.WorkspaceView,
2122+) (*floatingWorkspaceSession, error) {
2123+ key := floatingSessionMapKey(tabID, sessionID)
2124+ if session, ok := a.floatingSessions[key]; ok && session != nil {
2125+ if wsView == nil {
2126+ return session, nil
2127+ }
2128+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2129+ if session.widget != nil {
2130+ wsView.AddWorkspaceOverlayWidget(session.widget)
2131+ }
2132+ return session, nil
2133+ }
2134+
2135+ if wsView == nil {
2136+ return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2137+ }
2138+ if a.contentCoord == nil {
2139+ return nil, fmt.Errorf("content coordinator not initialized")
2140+ }
2141+ if a.widgetFactory == nil {
2142+ return nil, fmt.Errorf("widget factory not initialized")
2143+ }
2144+
2145+ paneID := floatingPaneIDForSession(tabID, sessionID)
2146+ wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2147+ if err != nil {
2148+ return nil, fmt.Errorf("ensure floating webview: %w", err)
2149+ }
2150+
2151+ webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2152+ if webViewWidget == nil {
2153+ return nil, fmt.Errorf("wrap floating webview widget")
2154+ }
2155+ webViewWidget.SetHexpand(true)
2156+ webViewWidget.SetVexpand(true)
2157+
2158+ overlay := a.widgetFactory.NewOverlay()
2159+ overlay.SetChild(webViewWidget)
2160+ decorateFloatingOverlay(overlay)
2161+ overlay.SetHalign(gtk.AlignCenterValue)
2162+ overlay.SetValign(gtk.AlignCenterValue)
2163+ overlay.SetVisible(false)
2164+ wsView.AddWorkspaceOverlayWidget(overlay)
2165+
2166+ floatingCfg := a.currentFloatingConfig()
2167+ floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2168+ WidthPct: floatingCfg.WidthPct,
2169+ HeightPct: floatingCfg.HeightPct,
2170+ FallbackWidth: floatingPaneFallbackWidth,
2171+ FallbackHeight: floatingPaneFallbackHeight,
2172+ OnNavigate: func(navCtx context.Context, url string) error {
2173+ return wv.LoadURI(navCtx, url)
2174+ },
2175+ })
2176+
2177+ session := &floatingWorkspaceSession{
2178+ paneID: paneID,
2179+ pane: floatingPane,
2180+ webView: wv,
2181+ overlay: overlay,
2182+ widget: overlay,
2183+ }
2184+ a.floatingSessions[key] = session
2185+ return session, nil
2186+}
2187+
2188+func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2189+ sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2190+ for key, session := range a.floatingSessions {
2191+ if key.tabID != tabID || session == nil {
2192+ continue
2193+ }
2194+ sessions[key] = session
2195+ }
2196+ return sessions
2197+}
2198+
2199+func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2200+ sessions := a.floatingSessionsForTab(tabID)
2201+ if len(sessions) == 0 {
2202+ return
2203+ }
2204+
2205+ for key, session := range sessions {
2206+ a.stopFloatingResizeWatcher(session)
2207+ if session.pane != nil {
2208+ session.pane.Hide(ctx)
2209+ }
2210+ a.hideFloatingOmnibox(ctx, session)
2211+ if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil {
2212+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
2213+ }
2214+ if a.contentCoord != nil && session.paneID != "" {
2215+ a.contentCoord.ReleaseWebView(ctx, session.paneID)
2216+ }
2217+ delete(a.floatingSessions, key)
2218+ }
2219+ a.syncFloatingFocus()
2220+}
2221+
2222+func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2223+ activeTab := a.tabs.ActiveTab()
2224+ if activeTab == nil {
2225+ return nil, ""
2226+ }
2227+ for key, session := range a.floatingSessions {
2228+ if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2229+ continue
2230+ }
2231+ if session.pane.IsVisible() {
2232+ return session, activeTab.ID
2233+ }
2234+ }
2235+ return nil, activeTab.ID
2236+}
2237+
2238+func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2239+ if paneID == "" {
2240+ return
2241+ }
2242+
2243+ for _, session := range a.floatingSessions {
2244+ if session == nil || session.pane == nil {
2245+ continue
2246+ }
2247+ if session.paneID != paneID {
2248+ continue
2249+ }
2250+ session.pane.RecordLoadedURL(url)
2251+ return
2252+ }
2253+}
2254+
2255+func (a *App) syncFloatingFocus() {
2256+ for _, wsView := range a.workspaceViews {
2257+ if wsView != nil {
2258+ wsView.SetHoverFocusLocked(false)
2259+ }
2260+ }
2261+
2262+ if a.contentCoord == nil {
2263+ return
2264+ }
2265+
2266+ activeTab := a.tabs.ActiveTab()
2267+ if activeTab == nil {
2268+ a.contentCoord.ClearActivePaneOverride()
2269+ return
2270+ }
2271+
2272+ if session, _ := a.activeFloatingSession(); session != nil {
2273+ if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2274+ wsView.SetHoverFocusLocked(true)
2275+ }
2276+ a.startFloatingResizeWatcher(session)
2277+ a.contentCoord.SetActivePaneOverride(session.paneID)
2278+ return
2279+ }
2280+
2281+ a.contentCoord.ClearActivePaneOverride()
2282+}
2283+
2284+func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2285+ if session == nil || session.overlay == nil || a.widgetFactory == nil {
2286+ return
2287+ }
2288+
2289+ if session.omnibox == nil {
2290+ cfg := a.omniboxCfg
2291+ cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2292+ if a.appToaster != nil {
2293+ a.appToaster.Show(toastCtx, message, level)
2294+ }
2295+ }
2296+
2297+ omnibox := component.NewOmnibox(ctx, cfg)
2298+ if omnibox == nil {
2299+ return
2300+ }
2301+
2302+ omnibox.SetParentOverlay(session.overlay)
2303+ omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2304+ if omniboxWidget == nil {
2305+ return
2306+ }
2307+
2308+ session.overlay.AddOverlay(omniboxWidget)
2309+ session.overlay.SetClipOverlay(omniboxWidget, false)
2310+ session.overlay.SetMeasureOverlay(omniboxWidget, false)
2311+
2312+ session.omnibox = omnibox
2313+ session.omniboxWidget = omniboxWidget
2314+ }
2315+
2316+ session.pane.SetOmniboxVisible(true)
2317+ session.omnibox.Show(ctx, "")
2318+}
2319+
2320+func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2321+ if session == nil || session.pane == nil {
2322+ return
2323+ }
2324+
2325+ session.pane.SetOmniboxVisible(false)
2326+ if session.omnibox == nil {
2327+ return
2328+ }
2329+
2330+ session.omnibox.Hide(ctx)
2331+ if session.overlay != nil && session.omniboxWidget != nil {
2332+ if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2333+ session.overlay.RemoveOverlay(session.omniboxWidget)
2334+ } else if parent != nil {
2335+ session.omniboxWidget.Unparent()
2336+ }
2337+ }
2338+ session.omnibox = nil
2339+ session.omniboxWidget = nil
2340+}
2341+
2342+func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2343+ if session == nil || session.pane == nil {
2344+ return
2345+ }
2346+ session.pane.Resize()
2347+ if session.widget == nil {
2348+ return
2349+ }
2350+ width, height := session.pane.Dimensions()
2351+ if session.appliedWidth == width && session.appliedHeight == height {
2352+ return
2353+ }
2354+ session.widget.SetSizeRequest(width, height)
2355+ session.appliedWidth = width
2356+ session.appliedHeight = height
2357+}
2358+
2359+func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2360+ if session == nil || session.pane == nil {
2361+ return false
2362+ }
2363+ if !session.pane.IsVisible() {
2364+ session.resizeWatcherActive = false
2365+ session.resizeTickID = 0
2366+ return false
2367+ }
2368+
2369+ a.resizeFloatingWidget(session)
2370+ return true
2371+}
2372+
2373+func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2374+ if session == nil || session.overlay == nil || session.resizeWatcherActive {
2375+ return
2376+ }
2377+
2378+ overlayWidget := session.overlay.GtkWidget()
2379+ if overlayWidget == nil {
2380+ return
2381+ }
2382+
2383+ session.resizeWatcherActive = true
2384+ tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2385+ keepRunning := a.handleFloatingViewportTick(session)
2386+ if !keepRunning {
2387+ session.resizeWatcherActive = false
2388+ session.resizeTickID = 0
2389+ }
2390+ return keepRunning
2391+ })
2392+ session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2393+}
2394+
2395+func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2396+ if session == nil {
2397+ return
2398+ }
2399+
2400+ if session.resizeTickID != 0 && session.overlay != nil {
2401+ if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2402+ overlayWidget.RemoveTickCallback(session.resizeTickID)
2403+ }
2404+ }
2405+ session.resizeTickID = 0
2406+ session.resizeWatcherActive = false
2407+}
2408+
2409+func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2410+ if session == nil || session.pane == nil {
2411+ return
2412+ }
2413+
2414+ a.stopFloatingResizeWatcher(session)
2415+ session.pane.Hide(ctx)
2416+ a.hideFloatingOmnibox(ctx, session)
2417+ if session.widget != nil {
2418+ session.widget.SetVisible(false)
2419+ }
2420+}
2421+
2422+func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2423+ for key, session := range a.floatingSessions {
2424+ if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2425+ continue
2426+ }
2427+ if !session.pane.IsVisible() {
2428+ continue
2429+ }
2430+ a.hideFloatingSession(ctx, session)
2431+ }
2432+}
2433+
2434+func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2435+ session, _ := a.activeFloatingSession()
2436+ if session == nil {
2437+ return false
2438+ }
2439+ a.hideFloatingSession(ctx, session)
2440+ a.syncFloatingFocus()
2441+ return true
2442+}
2443+
2444+// ToggleFloatingPane toggles the active workspace floating pane visibility.
2445+func (a *App) ToggleFloatingPane(ctx context.Context) error {
2446+ activeTab := a.tabs.ActiveTab()
2447+ if activeTab == nil {
2448+ return nil
2449+ }
2450+ if a.closeActiveFloatingPane(ctx) {
2451+ return nil
2452+ }
2453+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2454+}
2455+
2456+// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2457+func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2458+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2459+}
2460+
2461+// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2462+func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2463+ return a.openFloatingPaneSession(ctx, sessionID, url)
2464+}
2465+
2466+func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2467+ activeTab := a.tabs.ActiveTab()
2468+ if activeTab == nil {
2469+ return nil
2470+ }
2471+
2472+ wsView := a.workspaceViews[activeTab.ID]
2473+ session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2474+ if err != nil {
2475+ return err
2476+ }
2477+ a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2478+
2479+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2480+ if hasURL {
2481+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2482+ return err
2483+ }
2484+ a.hideFloatingOmnibox(ctx, session)
2485+ } else {
2486+ if err := session.pane.ShowToggle(ctx); err != nil {
2487+ return err
2488+ }
2489+ if session.pane.IsOmniboxVisible() {
2490+ a.showFloatingOmnibox(ctx, session)
2491+ }
2492+ }
2493+
2494+ a.resizeFloatingWidget(session)
2495+ if session.widget != nil {
2496+ session.widget.SetVisible(session.pane.IsVisible())
2497+ }
2498+ a.startFloatingResizeWatcher(session)
2499+ a.syncFloatingFocus()
2500+
2501+ return nil
2502+}
2503+
2504+func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2505+ session, _ := a.activeFloatingSession()
2506+ if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2507+ return session.pane.Navigate(ctx, url)
2508+ }
2509+ if a.omniboxNavigateFn != nil {
2510+ a.omniboxNavigateFn(url)
2511+ return nil
2512+ }
2513+ if a.navCoord == nil {
2514+ return fmt.Errorf("navigation coordinator not initialized")
2515+ }
2516+ return a.navCoord.Navigate(ctx, url)
2517+}
2518+
20132519 // ToggleOmnibox implements OmniboxProvider.
20142520 // Toggles the omnibox visibility in the active workspace view.
20152521 func (a *App) ToggleOmnibox(ctx context.Context) {
20162522 log := logging.FromContext(ctx)
2523+ if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2524+ wsView := a.activeWorkspaceView()
2525+ if wsView != nil && wsView.IsOmniboxVisible() {
2526+ wsView.HideOmnibox()
2527+ }
2528+
2529+ if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2530+ a.hideFloatingOmnibox(ctx, session)
2531+ } else {
2532+ a.showFloatingOmnibox(ctx, session)
2533+ }
2534+ a.syncFloatingFocus()
2535+ return
2536+ }
20172537
20182538 wsView := a.activeWorkspaceView()
20192539 if wsView == nil {
@@ -2098,8 +2618,7 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
20982618 // This is called to set the navigation callback on new omniboxes.
20992619 // Since omniboxes are created per-pane, we store the config with the callback.
21002620 func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
2101- // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
2102- // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
2621+ a.omniboxNavigateFn = fn
21032622 }
21042623
21052624 // initFilteringAsync starts background filter loading with toast feedback.
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
10 11
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
@@ -44,8 +45,16 @@ const (
44 AppID = "com.github.bnema.dumber"45 AppID = "com.github.bnema.dumber"
45 // crashReportToastDurationMs keeps startup crash-report toast visible longer.46 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
46 crashReportToastDurationMs = 500047 crashReportToastDurationMs = 5000
48+ floatingPaneFallbackWidth = 1200
49+ floatingPaneFallbackHeight = 800
50+ floatingPaneIDPrefix = "floating-pane:"
51+ floatingSessionIDDefault = "default"
47 )52 )
48 53
54+func gtkApplicationFlags() gio.ApplicationFlags {
55+ return gio.GApplicationNonUniqueValue
56+}
57+
49 // App wraps the GTK Application and manages the browser lifecycle.58 // App wraps the GTK Application and manages the browser lifecycle.
50 type App struct {59 type App struct {
51 deps *Dependencies60 deps *Dependencies
@@ -81,9 +90,12 @@ type App struct {
81 resizeModeBorderTarget layout.Widget90 resizeModeBorderTarget layout.Widget
82 91
83 // Omnibox configuration (omnibox is created per workspace view)92 // Omnibox configuration (omnibox is created per workspace view)
84- omniboxCfg component.OmniboxConfig93+ omniboxCfg component.OmniboxConfig
94+ omniboxNavigateFn func(url string)
85 // Find bar configuration (find bar is created per workspace view)95 // Find bar configuration (find bar is created per workspace view)
86 findBarCfg component.FindBarConfig96 findBarCfg component.FindBarConfig
97+ // Floating pane sessions keyed by tab and profile session ID.
98+ floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
87 99
88 // Web content (managed by ContentCoordinator)100 // Web content (managed by ContentCoordinator)
89 pool *webkit.WebViewPool101 pool *webkit.WebViewPool
@@ -130,6 +142,25 @@ type App struct {
130 cancel context.CancelCauseFunc142 cancel context.CancelCauseFunc
131 }143 }
132 144
145+type floatingWorkspaceSession struct {
146+ paneID entity.PaneID
147+ pane *component.FloatingPane
148+ webView *webkit.WebView
149+ overlay layout.OverlayWidget
150+ widget layout.Widget
151+ omnibox *component.Omnibox
152+ omniboxWidget layout.Widget
153+ resizeWatcherActive bool
154+ resizeTickID uint
155+ appliedWidth int
156+ appliedHeight int
157+}
158+
159+type floatingSessionKey struct {
160+ tabID entity.TabID
161+ sessionID string
162+}
163+
133 // New creates a new App with the given dependencies.164 // New creates a new App with the given dependencies.
134 func New(deps *Dependencies) (*App, error) {165 func New(deps *Dependencies) (*App, error) {
135 if err := deps.Validate(); err != nil {166 if err := deps.Validate(); err != nil {
@@ -138,17 +169,18 @@ func New(deps *Dependencies) (*App, error) {
138 ctx, cancel := context.WithCancelCause(deps.Ctx)169 ctx, cancel := context.WithCancelCause(deps.Ctx)
139 170
140 app := &App{171 app := &App{
141- deps: deps,172+ deps: deps,
142- tabs: entity.NewTabList(),173+ tabs: entity.NewTabList(),
143- tabsUC: deps.TabsUC,174+ tabsUC: deps.TabsUC,
144- panesUC: deps.PanesUC,175+ panesUC: deps.PanesUC,
145- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),176+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
146- pool: deps.Pool,177+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
147- injector: deps.Injector,178+ pool: deps.Pool,
148- router: deps.MessageRouter,179+ injector: deps.Injector,
149- settings: deps.Settings,180+ router: deps.MessageRouter,
150- configManager: config.GetManager(),181+ settings: deps.Settings,
151- cancel: cancel,182+ configManager: config.GetManager(),
183+ cancel: cancel,
152 }184 }
153 if app.router == nil {185 if app.router == nil {
154 app.router = webkit.NewMessageRouter(ctx)186 app.router = webkit.NewMessageRouter(ctx)
@@ -208,7 +240,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
208 }240 }
209 241
210 appID := AppID242 appID := AppID
211- a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)243+ a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
212 if a.gtkApp == nil {244 if a.gtkApp == nil {
213 log.Error().Msg("failed to create GTK application")245 log.Error().Msg("failed to create GTK application")
214 return 1246 return 1
@@ -582,6 +614,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
582 // Create keyboard handler and wire to dispatcher.614 // Create keyboard handler and wire to dispatcher.
583 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)615 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
584 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {616 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
617+ if action == input.ActionClosePane {
618+ if a.closeActiveFloatingPane(ctx) {
619+ return nil
620+ }
621+ }
585 return a.kbDispatcher.Dispatch(ctx, action)622 return a.kbDispatcher.Dispatch(ctx, action)
586 })623 })
587 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {624 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
@@ -656,7 +693,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
656 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,693 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
657 UIScale: a.deps.Config.DefaultUIScale,694 UIScale: a.deps.Config.DefaultUIScale,
658 OnNavigate: func(url string) {695 OnNavigate: func(url string) {
659- if err := a.navCoord.Navigate(ctx, url); err != nil {696+ if err := a.navigateFromOmnibox(ctx, url); err != nil {
660 log.Error().Err(err).Str("url", url).Msg("navigation failed")697 log.Error().Err(err).Str("url", url).Msg("navigation failed")
661 }698 }
662 },699 },
@@ -967,6 +1004,7 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
967 }1004 }
968 1005
969 func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {1006 func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1007+ a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
970 delete(a.workspaceViews, sourceTabID)1008 delete(a.workspaceViews, sourceTabID)
971 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {1009 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
972 a.mainWindow.TabBar().RemoveTab(sourceTabID)1010 a.mainWindow.TabBar().RemoveTab(sourceTabID)
@@ -1309,6 +1347,17 @@ func (a *App) onShutdown(ctx context.Context) {
1309 if a.faviconAdapter != nil {1347 if a.faviconAdapter != nil {
1310 a.faviconAdapter.Close()1348 a.faviconAdapter.Close()
1311 }1349 }
1350+ tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1351+ for key := range a.floatingSessions {
1352+ tabIDSet[key.tabID] = struct{}{}
1353+ }
1354+ tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1355+ for tabID := range tabIDSet {
1356+ tabIDs = append(tabIDs, tabID)
1357+ }
1358+ for _, tabID := range tabIDs {
1359+ a.releaseFloatingSessionsForTab(ctx, tabID)
1360+ }
1312 if a.deps.Pool != nil {1361 if a.deps.Pool != nil {
1313 a.deps.Pool.Close(ctx)1362 a.deps.Pool.Close(ctx)
1314 }1363 }
@@ -1484,6 +1533,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1484 // Wire pane URI updates for session snapshots (searches all tabs)1533 // Wire pane URI updates for session snapshots (searches all tabs)
1485 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {1534 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1486 a.updatePaneURIInAllTabs(paneID, url)1535 a.updatePaneURIInAllTabs(paneID, url)
1536+ a.updateFloatingSessionURI(paneID, url)
1487 // Mark dirty so snapshot captures the new URI1537 // Mark dirty so snapshot captures the new URI
1488 a.MarkDirty()1538 a.MarkDirty()
1489 })1539 })
@@ -1522,6 +1572,12 @@ func (a *App) initCoordinators(ctx context.Context) {
1522 a.deps.ZoomUC,1572 a.deps.ZoomUC,
1523 a.deps.CopyURLUC,1573 a.deps.CopyURLUC,
1524 )1574 )
1575+ a.wireKeyboardActions()
1576+
1577+ log.Debug().Msg("coordinators initialized")
1578+}
1579+
1580+func (a *App) wireKeyboardActions() {
1525 a.kbDispatcher.SetOnQuit(a.Quit)1581 a.kbDispatcher.SetOnQuit(a.Quit)
1526 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {1582 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1527 a.ToggleFindBar(ctx)1583 a.ToggleFindBar(ctx)
@@ -1545,13 +1601,17 @@ func (a *App) initCoordinators(ctx context.Context) {
1545 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {1601 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1546 return a.HandleMovePaneToNextTab(ctx)1602 return a.HandleMovePaneToNextTab(ctx)
1547 })1603 })
1604+ a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1605+ return a.ToggleFloatingPane(ctx)
1606+ })
1607+ a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1608+ return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1609+ })
1548 1610
1549 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)1611 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1550 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {1612 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1551 return a.kbDispatcher.Dispatch(ctx, action)1613 return a.kbDispatcher.Dispatch(ctx, action)
1552 })1614 })
1553-
1554- log.Debug().Msg("coordinators initialized")
1555 }1615 }
1556 1616
1557 func (a *App) wireWebRTCPermissionIndicator() {1617 func (a *App) wireWebRTCPermissionIndicator() {
@@ -1889,6 +1949,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1889 1949
1890 // Store in map1950 // Store in map
1891 a.workspaceViews[tab.ID] = wsView1951 a.workspaceViews[tab.ID] = wsView
1952+ for key, session := range a.floatingSessions {
1953+ if key.tabID != tab.ID || session == nil || session.pane == nil {
1954+ continue
1955+ }
1956+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1957+ if session.widget != nil {
1958+ wsView.AddWorkspaceOverlayWidget(session.widget)
1959+ session.widget.SetVisible(session.pane.IsVisible())
1960+ }
1961+ }
1962+ a.syncFloatingFocus()
1892 1963
1893 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")1964 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1894 }1965 }
@@ -1929,12 +2000,11 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
1929 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.2000 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1930 // Used by the accent picker to insert accented characters into web content.2001 // Used by the accent picker to insert accented characters into web content.
1931 func (a *App) getActiveWebViewTarget() port.TextInputTarget {2002 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1932- ws := a.activeWorkspace()2003+ if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1933- if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1934 return nil2004 return nil
1935 }2005 }
1936 2006
1937- wv := a.contentCoord.GetWebView(ws.ActivePaneID)2007+ wv := a.contentCoord.ActiveWebView(context.Background())
1938 if wv == nil {2008 if wv == nil {
1939 return nil2009 return nil
1940 }2010 }
@@ -2006,14 +2076,464 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2006 2076
2007 // Update window title with the new active pane's title2077 // Update window title with the new active pane's title
2008 a.updateWindowTitleFromActivePane()2078 a.updateWindowTitleFromActivePane()
2079+ a.syncFloatingFocus()
2009 2080
2010 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")2081 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
2011 }2082 }
2012 2083
2084+func normalizeFloatingSessionID(sessionID string) string {
2085+ sessionID = strings.TrimSpace(sessionID)
2086+ if sessionID == "" {
2087+ return floatingSessionIDDefault
2088+ }
2089+ return sessionID
2090+}
2091+
2092+func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2093+ return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2094+}
2095+
2096+func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2097+ return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2098+}
2099+
2100+func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2101+ if overlay == nil {
2102+ return
2103+ }
2104+
2105+ overlay.AddCssClass("floating-pane-container")
2106+ overlay.AddCssClass("pane-border")
2107+ overlay.AddCssClass("pane-active")
2108+}
2109+
2110+func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2111+ if a.deps != nil && a.deps.Config != nil {
2112+ return a.deps.Config.Workspace.FloatingPane
2113+ }
2114+ return config.Get().Workspace.FloatingPane
2115+}
2116+
2117+func (a *App) ensureFloatingSession(
2118+ ctx context.Context,
2119+ tabID entity.TabID,
2120+ sessionID string,
2121+ wsView *component.WorkspaceView,
2122+) (*floatingWorkspaceSession, error) {
2123+ key := floatingSessionMapKey(tabID, sessionID)
2124+ if session, ok := a.floatingSessions[key]; ok && session != nil {
2125+ if wsView == nil {
2126+ return session, nil
2127+ }
2128+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2129+ if session.widget != nil {
2130+ wsView.AddWorkspaceOverlayWidget(session.widget)
2131+ }
2132+ return session, nil
2133+ }
2134+
2135+ if wsView == nil {
2136+ return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2137+ }
2138+ if a.contentCoord == nil {
2139+ return nil, fmt.Errorf("content coordinator not initialized")
2140+ }
2141+ if a.widgetFactory == nil {
2142+ return nil, fmt.Errorf("widget factory not initialized")
2143+ }
2144+
2145+ paneID := floatingPaneIDForSession(tabID, sessionID)
2146+ wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2147+ if err != nil {
2148+ return nil, fmt.Errorf("ensure floating webview: %w", err)
2149+ }
2150+
2151+ webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2152+ if webViewWidget == nil {
2153+ return nil, fmt.Errorf("wrap floating webview widget")
2154+ }
2155+ webViewWidget.SetHexpand(true)
2156+ webViewWidget.SetVexpand(true)
2157+
2158+ overlay := a.widgetFactory.NewOverlay()
2159+ overlay.SetChild(webViewWidget)
2160+ decorateFloatingOverlay(overlay)
2161+ overlay.SetHalign(gtk.AlignCenterValue)
2162+ overlay.SetValign(gtk.AlignCenterValue)
2163+ overlay.SetVisible(false)
2164+ wsView.AddWorkspaceOverlayWidget(overlay)
2165+
2166+ floatingCfg := a.currentFloatingConfig()
2167+ floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2168+ WidthPct: floatingCfg.WidthPct,
2169+ HeightPct: floatingCfg.HeightPct,
2170+ FallbackWidth: floatingPaneFallbackWidth,
2171+ FallbackHeight: floatingPaneFallbackHeight,
2172+ OnNavigate: func(navCtx context.Context, url string) error {
2173+ return wv.LoadURI(navCtx, url)
2174+ },
2175+ })
2176+
2177+ session := &floatingWorkspaceSession{
2178+ paneID: paneID,
2179+ pane: floatingPane,
2180+ webView: wv,
2181+ overlay: overlay,
2182+ widget: overlay,
2183+ }
2184+ a.floatingSessions[key] = session
2185+ return session, nil
2186+}
2187+
2188+func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2189+ sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2190+ for key, session := range a.floatingSessions {
2191+ if key.tabID != tabID || session == nil {
2192+ continue
2193+ }
2194+ sessions[key] = session
2195+ }
2196+ return sessions
2197+}
2198+
2199+func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2200+ sessions := a.floatingSessionsForTab(tabID)
2201+ if len(sessions) == 0 {
2202+ return
2203+ }
2204+
2205+ for key, session := range sessions {
2206+ a.stopFloatingResizeWatcher(session)
2207+ if session.pane != nil {
2208+ session.pane.Hide(ctx)
2209+ }
2210+ a.hideFloatingOmnibox(ctx, session)
2211+ if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil {
2212+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
2213+ }
2214+ if a.contentCoord != nil && session.paneID != "" {
2215+ a.contentCoord.ReleaseWebView(ctx, session.paneID)
2216+ }
2217+ delete(a.floatingSessions, key)
2218+ }
2219+ a.syncFloatingFocus()
2220+}
2221+
2222+func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2223+ activeTab := a.tabs.ActiveTab()
2224+ if activeTab == nil {
2225+ return nil, ""
2226+ }
2227+ for key, session := range a.floatingSessions {
2228+ if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2229+ continue
2230+ }
2231+ if session.pane.IsVisible() {
2232+ return session, activeTab.ID
2233+ }
2234+ }
2235+ return nil, activeTab.ID
2236+}
2237+
2238+func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2239+ if paneID == "" {
2240+ return
2241+ }
2242+
2243+ for _, session := range a.floatingSessions {
2244+ if session == nil || session.pane == nil {
2245+ continue
2246+ }
2247+ if session.paneID != paneID {
2248+ continue
2249+ }
2250+ session.pane.RecordLoadedURL(url)
2251+ return
2252+ }
2253+}
2254+
2255+func (a *App) syncFloatingFocus() {
2256+ for _, wsView := range a.workspaceViews {
2257+ if wsView != nil {
2258+ wsView.SetHoverFocusLocked(false)
2259+ }
2260+ }
2261+
2262+ if a.contentCoord == nil {
2263+ return
2264+ }
2265+
2266+ activeTab := a.tabs.ActiveTab()
2267+ if activeTab == nil {
2268+ a.contentCoord.ClearActivePaneOverride()
2269+ return
2270+ }
2271+
2272+ if session, _ := a.activeFloatingSession(); session != nil {
2273+ if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2274+ wsView.SetHoverFocusLocked(true)
2275+ }
2276+ a.startFloatingResizeWatcher(session)
2277+ a.contentCoord.SetActivePaneOverride(session.paneID)
2278+ return
2279+ }
2280+
2281+ a.contentCoord.ClearActivePaneOverride()
2282+}
2283+
2284+func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2285+ if session == nil || session.overlay == nil || a.widgetFactory == nil {
2286+ return
2287+ }
2288+
2289+ if session.omnibox == nil {
2290+ cfg := a.omniboxCfg
2291+ cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2292+ if a.appToaster != nil {
2293+ a.appToaster.Show(toastCtx, message, level)
2294+ }
2295+ }
2296+
2297+ omnibox := component.NewOmnibox(ctx, cfg)
2298+ if omnibox == nil {
2299+ return
2300+ }
2301+
2302+ omnibox.SetParentOverlay(session.overlay)
2303+ omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2304+ if omniboxWidget == nil {
2305+ return
2306+ }
2307+
2308+ session.overlay.AddOverlay(omniboxWidget)
2309+ session.overlay.SetClipOverlay(omniboxWidget, false)
2310+ session.overlay.SetMeasureOverlay(omniboxWidget, false)
2311+
2312+ session.omnibox = omnibox
2313+ session.omniboxWidget = omniboxWidget
2314+ }
2315+
2316+ session.pane.SetOmniboxVisible(true)
2317+ session.omnibox.Show(ctx, "")
2318+}
2319+
2320+func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2321+ if session == nil || session.pane == nil {
2322+ return
2323+ }
2324+
2325+ session.pane.SetOmniboxVisible(false)
2326+ if session.omnibox == nil {
2327+ return
2328+ }
2329+
2330+ session.omnibox.Hide(ctx)
2331+ if session.overlay != nil && session.omniboxWidget != nil {
2332+ if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2333+ session.overlay.RemoveOverlay(session.omniboxWidget)
2334+ } else if parent != nil {
2335+ session.omniboxWidget.Unparent()
2336+ }
2337+ }
2338+ session.omnibox = nil
2339+ session.omniboxWidget = nil
2340+}
2341+
2342+func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2343+ if session == nil || session.pane == nil {
2344+ return
2345+ }
2346+ session.pane.Resize()
2347+ if session.widget == nil {
2348+ return
2349+ }
2350+ width, height := session.pane.Dimensions()
2351+ if session.appliedWidth == width && session.appliedHeight == height {
2352+ return
2353+ }
2354+ session.widget.SetSizeRequest(width, height)
2355+ session.appliedWidth = width
2356+ session.appliedHeight = height
2357+}
2358+
2359+func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2360+ if session == nil || session.pane == nil {
2361+ return false
2362+ }
2363+ if !session.pane.IsVisible() {
2364+ session.resizeWatcherActive = false
2365+ session.resizeTickID = 0
2366+ return false
2367+ }
2368+
2369+ a.resizeFloatingWidget(session)
2370+ return true
2371+}
2372+
2373+func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2374+ if session == nil || session.overlay == nil || session.resizeWatcherActive {
2375+ return
2376+ }
2377+
2378+ overlayWidget := session.overlay.GtkWidget()
2379+ if overlayWidget == nil {
2380+ return
2381+ }
2382+
2383+ session.resizeWatcherActive = true
2384+ tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2385+ keepRunning := a.handleFloatingViewportTick(session)
2386+ if !keepRunning {
2387+ session.resizeWatcherActive = false
2388+ session.resizeTickID = 0
2389+ }
2390+ return keepRunning
2391+ })
2392+ session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2393+}
2394+
2395+func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2396+ if session == nil {
2397+ return
2398+ }
2399+
2400+ if session.resizeTickID != 0 && session.overlay != nil {
2401+ if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2402+ overlayWidget.RemoveTickCallback(session.resizeTickID)
2403+ }
2404+ }
2405+ session.resizeTickID = 0
2406+ session.resizeWatcherActive = false
2407+}
2408+
2409+func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2410+ if session == nil || session.pane == nil {
2411+ return
2412+ }
2413+
2414+ a.stopFloatingResizeWatcher(session)
2415+ session.pane.Hide(ctx)
2416+ a.hideFloatingOmnibox(ctx, session)
2417+ if session.widget != nil {
2418+ session.widget.SetVisible(false)
2419+ }
2420+}
2421+
2422+func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2423+ for key, session := range a.floatingSessions {
2424+ if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2425+ continue
2426+ }
2427+ if !session.pane.IsVisible() {
2428+ continue
2429+ }
2430+ a.hideFloatingSession(ctx, session)
2431+ }
2432+}
2433+
2434+func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2435+ session, _ := a.activeFloatingSession()
2436+ if session == nil {
2437+ return false
2438+ }
2439+ a.hideFloatingSession(ctx, session)
2440+ a.syncFloatingFocus()
2441+ return true
2442+}
2443+
2444+// ToggleFloatingPane toggles the active workspace floating pane visibility.
2445+func (a *App) ToggleFloatingPane(ctx context.Context) error {
2446+ activeTab := a.tabs.ActiveTab()
2447+ if activeTab == nil {
2448+ return nil
2449+ }
2450+ if a.closeActiveFloatingPane(ctx) {
2451+ return nil
2452+ }
2453+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2454+}
2455+
2456+// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2457+func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2458+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2459+}
2460+
2461+// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2462+func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2463+ return a.openFloatingPaneSession(ctx, sessionID, url)
2464+}
2465+
2466+func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2467+ activeTab := a.tabs.ActiveTab()
2468+ if activeTab == nil {
2469+ return nil
2470+ }
2471+
2472+ wsView := a.workspaceViews[activeTab.ID]
2473+ session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2474+ if err != nil {
2475+ return err
2476+ }
2477+ a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2478+
2479+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2480+ if hasURL {
2481+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2482+ return err
2483+ }
2484+ a.hideFloatingOmnibox(ctx, session)
2485+ } else {
2486+ if err := session.pane.ShowToggle(ctx); err != nil {
2487+ return err
2488+ }
2489+ if session.pane.IsOmniboxVisible() {
2490+ a.showFloatingOmnibox(ctx, session)
2491+ }
2492+ }
2493+
2494+ a.resizeFloatingWidget(session)
2495+ if session.widget != nil {
2496+ session.widget.SetVisible(session.pane.IsVisible())
2497+ }
2498+ a.startFloatingResizeWatcher(session)
2499+ a.syncFloatingFocus()
2500+
2501+ return nil
2502+}
2503+
2504+func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2505+ session, _ := a.activeFloatingSession()
2506+ if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2507+ return session.pane.Navigate(ctx, url)
2508+ }
2509+ if a.omniboxNavigateFn != nil {
2510+ a.omniboxNavigateFn(url)
2511+ return nil
2512+ }
2513+ if a.navCoord == nil {
2514+ return fmt.Errorf("navigation coordinator not initialized")
2515+ }
2516+ return a.navCoord.Navigate(ctx, url)
2517+}
2518+
2013 // ToggleOmnibox implements OmniboxProvider.2519 // ToggleOmnibox implements OmniboxProvider.
2014 // Toggles the omnibox visibility in the active workspace view.2520 // Toggles the omnibox visibility in the active workspace view.
2015 func (a *App) ToggleOmnibox(ctx context.Context) {2521 func (a *App) ToggleOmnibox(ctx context.Context) {
2016 log := logging.FromContext(ctx)2522 log := logging.FromContext(ctx)
2523+ if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2524+ wsView := a.activeWorkspaceView()
2525+ if wsView != nil && wsView.IsOmniboxVisible() {
2526+ wsView.HideOmnibox()
2527+ }
2528+
2529+ if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2530+ a.hideFloatingOmnibox(ctx, session)
2531+ } else {
2532+ a.showFloatingOmnibox(ctx, session)
2533+ }
2534+ a.syncFloatingFocus()
2535+ return
2536+ }
2017 2537
2018 wsView := a.activeWorkspaceView()2538 wsView := a.activeWorkspaceView()
2019 if wsView == nil {2539 if wsView == nil {
@@ -2098,8 +2618,7 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
2098 // This is called to set the navigation callback on new omniboxes.2618 // This is called to set the navigation callback on new omniboxes.
2099 // Since omniboxes are created per-pane, we store the config with the callback.2619 // Since omniboxes are created per-pane, we store the config with the callback.
2100 func (a *App) SetOmniboxOnNavigate(fn func(url string)) {2620 func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
2101- // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox2621+ a.omniboxNavigateFn = fn
2102- // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
2103 }2622 }
2104 2623
2105 // initFilteringAsync starts background filter loading with toast feedback.2624 // initFilteringAsync starts background filter loading with toast feedback.
A internal/ui/app_flags_test.go +15 -0
@@ -0,0 +1,15 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gio"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestGTKApplicationFlags_AreNonUnique(t *testing.T) {
11+ flags := gtkApplicationFlags()
12+
13+ assert.Equal(t, gio.GApplicationNonUniqueValue, flags)
14+ assert.NotEqual(t, gio.GApplicationFlagsNoneValue, flags)
15+}
@@ -0,0 +1,15 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gio"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestGTKApplicationFlags_AreNonUnique(t *testing.T) {
11+ flags := gtkApplicationFlags()
12+
13+ assert.Equal(t, gio.GApplicationNonUniqueValue, flags)
14+ assert.NotEqual(t, gio.GApplicationFlagsNoneValue, flags)
15+}

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