detached 82a17d2

Merge pull request #35 from bnema/dev

feat: link URL status overlay and local file path support
commit
82a17d2235e79bc49fb8e39a6bf780f22e9499b1
author
bnema · · 2025-12-25 22:04 UTC
committer
GitHub · · 2025-12-25 22:04 UTC
merge of
24473b6 + 8949dc3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +642 -13
M CHANGELOG.md +2 -0
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
9+- **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
10+- **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
911 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.
1012 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).
1113 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.8 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
9+- **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
10+- **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
9 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.11 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.
10 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).12 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).
11 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.13 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.
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/domain/url/normalize.go +53 -0
@@ -4,11 +4,41 @@ package url
44 import (
55 "fmt"
66 "net/url"
7+ "os"
8+ "path/filepath"
79 "strings"
810 )
911
12+// expandHome expands ~ prefix to user's home directory.
13+func expandHome(path string) string {
14+ if strings.HasPrefix(path, "~/") {
15+ if home, err := os.UserHomeDir(); err == nil {
16+ return filepath.Join(home, path[2:])
17+ }
18+ }
19+ return path
20+}
21+
22+// looksLikeFilePath returns true if the input looks like a filesystem path.
23+// This includes absolute paths (/path), relative paths (./path, ../path),
24+// and home-relative paths (~/path).
25+func looksLikeFilePath(input string) bool {
26+ switch {
27+ case strings.HasPrefix(input, "/"):
28+ return true
29+ case strings.HasPrefix(input, "./"):
30+ return true
31+ case strings.HasPrefix(input, "../"):
32+ return true
33+ case strings.HasPrefix(input, "~/"):
34+ return true
35+ }
36+ return false
37+}
38+
1039 // Normalize adds https:// prefix if missing for URL-like inputs.
1140 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
41+// If the input is an existing local file, returns a file:// URL.
1242 func Normalize(input string) string {
1343 if input == "" {
1444 return ""
@@ -28,6 +58,29 @@ func Normalize(input string) string {
2858 return input
2959 }
3060
61+ // Check if input looks like a file path
62+ if looksLikeFilePath(input) {
63+ expanded := expandHome(input)
64+ absPath, err := filepath.Abs(expanded)
65+ if err == nil {
66+ if _, statErr := os.Stat(absPath); statErr == nil {
67+ // File exists, convert to file:// URL
68+ return "file://" + absPath
69+ }
70+ }
71+ // Path-like input but file doesn't exist - return unchanged
72+ // Don't treat as URL to avoid malformed URLs like https:///path
73+ return input
74+ }
75+
76+ // Check if input is an existing file in current directory (relative without ./ prefix)
77+ absPath, err := filepath.Abs(input)
78+ if err == nil {
79+ if _, statErr := os.Stat(absPath); statErr == nil {
80+ return "file://" + absPath
81+ }
82+ }
83+
3184 // Looks like a URL (contains . and no spaces)
3285 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
3386 return "https://" + input
@@ -4,11 +4,41 @@ package url
4 import (4 import (
5 "fmt"5 "fmt"
6 "net/url"6 "net/url"
7+ "os"
8+ "path/filepath"
7 "strings"9 "strings"
8 )10 )
9 11
12+// expandHome expands ~ prefix to user's home directory.
13+func expandHome(path string) string {
14+ if strings.HasPrefix(path, "~/") {
15+ if home, err := os.UserHomeDir(); err == nil {
16+ return filepath.Join(home, path[2:])
17+ }
18+ }
19+ return path
20+}
21+
22+// looksLikeFilePath returns true if the input looks like a filesystem path.
23+// This includes absolute paths (/path), relative paths (./path, ../path),
24+// and home-relative paths (~/path).
25+func looksLikeFilePath(input string) bool {
26+ switch {
27+ case strings.HasPrefix(input, "/"):
28+ return true
29+ case strings.HasPrefix(input, "./"):
30+ return true
31+ case strings.HasPrefix(input, "../"):
32+ return true
33+ case strings.HasPrefix(input, "~/"):
34+ return true
35+ }
36+ return false
37+}
38+
10 // Normalize adds https:// prefix if missing for URL-like inputs.39 // Normalize adds https:// prefix if missing for URL-like inputs.
11 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.40 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
41+// If the input is an existing local file, returns a file:// URL.
12 func Normalize(input string) string {42 func Normalize(input string) string {
13 if input == "" {43 if input == "" {
14 return ""44 return ""
@@ -28,6 +58,29 @@ func Normalize(input string) string {
28 return input58 return input
29 }59 }
30 60
61+ // Check if input looks like a file path
62+ if looksLikeFilePath(input) {
63+ expanded := expandHome(input)
64+ absPath, err := filepath.Abs(expanded)
65+ if err == nil {
66+ if _, statErr := os.Stat(absPath); statErr == nil {
67+ // File exists, convert to file:// URL
68+ return "file://" + absPath
69+ }
70+ }
71+ // Path-like input but file doesn't exist - return unchanged
72+ // Don't treat as URL to avoid malformed URLs like https:///path
73+ return input
74+ }
75+
76+ // Check if input is an existing file in current directory (relative without ./ prefix)
77+ absPath, err := filepath.Abs(input)
78+ if err == nil {
79+ if _, statErr := os.Stat(absPath); statErr == nil {
80+ return "file://" + absPath
81+ }
82+ }
83+
31 // Looks like a URL (contains . and no spaces)84 // Looks like a URL (contains . and no spaces)
32 if strings.Contains(input, ".") && !strings.Contains(input, " ") {85 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
33 return "https://" + input86 return "https://" + input
A internal/domain/url/normalize_test.go +270 -0
@@ -0,0 +1,270 @@
1+package url
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestNormalize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ want: "",
19+ },
20+ {
21+ name: "http scheme unchanged",
22+ input: "http://example.com",
23+ want: "http://example.com",
24+ },
25+ {
26+ name: "https scheme unchanged",
27+ input: "https://example.com",
28+ want: "https://example.com",
29+ },
30+ {
31+ name: "file scheme unchanged",
32+ input: "file:///path/to/file.html",
33+ want: "file:///path/to/file.html",
34+ },
35+ {
36+ name: "dumb scheme unchanged",
37+ input: "dumb://home",
38+ want: "dumb://home",
39+ },
40+ {
41+ name: "about scheme unchanged",
42+ input: "about:blank",
43+ want: "about:blank",
44+ },
45+ {
46+ name: "domain gets https",
47+ input: "example.com",
48+ want: "https://example.com",
49+ },
50+ {
51+ name: "domain with path gets https",
52+ input: "example.com/path",
53+ want: "https://example.com/path",
54+ },
55+ {
56+ name: "search query unchanged",
57+ input: "hello world",
58+ want: "hello world",
59+ },
60+ {
61+ name: "single word unchanged",
62+ input: "hello",
63+ want: "hello",
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ got := Normalize(tt.input)
70+ if got != tt.want {
71+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
72+ }
73+ })
74+ }
75+}
76+
77+func TestNormalize_LocalFiles(t *testing.T) {
78+ // Create a temporary file for testing
79+ tmpDir := t.TempDir()
80+ tmpFile := filepath.Join(tmpDir, "test.html")
81+ if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
82+ t.Fatalf("failed to create temp file: %v", err)
83+ }
84+
85+ // Create a subdirectory with a file
86+ subDir := filepath.Join(tmpDir, "subdir")
87+ if err := os.Mkdir(subDir, 0755); err != nil {
88+ t.Fatalf("failed to create subdir: %v", err)
89+ }
90+ subFile := filepath.Join(subDir, "page.html")
91+ if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
92+ t.Fatalf("failed to create subdir file: %v", err)
93+ }
94+
95+ tests := []struct {
96+ name string
97+ input string
98+ want string
99+ }{
100+ {
101+ name: "absolute path to existing file",
102+ input: tmpFile,
103+ want: "file://" + tmpFile,
104+ },
105+ {
106+ name: "absolute path to existing directory",
107+ input: tmpDir,
108+ want: "file://" + tmpDir,
109+ },
110+ {
111+ name: "non-existent absolute path returned unchanged",
112+ input: "/nonexistent/file.html",
113+ want: "/nonexistent/file.html",
114+ },
115+ {
116+ name: "non-existent relative path with dot prefix returned unchanged",
117+ input: "./nonexistent/file.html",
118+ want: "./nonexistent/file.html",
119+ },
120+ {
121+ name: "non-existent home path returned unchanged",
122+ input: "~/nonexistent/file.html",
123+ want: "~/nonexistent/file.html",
124+ },
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ got := Normalize(tt.input)
130+ if got != tt.want {
131+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
132+ }
133+ })
134+ }
135+}
136+
137+func TestNormalize_RelativePaths(t *testing.T) {
138+ // Create a temp file in current directory
139+ tmpFile, err := os.CreateTemp(".", "test-*.html")
140+ if err != nil {
141+ t.Fatalf("failed to create temp file: %v", err)
142+ }
143+ tmpFile.Close()
144+ defer os.Remove(tmpFile.Name())
145+
146+ // Get absolute path for comparison
147+ absPath, err := filepath.Abs(tmpFile.Name())
148+ if err != nil {
149+ t.Fatalf("failed to get abs path: %v", err)
150+ }
151+
152+ t.Run("relative path to existing file", func(t *testing.T) {
153+ got := Normalize(tmpFile.Name())
154+ want := "file://" + absPath
155+ if got != want {
156+ t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
157+ }
158+ })
159+}
160+
161+func TestExpandHome(t *testing.T) {
162+ home, err := os.UserHomeDir()
163+ if err != nil {
164+ t.Skip("cannot get home directory")
165+ }
166+
167+ tests := []struct {
168+ name string
169+ input string
170+ want string
171+ }{
172+ {
173+ name: "tilde expansion",
174+ input: "~/Documents",
175+ want: filepath.Join(home, "Documents"),
176+ },
177+ {
178+ name: "no tilde unchanged",
179+ input: "/absolute/path",
180+ want: "/absolute/path",
181+ },
182+ {
183+ name: "relative path unchanged",
184+ input: "./relative/path",
185+ want: "./relative/path",
186+ },
187+ {
188+ name: "tilde in middle unchanged",
189+ input: "/path/~/file",
190+ want: "/path/~/file",
191+ },
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ got := expandHome(tt.input)
197+ if got != tt.want {
198+ t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
199+ }
200+ })
201+ }
202+}
203+
204+func TestLooksLikeURL(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ input string
208+ want bool
209+ }{
210+ {
211+ name: "empty string",
212+ input: "",
213+ want: false,
214+ },
215+ {
216+ name: "http URL",
217+ input: "http://example.com",
218+ want: true,
219+ },
220+ {
221+ name: "https URL",
222+ input: "https://example.com",
223+ want: true,
224+ },
225+ {
226+ name: "file URL",
227+ input: "file:///path/to/file",
228+ want: true,
229+ },
230+ {
231+ name: "dumb URL",
232+ input: "dumb://home",
233+ want: true,
234+ },
235+ {
236+ name: "about URL",
237+ input: "about:blank",
238+ want: true,
239+ },
240+ {
241+ name: "domain",
242+ input: "example.com",
243+ want: true,
244+ },
245+ {
246+ name: "domain with path",
247+ input: "example.com/path",
248+ want: true,
249+ },
250+ {
251+ name: "search query",
252+ input: "hello world",
253+ want: false,
254+ },
255+ {
256+ name: "single word",
257+ input: "hello",
258+ want: false,
259+ },
260+ }
261+
262+ for _, tt := range tests {
263+ t.Run(tt.name, func(t *testing.T) {
264+ got := LooksLikeURL(tt.input)
265+ if got != tt.want {
266+ t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
267+ }
268+ })
269+ }
270+}
@@ -0,0 +1,270 @@
1+package url
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestNormalize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ want: "",
19+ },
20+ {
21+ name: "http scheme unchanged",
22+ input: "http://example.com",
23+ want: "http://example.com",
24+ },
25+ {
26+ name: "https scheme unchanged",
27+ input: "https://example.com",
28+ want: "https://example.com",
29+ },
30+ {
31+ name: "file scheme unchanged",
32+ input: "file:///path/to/file.html",
33+ want: "file:///path/to/file.html",
34+ },
35+ {
36+ name: "dumb scheme unchanged",
37+ input: "dumb://home",
38+ want: "dumb://home",
39+ },
40+ {
41+ name: "about scheme unchanged",
42+ input: "about:blank",
43+ want: "about:blank",
44+ },
45+ {
46+ name: "domain gets https",
47+ input: "example.com",
48+ want: "https://example.com",
49+ },
50+ {
51+ name: "domain with path gets https",
52+ input: "example.com/path",
53+ want: "https://example.com/path",
54+ },
55+ {
56+ name: "search query unchanged",
57+ input: "hello world",
58+ want: "hello world",
59+ },
60+ {
61+ name: "single word unchanged",
62+ input: "hello",
63+ want: "hello",
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ got := Normalize(tt.input)
70+ if got != tt.want {
71+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
72+ }
73+ })
74+ }
75+}
76+
77+func TestNormalize_LocalFiles(t *testing.T) {
78+ // Create a temporary file for testing
79+ tmpDir := t.TempDir()
80+ tmpFile := filepath.Join(tmpDir, "test.html")
81+ if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
82+ t.Fatalf("failed to create temp file: %v", err)
83+ }
84+
85+ // Create a subdirectory with a file
86+ subDir := filepath.Join(tmpDir, "subdir")
87+ if err := os.Mkdir(subDir, 0755); err != nil {
88+ t.Fatalf("failed to create subdir: %v", err)
89+ }
90+ subFile := filepath.Join(subDir, "page.html")
91+ if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
92+ t.Fatalf("failed to create subdir file: %v", err)
93+ }
94+
95+ tests := []struct {
96+ name string
97+ input string
98+ want string
99+ }{
100+ {
101+ name: "absolute path to existing file",
102+ input: tmpFile,
103+ want: "file://" + tmpFile,
104+ },
105+ {
106+ name: "absolute path to existing directory",
107+ input: tmpDir,
108+ want: "file://" + tmpDir,
109+ },
110+ {
111+ name: "non-existent absolute path returned unchanged",
112+ input: "/nonexistent/file.html",
113+ want: "/nonexistent/file.html",
114+ },
115+ {
116+ name: "non-existent relative path with dot prefix returned unchanged",
117+ input: "./nonexistent/file.html",
118+ want: "./nonexistent/file.html",
119+ },
120+ {
121+ name: "non-existent home path returned unchanged",
122+ input: "~/nonexistent/file.html",
123+ want: "~/nonexistent/file.html",
124+ },
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ got := Normalize(tt.input)
130+ if got != tt.want {
131+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
132+ }
133+ })
134+ }
135+}
136+
137+func TestNormalize_RelativePaths(t *testing.T) {
138+ // Create a temp file in current directory
139+ tmpFile, err := os.CreateTemp(".", "test-*.html")
140+ if err != nil {
141+ t.Fatalf("failed to create temp file: %v", err)
142+ }
143+ tmpFile.Close()
144+ defer os.Remove(tmpFile.Name())
145+
146+ // Get absolute path for comparison
147+ absPath, err := filepath.Abs(tmpFile.Name())
148+ if err != nil {
149+ t.Fatalf("failed to get abs path: %v", err)
150+ }
151+
152+ t.Run("relative path to existing file", func(t *testing.T) {
153+ got := Normalize(tmpFile.Name())
154+ want := "file://" + absPath
155+ if got != want {
156+ t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
157+ }
158+ })
159+}
160+
161+func TestExpandHome(t *testing.T) {
162+ home, err := os.UserHomeDir()
163+ if err != nil {
164+ t.Skip("cannot get home directory")
165+ }
166+
167+ tests := []struct {
168+ name string
169+ input string
170+ want string
171+ }{
172+ {
173+ name: "tilde expansion",
174+ input: "~/Documents",
175+ want: filepath.Join(home, "Documents"),
176+ },
177+ {
178+ name: "no tilde unchanged",
179+ input: "/absolute/path",
180+ want: "/absolute/path",
181+ },
182+ {
183+ name: "relative path unchanged",
184+ input: "./relative/path",
185+ want: "./relative/path",
186+ },
187+ {
188+ name: "tilde in middle unchanged",
189+ input: "/path/~/file",
190+ want: "/path/~/file",
191+ },
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ got := expandHome(tt.input)
197+ if got != tt.want {
198+ t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
199+ }
200+ })
201+ }
202+}
203+
204+func TestLooksLikeURL(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ input string
208+ want bool
209+ }{
210+ {
211+ name: "empty string",
212+ input: "",
213+ want: false,
214+ },
215+ {
216+ name: "http URL",
217+ input: "http://example.com",
218+ want: true,
219+ },
220+ {
221+ name: "https URL",
222+ input: "https://example.com",
223+ want: true,
224+ },
225+ {
226+ name: "file URL",
227+ input: "file:///path/to/file",
228+ want: true,
229+ },
230+ {
231+ name: "dumb URL",
232+ input: "dumb://home",
233+ want: true,
234+ },
235+ {
236+ name: "about URL",
237+ input: "about:blank",
238+ want: true,
239+ },
240+ {
241+ name: "domain",
242+ input: "example.com",
243+ want: true,
244+ },
245+ {
246+ name: "domain with path",
247+ input: "example.com/path",
248+ want: true,
249+ },
250+ {
251+ name: "search query",
252+ input: "hello world",
253+ want: false,
254+ },
255+ {
256+ name: "single word",
257+ input: "hello",
258+ want: false,
259+ },
260+ }
261+
262+ for _, tt := range tests {
263+ t.Run(tt.name, func(t *testing.T) {
264+ got := LooksLikeURL(tt.input)
265+ if got != tt.want {
266+ t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
267+ }
268+ })
269+ }
270+}
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 +154 -0
@@ -0,0 +1,154 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/jwijenbergh/puregotk/v4/glib"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+const (
14+ // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
15+ linkStatusShowDelayMs = 100
16+ // linkStatusMaxChars is the maximum characters before truncation.
17+ linkStatusMaxChars = 80
18+)
19+
20+// LinkStatusOverlay displays the destination URL when hovering over links.
21+// It appears in the bottom-left corner with a fade-in/fade-out transition.
22+type LinkStatusOverlay struct {
23+ factory layout.WidgetFactory
24+ container layout.BoxWidget
25+ label layout.LabelWidget
26+
27+ pendingURI string // URI waiting to be shown after delay
28+ showTimer uint // GLib timer for delayed show
29+ visible bool // Whether overlay is currently visible (has .visible class)
30+ mu sync.Mutex
31+}
32+
33+// NewLinkStatusOverlay creates a new link status overlay component.
34+// The overlay is positioned at bottom-left with fade transitions via CSS.
35+func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
36+ // Create container box for styling and positioning
37+ container := factory.NewBox(layout.OrientationHorizontal, 0)
38+ container.AddCssClass("link-status")
39+
40+ // Position at bottom-left
41+ container.SetHalign(gtk.AlignStartValue)
42+ container.SetValign(gtk.AlignEndValue)
43+
44+ // Don't expand to fill space
45+ container.SetHexpand(false)
46+ container.SetVexpand(false)
47+
48+ // Don't intercept pointer events - let clicks pass through
49+ container.SetCanTarget(false)
50+ container.SetCanFocus(false)
51+
52+ // Create label for URL text
53+ label := factory.NewLabel("")
54+ label.SetCanTarget(false)
55+ label.SetCanFocus(false)
56+ label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs
57+ label.SetMaxWidthChars(linkStatusMaxChars)
58+ container.Append(label)
59+
60+ return &LinkStatusOverlay{
61+ factory: factory,
62+ container: container,
63+ label: label,
64+ visible: false,
65+ }
66+}
67+
68+// Show displays the link status overlay with the given URI.
69+// If uri is empty, hides the overlay instead.
70+// Uses a small delay to avoid flicker during rapid mouse movement.
71+func (l *LinkStatusOverlay) Show(uri string) {
72+ l.mu.Lock()
73+ defer l.mu.Unlock()
74+
75+ // Cancel any pending show timer
76+ if l.showTimer != 0 {
77+ glib.SourceRemove(l.showTimer)
78+ l.showTimer = 0
79+ }
80+
81+ if uri == "" {
82+ l.hide()
83+ return
84+ }
85+
86+ l.pendingURI = uri
87+
88+ // Start delay timer before showing
89+ cb := glib.SourceFunc(func(_ uintptr) bool {
90+ l.mu.Lock()
91+ defer l.mu.Unlock()
92+
93+ if l.pendingURI != "" {
94+ l.label.SetText(l.pendingURI)
95+ if !l.visible {
96+ l.visible = true
97+ l.container.AddCssClass("visible")
98+ }
99+ }
100+ l.showTimer = 0
101+ return false // Don't repeat
102+ })
103+
104+ l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0)
105+}
106+
107+// Hide manually hides the link status overlay.
108+func (l *LinkStatusOverlay) Hide() {
109+ l.mu.Lock()
110+ defer l.mu.Unlock()
111+ l.hide()
112+}
113+
114+// hide performs the actual hide operation (must be called with lock held).
115+func (l *LinkStatusOverlay) hide() {
116+ // Cancel pending show timer
117+ if l.showTimer != 0 {
118+ glib.SourceRemove(l.showTimer)
119+ l.showTimer = 0
120+ }
121+ l.pendingURI = ""
122+
123+ if l.visible {
124+ l.visible = false
125+ l.container.RemoveCssClass("visible")
126+ }
127+}
128+
129+// Widget returns the underlying widget for embedding in overlays.
130+func (l *LinkStatusOverlay) Widget() layout.Widget {
131+ return l.container
132+}
133+
134+// IsVisible returns whether the overlay is currently visible.
135+func (l *LinkStatusOverlay) IsVisible() bool {
136+ l.mu.Lock()
137+ defer l.mu.Unlock()
138+ return l.visible
139+}
140+
141+// Cleanup cancels any pending timers and clears state.
142+// Must be called before removing the overlay from the UI.
143+func (l *LinkStatusOverlay) Cleanup() {
144+ l.mu.Lock()
145+ defer l.mu.Unlock()
146+
147+ // Cancel pending show timer to prevent use-after-free
148+ if l.showTimer != 0 {
149+ glib.SourceRemove(l.showTimer)
150+ l.showTimer = 0
151+ }
152+ l.pendingURI = ""
153+ l.visible = false
154+}
@@ -0,0 +1,154 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/jwijenbergh/puregotk/v4/glib"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+const (
14+ // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
15+ linkStatusShowDelayMs = 100
16+ // linkStatusMaxChars is the maximum characters before truncation.
17+ linkStatusMaxChars = 80
18+)
19+
20+// LinkStatusOverlay displays the destination URL when hovering over links.
21+// It appears in the bottom-left corner with a fade-in/fade-out transition.
22+type LinkStatusOverlay struct {
23+ factory layout.WidgetFactory
24+ container layout.BoxWidget
25+ label layout.LabelWidget
26+
27+ pendingURI string // URI waiting to be shown after delay
28+ showTimer uint // GLib timer for delayed show
29+ visible bool // Whether overlay is currently visible (has .visible class)
30+ mu sync.Mutex
31+}
32+
33+// NewLinkStatusOverlay creates a new link status overlay component.
34+// The overlay is positioned at bottom-left with fade transitions via CSS.
35+func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
36+ // Create container box for styling and positioning
37+ container := factory.NewBox(layout.OrientationHorizontal, 0)
38+ container.AddCssClass("link-status")
39+
40+ // Position at bottom-left
41+ container.SetHalign(gtk.AlignStartValue)
42+ container.SetValign(gtk.AlignEndValue)
43+
44+ // Don't expand to fill space
45+ container.SetHexpand(false)
46+ container.SetVexpand(false)
47+
48+ // Don't intercept pointer events - let clicks pass through
49+ container.SetCanTarget(false)
50+ container.SetCanFocus(false)
51+
52+ // Create label for URL text
53+ label := factory.NewLabel("")
54+ label.SetCanTarget(false)
55+ label.SetCanFocus(false)
56+ label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs
57+ label.SetMaxWidthChars(linkStatusMaxChars)
58+ container.Append(label)
59+
60+ return &LinkStatusOverlay{
61+ factory: factory,
62+ container: container,
63+ label: label,
64+ visible: false,
65+ }
66+}
67+
68+// Show displays the link status overlay with the given URI.
69+// If uri is empty, hides the overlay instead.
70+// Uses a small delay to avoid flicker during rapid mouse movement.
71+func (l *LinkStatusOverlay) Show(uri string) {
72+ l.mu.Lock()
73+ defer l.mu.Unlock()
74+
75+ // Cancel any pending show timer
76+ if l.showTimer != 0 {
77+ glib.SourceRemove(l.showTimer)
78+ l.showTimer = 0
79+ }
80+
81+ if uri == "" {
82+ l.hide()
83+ return
84+ }
85+
86+ l.pendingURI = uri
87+
88+ // Start delay timer before showing
89+ cb := glib.SourceFunc(func(_ uintptr) bool {
90+ l.mu.Lock()
91+ defer l.mu.Unlock()
92+
93+ if l.pendingURI != "" {
94+ l.label.SetText(l.pendingURI)
95+ if !l.visible {
96+ l.visible = true
97+ l.container.AddCssClass("visible")
98+ }
99+ }
100+ l.showTimer = 0
101+ return false // Don't repeat
102+ })
103+
104+ l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0)
105+}
106+
107+// Hide manually hides the link status overlay.
108+func (l *LinkStatusOverlay) Hide() {
109+ l.mu.Lock()
110+ defer l.mu.Unlock()
111+ l.hide()
112+}
113+
114+// hide performs the actual hide operation (must be called with lock held).
115+func (l *LinkStatusOverlay) hide() {
116+ // Cancel pending show timer
117+ if l.showTimer != 0 {
118+ glib.SourceRemove(l.showTimer)
119+ l.showTimer = 0
120+ }
121+ l.pendingURI = ""
122+
123+ if l.visible {
124+ l.visible = false
125+ l.container.RemoveCssClass("visible")
126+ }
127+}
128+
129+// Widget returns the underlying widget for embedding in overlays.
130+func (l *LinkStatusOverlay) Widget() layout.Widget {
131+ return l.container
132+}
133+
134+// IsVisible returns whether the overlay is currently visible.
135+func (l *LinkStatusOverlay) IsVisible() bool {
136+ l.mu.Lock()
137+ defer l.mu.Unlock()
138+ return l.visible
139+}
140+
141+// Cleanup cancels any pending timers and clears state.
142+// Must be called before removing the overlay from the UI.
143+func (l *LinkStatusOverlay) Cleanup() {
144+ l.mu.Lock()
145+ defer l.mu.Unlock()
146+
147+ // Cancel pending show timer to prevent use-after-free
148+ if l.showTimer != 0 {
149+ glib.SourceRemove(l.showTimer)
150+ l.showTimer = 0
151+ }
152+ l.pendingURI = ""
153+ l.visible = false
154+}
M internal/ui/component/pane_view.go +46 -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,9 @@ func (pv *PaneView) Cleanup() {
383420 pv.overlay.RemoveOverlay(pv.toaster.Widget())
384421 pv.toaster = nil
385422 }
423+ if pv.linkStatus != nil {
424+ pv.linkStatus.Cleanup() // Cancel pending timers before removal
425+ pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
426+ pv.linkStatus = nil
427+ }
386428 }
@@ -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,9 @@ 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.linkStatus.Cleanup() // Cancel pending timers before removal
425+ pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
426+ pv.linkStatus = nil
427+ }
386 }428 }
M internal/ui/component/progress_bar.go +6 -2
@@ -43,12 +43,16 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
4343 progressBar.SetHalign(gtk.AlignFillValue)
4444 progressBar.SetHexpand(true)
4545
46+ // Set minimum size to prevent GTK warning about negative minimum width (-2)
47+ // The internal "progress" gizmo needs valid dimensions before realization
48+ // Using 0 for width (not -1) ensures GTK doesn't calculate negative sizes
49+ progressBar.SetSizeRequest(0, 4)
50+
4651 // Don't intercept pointer events - let clicks pass through to WebView
4752 progressBar.SetCanTarget(false)
4853 progressBar.SetCanFocus(false)
4954
50- // Initialize fraction to 0 to force proper sizing of internal progress gizmo
51- // This prevents GTK warning about negative minimum width
55+ // Initialize fraction to 0
5256 progressBar.SetFraction(0)
5357
5458 // Hidden by default
@@ -43,12 +43,16 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
43 progressBar.SetHalign(gtk.AlignFillValue)43 progressBar.SetHalign(gtk.AlignFillValue)
44 progressBar.SetHexpand(true)44 progressBar.SetHexpand(true)
45 45
46+ // Set minimum size to prevent GTK warning about negative minimum width (-2)
47+ // The internal "progress" gizmo needs valid dimensions before realization
48+ // Using 0 for width (not -1) ensures GTK doesn't calculate negative sizes
49+ progressBar.SetSizeRequest(0, 4)
50+
46 // Don't intercept pointer events - let clicks pass through to WebView51 // Don't intercept pointer events - let clicks pass through to WebView
47 progressBar.SetCanTarget(false)52 progressBar.SetCanTarget(false)
48 progressBar.SetCanFocus(false)53 progressBar.SetCanFocus(false)
49 54
50- // Initialize fraction to 0 to force proper sizing of internal progress gizmo55+ // Initialize fraction to 0
51- // This prevents GTK warning about negative minimum width
52 progressBar.SetFraction(0)56 progressBar.SetFraction(0)
53 57
54 // Hidden by default58 // Hidden by default
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 +47 -7
@@ -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
@@ -236,7 +240,7 @@ entry.omnibox-entry:focus-visible {
236240 border-color: var(--accent);
237241 background-color: shade(var(--bg), 1.05);
238242 outline-style: none;
239- outline-width: 0;
243+ outline-width: 0px;
240244 outline-color: transparent;
241245 }
242246
@@ -348,7 +352,7 @@ entry.omnibox-entry > *:focus-visible {
348352 padding: 0.125em 0.5em;
349353 font-size: 0.6875em;
350354 font-weight: 600;
351- margin-left: auto;
355+ /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
352356 }
353357 `
354358 }
@@ -389,7 +393,7 @@ entry.find-bar-entry:focus-within,
389393 entry.find-bar-entry:focus-visible {
390394 border-color: var(--accent);
391395 outline-style: none;
392- outline-width: 0;
396+ outline-width: 0px;
393397 outline-color: transparent;
394398 }
395399
@@ -545,13 +549,17 @@ progressbar.osd {
545549
546550 progressbar.osd trough {
547551 min-height: 4px;
548- min-width: 0px;
552+ min-width: 2px;
553+ margin: 0;
554+ padding: 0;
549555 background-color: alpha(var(--bg), 0.3);
550556 }
551557
552558 progressbar.osd progress {
553559 min-height: 4px;
554- min-width: 0px;
560+ min-width: 2px;
561+ margin: 0;
562+ padding: 0;
555563 background-color: var(--accent);
556564 }
557565 `
@@ -574,7 +582,7 @@ func generateSessionManagerCSS(p Palette) string {
574582 border-radius: 0.1875em;
575583 padding: 0;
576584 min-width: 28em;
577- max-width: 40em;
585+ /* Note: max-width not supported in GTK4 CSS, rely on container constraints */
578586 }
579587
580588 /* Header with title */
@@ -685,7 +693,7 @@ row:selected .session-manager-row {
685693 .session-time {
686694 font-size: 0.75em;
687695 color: var(--muted);
688- margin-left: auto;
696+ /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
689697 }
690698
691699 /* Section divider for EXITED sessions */
@@ -761,3 +769,35 @@ func generateToasterCSS(p Palette) string {
761769 }
762770 `
763771 }
772+
773+// generateLinkStatusCSS creates link status overlay styles.
774+// Uses CSS transitions for fade-in/fade-out effect.
775+func generateLinkStatusCSS(p Palette) string {
776+ return `/* ===== Link Status Overlay Styling ===== */
777+
778+/* Link status container - bottom left positioning */
779+.link-status {
780+ background-color: alpha(var(--surface-variant), 0.95);
781+ border-radius: 0.25em 0.25em 0 0;
782+ padding: 0.25em 0.5em;
783+ margin: 0;
784+ font-size: 0.75em;
785+ /* Note: max-width not supported in GTK4 CSS, use label SetMaxWidthChars instead */
786+ box-shadow: 0 -1px 4px alpha(black, 0.15);
787+
788+ /* Fade transition - hidden by default */
789+ opacity: 0;
790+ transition: opacity 150ms ease-in-out;
791+}
792+
793+/* Visible state - triggered by adding .visible class */
794+.link-status.visible {
795+ opacity: 1;
796+}
797+
798+/* Link status label text */
799+.link-status label {
800+ color: var(--muted);
801+}
802+`
803+}
@@ -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
@@ -236,7 +240,7 @@ entry.omnibox-entry:focus-visible {
236 border-color: var(--accent);240 border-color: var(--accent);
237 background-color: shade(var(--bg), 1.05);241 background-color: shade(var(--bg), 1.05);
238 outline-style: none;242 outline-style: none;
239- outline-width: 0;243+ outline-width: 0px;
240 outline-color: transparent;244 outline-color: transparent;
241 }245 }
242 246
@@ -348,7 +352,7 @@ entry.omnibox-entry > *:focus-visible {
348 padding: 0.125em 0.5em;352 padding: 0.125em 0.5em;
349 font-size: 0.6875em;353 font-size: 0.6875em;
350 font-weight: 600;354 font-weight: 600;
351- margin-left: auto;355+ /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
352 }356 }
353 `357 `
354 }358 }
@@ -389,7 +393,7 @@ entry.find-bar-entry:focus-within,
389 entry.find-bar-entry:focus-visible {393 entry.find-bar-entry:focus-visible {
390 border-color: var(--accent);394 border-color: var(--accent);
391 outline-style: none;395 outline-style: none;
392- outline-width: 0;396+ outline-width: 0px;
393 outline-color: transparent;397 outline-color: transparent;
394 }398 }
395 399
@@ -545,13 +549,17 @@ progressbar.osd {
545 549
546 progressbar.osd trough {550 progressbar.osd trough {
547 min-height: 4px;551 min-height: 4px;
548- min-width: 0px;552+ min-width: 2px;
553+ margin: 0;
554+ padding: 0;
549 background-color: alpha(var(--bg), 0.3);555 background-color: alpha(var(--bg), 0.3);
550 }556 }
551 557
552 progressbar.osd progress {558 progressbar.osd progress {
553 min-height: 4px;559 min-height: 4px;
554- min-width: 0px;560+ min-width: 2px;
561+ margin: 0;
562+ padding: 0;
555 background-color: var(--accent);563 background-color: var(--accent);
556 }564 }
557 `565 `
@@ -574,7 +582,7 @@ func generateSessionManagerCSS(p Palette) string {
574 border-radius: 0.1875em;582 border-radius: 0.1875em;
575 padding: 0;583 padding: 0;
576 min-width: 28em;584 min-width: 28em;
577- max-width: 40em;585+ /* Note: max-width not supported in GTK4 CSS, rely on container constraints */
578 }586 }
579 587
580 /* Header with title */588 /* Header with title */
@@ -685,7 +693,7 @@ row:selected .session-manager-row {
685 .session-time {693 .session-time {
686 font-size: 0.75em;694 font-size: 0.75em;
687 color: var(--muted);695 color: var(--muted);
688- margin-left: auto;696+ /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
689 }697 }
690 698
691 /* Section divider for EXITED sessions */699 /* Section divider for EXITED sessions */
@@ -761,3 +769,35 @@ func generateToasterCSS(p Palette) string {
761 }769 }
762 `770 `
763 }771 }
772+
773+// generateLinkStatusCSS creates link status overlay styles.
774+// Uses CSS transitions for fade-in/fade-out effect.
775+func generateLinkStatusCSS(p Palette) string {
776+ return `/* ===== Link Status Overlay Styling ===== */
777+
778+/* Link status container - bottom left positioning */
779+.link-status {
780+ background-color: alpha(var(--surface-variant), 0.95);
781+ border-radius: 0.25em 0.25em 0 0;
782+ padding: 0.25em 0.5em;
783+ margin: 0;
784+ font-size: 0.75em;
785+ /* Note: max-width not supported in GTK4 CSS, use label SetMaxWidthChars instead */
786+ box-shadow: 0 -1px 4px alpha(black, 0.15);
787+
788+ /* Fade transition - hidden by default */
789+ opacity: 0;
790+ transition: opacity 150ms ease-in-out;
791+}
792+
793+/* Visible state - triggered by adding .visible class */
794+.link-status.visible {
795+ opacity: 1;
796+}
797+
798+/* Link status label text */
799+.link-status label {
800+ color: var(--muted);
801+}
802+`
803+}

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