detached a976d89

feat(ui): show link URL in status overlay on hover

commit
a976d898bac731534c929abb54567fd3e7ae534c
author
bnema · · 2025-12-25 21:44 UTC
parent
5738c09
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +285 -4
M internal/application/port/webview.go +3 -0
@@ -89,6 +89,9 @@ type WebViewCallbacks struct {
8989 OnCreate func(request PopupRequest) WebView
9090 // OnReadyToShow is called when a popup WebView is ready to display.
9191 OnReadyToShow func()
92+ // OnLinkHover is called when hovering over a link, image, or media element.
93+ // The uri parameter contains the target URL, or empty string when leaving.
94+ OnLinkHover func(uri string)
9295 }
9396
9497 // FindOptions configures search behavior.
@@ -89,6 +89,9 @@ type WebViewCallbacks struct {
89 OnCreate func(request PopupRequest) WebView89 OnCreate func(request PopupRequest) WebView
90 // OnReadyToShow is called when a popup WebView is ready to display.90 // OnReadyToShow is called when a popup WebView is ready to display.
91 OnReadyToShow func()91 OnReadyToShow func()
92+ // OnLinkHover is called when hovering over a link, image, or media element.
93+ // The uri parameter contains the target URL, or empty string when leaving.
94+ OnLinkHover func(uri string)
92 }95 }
93 96
94 // FindOptions configures search behavior.97 // FindOptions configures search behavior.
M internal/infrastructure/webkit/webview.go +32 -0
@@ -118,6 +118,7 @@ type WebView struct {
118118 OnEnterFullscreen func() bool // Return true to prevent fullscreen
119119 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
120120 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
121+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
121122
122123 logger zerolog.Logger
123124 mu sync.RWMutex
@@ -328,6 +329,7 @@ func (wv *WebView) connectSignals() {
328329 wv.connectEnterFullscreenSignal()
329330 wv.connectLeaveFullscreenSignal()
330331 wv.connectAudioStateSignal()
332+ wv.connectMouseTargetChangedSignal()
331333 }
332334
333335 func (wv *WebView) connectLoadChangedSignal() {
@@ -593,6 +595,34 @@ func (wv *WebView) connectAudioStateSignal() {
593595 wv.signalIDs = append(wv.signalIDs, sigID)
594596 }
595597
598+func (wv *WebView) connectMouseTargetChangedSignal() {
599+ mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
600+ if wv.OnLinkHover == nil {
601+ return
602+ }
603+
604+ hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
605+ if hitResult == nil {
606+ wv.OnLinkHover("")
607+ return
608+ }
609+
610+ var uri string
611+ switch {
612+ case hitResult.ContextIsLink():
613+ uri = hitResult.GetLinkUri()
614+ case hitResult.ContextIsImage():
615+ uri = hitResult.GetImageUri()
616+ case hitResult.ContextIsMedia():
617+ uri = hitResult.GetMediaUri()
618+ }
619+
620+ wv.OnLinkHover(uri)
621+ }
622+ sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
623+ wv.signalIDs = append(wv.signalIDs, sigID)
624+}
625+
596626 // ID returns the unique identifier for this WebView.
597627 func (wv *WebView) ID() WebViewID {
598628 return wv.id
@@ -862,6 +892,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
862892 return nil
863893 }
864894 }
895+ wv.OnLinkHover = callbacks.OnLinkHover
865896 }
866897
867898 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -935,6 +966,7 @@ func (wv *WebView) Destroy() {
935966 wv.OnEnterFullscreen = nil
936967 wv.OnLeaveFullscreen = nil
937968 wv.OnAudioStateChanged = nil
969+ wv.OnLinkHover = nil
938970
939971 // 3. Clear async callback references
940972 wv.mu.Lock()
@@ -118,6 +118,7 @@ type WebView struct {
118 OnEnterFullscreen func() bool // Return true to prevent fullscreen118 OnEnterFullscreen func() bool // Return true to prevent fullscreen
119 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen119 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
120 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops120 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
121+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
121 122
122 logger zerolog.Logger123 logger zerolog.Logger
123 mu sync.RWMutex124 mu sync.RWMutex
@@ -328,6 +329,7 @@ func (wv *WebView) connectSignals() {
328 wv.connectEnterFullscreenSignal()329 wv.connectEnterFullscreenSignal()
329 wv.connectLeaveFullscreenSignal()330 wv.connectLeaveFullscreenSignal()
330 wv.connectAudioStateSignal()331 wv.connectAudioStateSignal()
332+ wv.connectMouseTargetChangedSignal()
331 }333 }
332 334
333 func (wv *WebView) connectLoadChangedSignal() {335 func (wv *WebView) connectLoadChangedSignal() {
@@ -593,6 +595,34 @@ func (wv *WebView) connectAudioStateSignal() {
593 wv.signalIDs = append(wv.signalIDs, sigID)595 wv.signalIDs = append(wv.signalIDs, sigID)
594 }596 }
595 597
598+func (wv *WebView) connectMouseTargetChangedSignal() {
599+ mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
600+ if wv.OnLinkHover == nil {
601+ return
602+ }
603+
604+ hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
605+ if hitResult == nil {
606+ wv.OnLinkHover("")
607+ return
608+ }
609+
610+ var uri string
611+ switch {
612+ case hitResult.ContextIsLink():
613+ uri = hitResult.GetLinkUri()
614+ case hitResult.ContextIsImage():
615+ uri = hitResult.GetImageUri()
616+ case hitResult.ContextIsMedia():
617+ uri = hitResult.GetMediaUri()
618+ }
619+
620+ wv.OnLinkHover(uri)
621+ }
622+ sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
623+ wv.signalIDs = append(wv.signalIDs, sigID)
624+}
625+
596 // ID returns the unique identifier for this WebView.626 // ID returns the unique identifier for this WebView.
597 func (wv *WebView) ID() WebViewID {627 func (wv *WebView) ID() WebViewID {
598 return wv.id628 return wv.id
@@ -862,6 +892,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
862 return nil892 return nil
863 }893 }
864 }894 }
895+ wv.OnLinkHover = callbacks.OnLinkHover
865 }896 }
866 897
867 // ShowDevTools opens the WebKit inspector/developer tools.898 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -935,6 +966,7 @@ func (wv *WebView) Destroy() {
935 wv.OnEnterFullscreen = nil966 wv.OnEnterFullscreen = nil
936 wv.OnLeaveFullscreen = nil967 wv.OnLeaveFullscreen = nil
937 wv.OnAudioStateChanged = nil968 wv.OnAudioStateChanged = nil
969+ wv.OnLinkHover = nil
938 970
939 // 3. Clear async callback references971 // 3. Clear async callback references
940 wv.mu.Lock()972 wv.mu.Lock()
A internal/ui/component/link_status.go +140 -0
@@ -0,0 +1,140 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+ "time"
7+
8+ "github.com/jwijenbergh/puregotk/v4/glib"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/ui/layout"
12+)
13+
14+const (
15+ // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
16+ linkStatusShowDelayMs = 100
17+ // linkStatusMaxChars is the maximum characters before truncation.
18+ linkStatusMaxChars = 80
19+)
20+
21+// LinkStatusOverlay displays the destination URL when hovering over links.
22+// It appears in the bottom-left corner with a fade-in/fade-out transition.
23+type LinkStatusOverlay struct {
24+ factory layout.WidgetFactory
25+ container layout.BoxWidget
26+ label layout.LabelWidget
27+
28+ pendingURI string // URI waiting to be shown after delay
29+ showTimer uint // GLib timer for delayed show
30+ visible bool // Whether overlay is currently visible (has .visible class)
31+ mu sync.Mutex
32+}
33+
34+// NewLinkStatusOverlay creates a new link status overlay component.
35+// The overlay is positioned at bottom-left with fade transitions via CSS.
36+func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
37+ // Create container box for styling and positioning
38+ container := factory.NewBox(layout.OrientationHorizontal, 0)
39+ container.AddCssClass("link-status")
40+
41+ // Position at bottom-left
42+ container.SetHalign(gtk.AlignStartValue)
43+ container.SetValign(gtk.AlignEndValue)
44+
45+ // Don't expand to fill space
46+ container.SetHexpand(false)
47+ container.SetVexpand(false)
48+
49+ // Don't intercept pointer events - let clicks pass through
50+ container.SetCanTarget(false)
51+ container.SetCanFocus(false)
52+
53+ // Create label for URL text
54+ label := factory.NewLabel("")
55+ label.SetCanTarget(false)
56+ label.SetCanFocus(false)
57+ label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs
58+ label.SetMaxWidthChars(linkStatusMaxChars)
59+ container.Append(label)
60+
61+ return &LinkStatusOverlay{
62+ factory: factory,
63+ container: container,
64+ label: label,
65+ visible: false,
66+ }
67+}
68+
69+// Show displays the link status overlay with the given URI.
70+// If uri is empty, hides the overlay instead.
71+// Uses a small delay to avoid flicker during rapid mouse movement.
72+func (l *LinkStatusOverlay) Show(uri string) {
73+ l.mu.Lock()
74+ defer l.mu.Unlock()
75+
76+ // Cancel any pending show timer
77+ if l.showTimer != 0 {
78+ glib.SourceRemove(l.showTimer)
79+ l.showTimer = 0
80+ }
81+
82+ if uri == "" {
83+ l.hide()
84+ return
85+ }
86+
87+ l.pendingURI = uri
88+
89+ // Start delay timer before showing
90+ cb := glib.SourceFunc(func(_ uintptr) bool {
91+ l.mu.Lock()
92+ defer l.mu.Unlock()
93+
94+ if l.pendingURI != "" {
95+ l.label.SetText(l.pendingURI)
96+ if !l.visible {
97+ l.visible = true
98+ l.container.AddCssClass("visible")
99+ }
100+ }
101+ l.showTimer = 0
102+ return false // Don't repeat
103+ })
104+
105+ l.showTimer = glib.TimeoutAdd(uint(linkStatusShowDelayMs/time.Millisecond), &cb, 0)
106+}
107+
108+// Hide manually hides the link status overlay.
109+func (l *LinkStatusOverlay) Hide() {
110+ l.mu.Lock()
111+ defer l.mu.Unlock()
112+ l.hide()
113+}
114+
115+// hide performs the actual hide operation (must be called with lock held).
116+func (l *LinkStatusOverlay) hide() {
117+ // Cancel pending show timer
118+ if l.showTimer != 0 {
119+ glib.SourceRemove(l.showTimer)
120+ l.showTimer = 0
121+ }
122+ l.pendingURI = ""
123+
124+ if l.visible {
125+ l.visible = false
126+ l.container.RemoveCssClass("visible")
127+ }
128+}
129+
130+// Widget returns the underlying widget for embedding in overlays.
131+func (l *LinkStatusOverlay) Widget() layout.Widget {
132+ return l.container
133+}
134+
135+// IsVisible returns whether the overlay is currently visible.
136+func (l *LinkStatusOverlay) IsVisible() bool {
137+ l.mu.Lock()
138+ defer l.mu.Unlock()
139+ return l.visible
140+}
@@ -0,0 +1,140 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+ "time"
7+
8+ "github.com/jwijenbergh/puregotk/v4/glib"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/ui/layout"
12+)
13+
14+const (
15+ // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
16+ linkStatusShowDelayMs = 100
17+ // linkStatusMaxChars is the maximum characters before truncation.
18+ linkStatusMaxChars = 80
19+)
20+
21+// LinkStatusOverlay displays the destination URL when hovering over links.
22+// It appears in the bottom-left corner with a fade-in/fade-out transition.
23+type LinkStatusOverlay struct {
24+ factory layout.WidgetFactory
25+ container layout.BoxWidget
26+ label layout.LabelWidget
27+
28+ pendingURI string // URI waiting to be shown after delay
29+ showTimer uint // GLib timer for delayed show
30+ visible bool // Whether overlay is currently visible (has .visible class)
31+ mu sync.Mutex
32+}
33+
34+// NewLinkStatusOverlay creates a new link status overlay component.
35+// The overlay is positioned at bottom-left with fade transitions via CSS.
36+func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
37+ // Create container box for styling and positioning
38+ container := factory.NewBox(layout.OrientationHorizontal, 0)
39+ container.AddCssClass("link-status")
40+
41+ // Position at bottom-left
42+ container.SetHalign(gtk.AlignStartValue)
43+ container.SetValign(gtk.AlignEndValue)
44+
45+ // Don't expand to fill space
46+ container.SetHexpand(false)
47+ container.SetVexpand(false)
48+
49+ // Don't intercept pointer events - let clicks pass through
50+ container.SetCanTarget(false)
51+ container.SetCanFocus(false)
52+
53+ // Create label for URL text
54+ label := factory.NewLabel("")
55+ label.SetCanTarget(false)
56+ label.SetCanFocus(false)
57+ label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs
58+ label.SetMaxWidthChars(linkStatusMaxChars)
59+ container.Append(label)
60+
61+ return &LinkStatusOverlay{
62+ factory: factory,
63+ container: container,
64+ label: label,
65+ visible: false,
66+ }
67+}
68+
69+// Show displays the link status overlay with the given URI.
70+// If uri is empty, hides the overlay instead.
71+// Uses a small delay to avoid flicker during rapid mouse movement.
72+func (l *LinkStatusOverlay) Show(uri string) {
73+ l.mu.Lock()
74+ defer l.mu.Unlock()
75+
76+ // Cancel any pending show timer
77+ if l.showTimer != 0 {
78+ glib.SourceRemove(l.showTimer)
79+ l.showTimer = 0
80+ }
81+
82+ if uri == "" {
83+ l.hide()
84+ return
85+ }
86+
87+ l.pendingURI = uri
88+
89+ // Start delay timer before showing
90+ cb := glib.SourceFunc(func(_ uintptr) bool {
91+ l.mu.Lock()
92+ defer l.mu.Unlock()
93+
94+ if l.pendingURI != "" {
95+ l.label.SetText(l.pendingURI)
96+ if !l.visible {
97+ l.visible = true
98+ l.container.AddCssClass("visible")
99+ }
100+ }
101+ l.showTimer = 0
102+ return false // Don't repeat
103+ })
104+
105+ l.showTimer = glib.TimeoutAdd(uint(linkStatusShowDelayMs/time.Millisecond), &cb, 0)
106+}
107+
108+// Hide manually hides the link status overlay.
109+func (l *LinkStatusOverlay) Hide() {
110+ l.mu.Lock()
111+ defer l.mu.Unlock()
112+ l.hide()
113+}
114+
115+// hide performs the actual hide operation (must be called with lock held).
116+func (l *LinkStatusOverlay) hide() {
117+ // Cancel pending show timer
118+ if l.showTimer != 0 {
119+ glib.SourceRemove(l.showTimer)
120+ l.showTimer = 0
121+ }
122+ l.pendingURI = ""
123+
124+ if l.visible {
125+ l.visible = false
126+ l.container.RemoveCssClass("visible")
127+ }
128+}
129+
130+// Widget returns the underlying widget for embedding in overlays.
131+func (l *LinkStatusOverlay) Widget() layout.Widget {
132+ return l.container
133+}
134+
135+// IsVisible returns whether the overlay is currently visible.
136+func (l *LinkStatusOverlay) IsVisible() bool {
137+ l.mu.Lock()
138+ defer l.mu.Unlock()
139+ return l.visible
140+}
M internal/ui/component/pane_view.go +45 -4
@@ -20,10 +20,11 @@ const (
2020 type PaneView struct {
2121 factory layout.WidgetFactory
2222 overlay layout.OverlayWidget
23- webViewWidget layout.Widget // The actual WebView widget
24- borderBox layout.BoxWidget // Border overlay for active indication
25- progressBar *ProgressBar // Loading progress indicator
26- toaster *Toaster // Toast notification overlay
23+ webViewWidget layout.Widget // The actual WebView widget
24+ borderBox layout.BoxWidget // Border overlay for active indication
25+ progressBar *ProgressBar // Loading progress indicator
26+ toaster *Toaster // Toast notification overlay
27+ linkStatus *LinkStatusOverlay // Link hover URL overlay
2728 paneID entity.PaneID
2829 isActive bool
2930
@@ -350,6 +351,42 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
350351 t.ShowZoom(ctx, zoomPercent)
351352 }
352353
354+// ensureLinkStatus creates the link status overlay lazily on first use.
355+// Must be called with write lock held.
356+func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
357+ if pv.linkStatus != nil {
358+ return pv.linkStatus
359+ }
360+
361+ ls := NewLinkStatusOverlay(pv.factory)
362+ pv.overlay.AddOverlay(ls.Widget())
363+ pv.overlay.SetClipOverlay(ls.Widget(), false)
364+ pv.overlay.SetMeasureOverlay(ls.Widget(), false)
365+ pv.linkStatus = ls
366+ return ls
367+}
368+
369+// ShowLinkStatus displays the link status overlay with the given URI.
370+// If uri is empty, hides the overlay instead.
371+func (pv *PaneView) ShowLinkStatus(uri string) {
372+ pv.mu.Lock()
373+ ls := pv.ensureLinkStatus()
374+ pv.mu.Unlock()
375+
376+ ls.Show(uri)
377+}
378+
379+// HideLinkStatus hides the link status overlay.
380+func (pv *PaneView) HideLinkStatus() {
381+ pv.mu.Lock()
382+ ls := pv.linkStatus
383+ pv.mu.Unlock()
384+
385+ if ls != nil {
386+ ls.Hide()
387+ }
388+}
389+
353390 // Cleanup removes the WebView widget from the overlay and clears references.
354391 // This must be called before destroying the WebView to ensure proper GTK cleanup.
355392 // After calling Cleanup, the PaneView should not be reused.
@@ -383,4 +420,8 @@ func (pv *PaneView) Cleanup() {
383420 pv.overlay.RemoveOverlay(pv.toaster.Widget())
384421 pv.toaster = nil
385422 }
423+ if pv.linkStatus != nil {
424+ pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
425+ pv.linkStatus = nil
426+ }
386427 }
@@ -20,10 +20,11 @@ const (
20 type PaneView struct {20 type PaneView struct {
21 factory layout.WidgetFactory21 factory layout.WidgetFactory
22 overlay layout.OverlayWidget22 overlay layout.OverlayWidget
23- webViewWidget layout.Widget // The actual WebView widget23+ webViewWidget layout.Widget // The actual WebView widget
24- borderBox layout.BoxWidget // Border overlay for active indication24+ borderBox layout.BoxWidget // Border overlay for active indication
25- progressBar *ProgressBar // Loading progress indicator25+ progressBar *ProgressBar // Loading progress indicator
26- toaster *Toaster // Toast notification overlay26+ toaster *Toaster // Toast notification overlay
27+ linkStatus *LinkStatusOverlay // Link hover URL overlay
27 paneID entity.PaneID28 paneID entity.PaneID
28 isActive bool29 isActive bool
29 30
@@ -350,6 +351,42 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
350 t.ShowZoom(ctx, zoomPercent)351 t.ShowZoom(ctx, zoomPercent)
351 }352 }
352 353
354+// ensureLinkStatus creates the link status overlay lazily on first use.
355+// Must be called with write lock held.
356+func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
357+ if pv.linkStatus != nil {
358+ return pv.linkStatus
359+ }
360+
361+ ls := NewLinkStatusOverlay(pv.factory)
362+ pv.overlay.AddOverlay(ls.Widget())
363+ pv.overlay.SetClipOverlay(ls.Widget(), false)
364+ pv.overlay.SetMeasureOverlay(ls.Widget(), false)
365+ pv.linkStatus = ls
366+ return ls
367+}
368+
369+// ShowLinkStatus displays the link status overlay with the given URI.
370+// If uri is empty, hides the overlay instead.
371+func (pv *PaneView) ShowLinkStatus(uri string) {
372+ pv.mu.Lock()
373+ ls := pv.ensureLinkStatus()
374+ pv.mu.Unlock()
375+
376+ ls.Show(uri)
377+}
378+
379+// HideLinkStatus hides the link status overlay.
380+func (pv *PaneView) HideLinkStatus() {
381+ pv.mu.Lock()
382+ ls := pv.linkStatus
383+ pv.mu.Unlock()
384+
385+ if ls != nil {
386+ ls.Hide()
387+ }
388+}
389+
353 // Cleanup removes the WebView widget from the overlay and clears references.390 // Cleanup removes the WebView widget from the overlay and clears references.
354 // This must be called before destroying the WebView to ensure proper GTK cleanup.391 // This must be called before destroying the WebView to ensure proper GTK cleanup.
355 // After calling Cleanup, the PaneView should not be reused.392 // After calling Cleanup, the PaneView should not be reused.
@@ -383,4 +420,8 @@ func (pv *PaneView) Cleanup() {
383 pv.overlay.RemoveOverlay(pv.toaster.Widget())420 pv.overlay.RemoveOverlay(pv.toaster.Widget())
384 pv.toaster = nil421 pv.toaster = nil
385 }422 }
423+ if pv.linkStatus != nil {
424+ pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
425+ pv.linkStatus = nil
426+ }
386 }427 }
M internal/ui/coordinator/content.go +29 -0
@@ -190,6 +190,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
190190 return c.handleLinkMiddleClick(ctx, paneID, uri)
191191 }
192192
193+ // Set up link hover callback for status overlay
194+ wv.OnLinkHover = func(uri string) {
195+ c.onLinkHover(paneID, uri)
196+ }
197+
193198 // Set up fullscreen handlers for idle inhibition
194199 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
195200
@@ -764,6 +769,25 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
764769 }
765770 }
766771
772+// onLinkHover updates the link status overlay when hovering over links.
773+func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
774+ _, wsView := c.getActiveWS()
775+ if wsView == nil {
776+ return
777+ }
778+
779+ paneView := wsView.GetPaneView(paneID)
780+ if paneView == nil {
781+ return
782+ }
783+
784+ if uri != "" {
785+ paneView.ShowLinkStatus(uri)
786+ } else {
787+ paneView.HideLinkStatus()
788+ }
789+}
790+
767791 // --- Popup Handling ---
768792
769793 // SetPopupConfig configures popup handling.
@@ -1050,6 +1074,11 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
10501074 return c.handleLinkMiddleClick(ctx, paneID, uri)
10511075 }
10521076
1077+ // Link hover callback for status overlay
1078+ wv.OnLinkHover = func(uri string) {
1079+ c.onLinkHover(paneID, uri)
1080+ }
1081+
10531082 // Fullscreen handlers for idle inhibition
10541083 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
10551084 }
@@ -190,6 +190,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
190 return c.handleLinkMiddleClick(ctx, paneID, uri)190 return c.handleLinkMiddleClick(ctx, paneID, uri)
191 }191 }
192 192
193+ // Set up link hover callback for status overlay
194+ wv.OnLinkHover = func(uri string) {
195+ c.onLinkHover(paneID, uri)
196+ }
197+
193 // Set up fullscreen handlers for idle inhibition198 // Set up fullscreen handlers for idle inhibition
194 c.setupIdleInhibitionHandlers(ctx, paneID, wv)199 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
195 200
@@ -764,6 +769,25 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
764 }769 }
765 }770 }
766 771
772+// onLinkHover updates the link status overlay when hovering over links.
773+func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
774+ _, wsView := c.getActiveWS()
775+ if wsView == nil {
776+ return
777+ }
778+
779+ paneView := wsView.GetPaneView(paneID)
780+ if paneView == nil {
781+ return
782+ }
783+
784+ if uri != "" {
785+ paneView.ShowLinkStatus(uri)
786+ } else {
787+ paneView.HideLinkStatus()
788+ }
789+}
790+
767 // --- Popup Handling ---791 // --- Popup Handling ---
768 792
769 // SetPopupConfig configures popup handling.793 // SetPopupConfig configures popup handling.
@@ -1050,6 +1074,11 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1050 return c.handleLinkMiddleClick(ctx, paneID, uri)1074 return c.handleLinkMiddleClick(ctx, paneID, uri)
1051 }1075 }
1052 1076
1077+ // Link hover callback for status overlay
1078+ wv.OnLinkHover = func(uri string) {
1079+ c.onLinkHover(paneID, uri)
1080+ }
1081+
1053 // Fullscreen handlers for idle inhibition1082 // Fullscreen handlers for idle inhibition
1054 c.setupIdleInhibitionHandlers(ctx, paneID, wv)1083 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1055 }1084 }
M internal/ui/theme/css.go +36 -0
@@ -108,6 +108,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
108108 sb.WriteString(generateToasterCSS(p))
109109 sb.WriteString("\n")
110110
111+ // Link status overlay styling
112+ sb.WriteString(generateLinkStatusCSS(p))
113+ sb.WriteString("\n")
114+
111115 // Session manager styling
112116 sb.WriteString(generateSessionManagerCSS(p))
113117
@@ -761,3 +765,35 @@ func generateToasterCSS(p Palette) string {
761765 }
762766 `
763767 }
768+
769+// generateLinkStatusCSS creates link status overlay styles.
770+// Uses CSS transitions for fade-in/fade-out effect.
771+func generateLinkStatusCSS(p Palette) string {
772+ return `/* ===== Link Status Overlay Styling ===== */
773+
774+/* Link status container - bottom left positioning */
775+.link-status {
776+ background-color: alpha(var(--surface-variant), 0.95);
777+ border-radius: 0.25em 0.25em 0 0;
778+ padding: 0.25em 0.5em;
779+ margin: 0;
780+ font-size: 0.75em;
781+ max-width: 50em;
782+ box-shadow: 0 -1px 4px alpha(black, 0.15);
783+
784+ /* Fade transition - hidden by default */
785+ opacity: 0;
786+ transition: opacity 150ms ease-in-out;
787+}
788+
789+/* Visible state - triggered by adding .visible class */
790+.link-status.visible {
791+ opacity: 1;
792+}
793+
794+/* Link status label text */
795+.link-status label {
796+ color: var(--muted);
797+}
798+`
799+}
@@ -108,6 +108,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
108 sb.WriteString(generateToasterCSS(p))108 sb.WriteString(generateToasterCSS(p))
109 sb.WriteString("\n")109 sb.WriteString("\n")
110 110
111+ // Link status overlay styling
112+ sb.WriteString(generateLinkStatusCSS(p))
113+ sb.WriteString("\n")
114+
111 // Session manager styling115 // Session manager styling
112 sb.WriteString(generateSessionManagerCSS(p))116 sb.WriteString(generateSessionManagerCSS(p))
113 117
@@ -761,3 +765,35 @@ func generateToasterCSS(p Palette) string {
761 }765 }
762 `766 `
763 }767 }
768+
769+// generateLinkStatusCSS creates link status overlay styles.
770+// Uses CSS transitions for fade-in/fade-out effect.
771+func generateLinkStatusCSS(p Palette) string {
772+ return `/* ===== Link Status Overlay Styling ===== */
773+
774+/* Link status container - bottom left positioning */
775+.link-status {
776+ background-color: alpha(var(--surface-variant), 0.95);
777+ border-radius: 0.25em 0.25em 0 0;
778+ padding: 0.25em 0.5em;
779+ margin: 0;
780+ font-size: 0.75em;
781+ max-width: 50em;
782+ box-shadow: 0 -1px 4px alpha(black, 0.15);
783+
784+ /* Fade transition - hidden by default */
785+ opacity: 0;
786+ transition: opacity 150ms ease-in-out;
787+}
788+
789+/* Visible state - triggered by adding .visible class */
790+.link-status.visible {
791+ opacity: 1;
792+}
793+
794+/* Link status label text */
795+.link-status label {
796+ color: var(--muted);
797+}
798+`
799+}

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