detached c9e4be8

fix: add auto-copy on selection, link overlay auto-hide, multi-word search

commit
c9e4be82d41c97d651c0a37a27ae5c05730fcb06
author
brice · · 2026-01-01 13:42 UTC
parent
e79512f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +526 -40
M CHANGELOG.md +3 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
89
910 ### Changed
1011 - **Flatpak runtime**: Bumped to GNOME 49 runtime.
@@ -14,6 +15,8 @@ All notable changes to this project will be documented in this file.
1415 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
1516 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.
1617 - **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
18+- **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82.
19+- **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). Fixes #87.
1720
1821 ## [0.23.2] - 2025-12-31
1922
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
8 9
9 ### Changed10 ### Changed
10 - **Flatpak runtime**: Bumped to GNOME 49 runtime.11 - **Flatpak runtime**: Bumped to GNOME 49 runtime.
@@ -14,6 +15,8 @@ All notable changes to this project will be documented in this file.
14 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.15 - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
15 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.16 - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus.
16 - **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).17 - **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
18+- **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82.
19+- **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). Fixes #87.
17 20
18 ## [0.23.2] - 2025-12-3121 ## [0.23.2] - 2025-12-31
19 22
M internal/bootstrap/webkit_stack.go +5 -0
@@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
120120 settings := webkit.NewSettingsManager(ctx, cfg)
121121 injector := webkit.NewContentInjector(colorResolver)
122122
123+ // Set up auto-copy on selection config getter
124+ injector.SetAutoCopyConfigGetter(func() bool {
125+ return config.Get().Clipboard.AutoCopyOnSelection
126+ })
127+
123128 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
124129 themeCSSText := themeManager.GetWebUIThemeCSS()
125130 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
120 settings := webkit.NewSettingsManager(ctx, cfg)120 settings := webkit.NewSettingsManager(ctx, cfg)
121 injector := webkit.NewContentInjector(colorResolver)121 injector := webkit.NewContentInjector(colorResolver)
122 122
123+ // Set up auto-copy on selection config getter
124+ injector.SetAutoCopyConfigGetter(func() bool {
125+ return config.Get().Clipboard.AutoCopyOnSelection
126+ })
127+
123 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)128 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
124 themeCSSText := themeManager.GetWebUIThemeCSS()129 themeCSSText := themeManager.GetWebUIThemeCSS()
125 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {130 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
M internal/infrastructure/config/defaults.go +3 -0
@@ -253,6 +253,9 @@ func DefaultConfig() *Config {
253253 Enabled: true, // Ad blocking enabled by default
254254 AutoUpdate: true, // Auto-update filters from GitHub releases
255255 },
256+ Clipboard: ClipboardConfig{
257+ AutoCopyOnSelection: true, // Enabled by default (zellij-style)
258+ },
256259 Omnibox: OmniboxConfig{
257260 InitialBehavior: defaultOmniboxInitialBehavior,
258261 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
@@ -253,6 +253,9 @@ func DefaultConfig() *Config {
253 Enabled: true, // Ad blocking enabled by default253 Enabled: true, // Ad blocking enabled by default
254 AutoUpdate: true, // Auto-update filters from GitHub releases254 AutoUpdate: true, // Auto-update filters from GitHub releases
255 },255 },
256+ Clipboard: ClipboardConfig{
257+ AutoCopyOnSelection: true, // Enabled by default (zellij-style)
258+ },
256 Omnibox: OmniboxConfig{259 Omnibox: OmniboxConfig{
257 InitialBehavior: defaultOmniboxInitialBehavior,260 InitialBehavior: defaultOmniboxInitialBehavior,
258 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,261 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
M internal/infrastructure/config/loader.go +5 -0
@@ -328,6 +328,7 @@ func (m *Manager) setDefaults() {
328328 m.setRenderingDefaults(defaults)
329329 m.setWorkspaceDefaults(defaults)
330330 m.setContentFilteringDefaults(defaults)
331+ m.setClipboardDefaults(defaults)
331332 m.setOmniboxDefaults(defaults)
332333 m.setMediaDefaults(defaults)
333334 m.setRuntimeDefaults(defaults)
@@ -443,6 +444,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
443444 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
444445 }
445446
447+func (m *Manager) setClipboardDefaults(defaults *Config) {
448+ m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
449+}
450+
446451 func (m *Manager) setOmniboxDefaults(defaults *Config) {
447452 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448453 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
@@ -328,6 +328,7 @@ func (m *Manager) setDefaults() {
328 m.setRenderingDefaults(defaults)328 m.setRenderingDefaults(defaults)
329 m.setWorkspaceDefaults(defaults)329 m.setWorkspaceDefaults(defaults)
330 m.setContentFilteringDefaults(defaults)330 m.setContentFilteringDefaults(defaults)
331+ m.setClipboardDefaults(defaults)
331 m.setOmniboxDefaults(defaults)332 m.setOmniboxDefaults(defaults)
332 m.setMediaDefaults(defaults)333 m.setMediaDefaults(defaults)
333 m.setRuntimeDefaults(defaults)334 m.setRuntimeDefaults(defaults)
@@ -443,6 +444,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
443 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)444 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
444 }445 }
445 446
447+func (m *Manager) setClipboardDefaults(defaults *Config) {
448+ m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
449+}
450+
446 func (m *Manager) setOmniboxDefaults(defaults *Config) {451 func (m *Manager) setOmniboxDefaults(defaults *Config) {
447 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)452 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)453 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
M internal/infrastructure/config/schema.go +11 -0
@@ -21,6 +21,8 @@ type Config struct {
2121 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
2222 // ContentFiltering controls ad blocking and content filtering
2323 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
24+ // Clipboard controls clipboard-related behavior
25+ Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"`
2426 // Omnibox controls the omnibox behavior (initial history display)
2527 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
2628 // Rendering controls WebKit, GTK and compositor behavior.
@@ -337,6 +339,15 @@ type ContentFilteringConfig struct {
337339 // Note: Whitelist is managed via database (content_whitelist table)
338340 }
339341
342+// ClipboardConfig holds clipboard-related behavior preferences
343+type ClipboardConfig struct {
344+ // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style).
345+ // When enabled, selecting text in a webview immediately copies it to clipboard.
346+ // Does not apply to text selection in input fields or textareas.
347+ // Default: true
348+ AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line
349+}
350+
340351 // OmniboxConfig holds omnibox behavior preferences
341352 type OmniboxConfig struct {
342353 // InitialBehavior controls what to show when omnibox opens with empty input
@@ -21,6 +21,8 @@ type Config struct {
21 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`21 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
22 // ContentFiltering controls ad blocking and content filtering22 // ContentFiltering controls ad blocking and content filtering
23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
24+ // Clipboard controls clipboard-related behavior
25+ Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"`
24 // Omnibox controls the omnibox behavior (initial history display)26 // Omnibox controls the omnibox behavior (initial history display)
25 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
26 // Rendering controls WebKit, GTK and compositor behavior.28 // Rendering controls WebKit, GTK and compositor behavior.
@@ -337,6 +339,15 @@ type ContentFilteringConfig struct {
337 // Note: Whitelist is managed via database (content_whitelist table)339 // Note: Whitelist is managed via database (content_whitelist table)
338 }340 }
339 341
342+// ClipboardConfig holds clipboard-related behavior preferences
343+type ClipboardConfig struct {
344+ // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style).
345+ // When enabled, selecting text in a webview immediately copies it to clipboard.
346+ // Does not apply to text selection in input fields or textareas.
347+ // Default: true
348+ AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line
349+}
350+
340 // OmniboxConfig holds omnibox behavior preferences351 // OmniboxConfig holds omnibox behavior preferences
341 type OmniboxConfig struct {352 type OmniboxConfig struct {
342 // InitialBehavior controls what to show when omnibox opens with empty input353 // InitialBehavior controls what to show when omnibox opens with empty input
M internal/infrastructure/persistence/sqlite/history_repo.go +58 -15
@@ -5,6 +5,7 @@ import (
55 "database/sql"
66 "errors"
77 "fmt"
8+ "strings"
89 "time"
910
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -47,28 +48,70 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
4748 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
4849 log := logging.FromContext(ctx)
4950
50- // Use FTS5 full-text search for better accuracy
51- // FTS5 query syntax: use * for prefix matching
52- ftsQuery := query + "*"
51+ // Split query into words and add prefix matching to each
52+ // This enables multi-word searches like "github issues" -> "github* issues*"
53+ words := strings.Fields(query)
54+ if len(words) == 0 {
55+ return []entity.HistoryMatch{}, nil
56+ }
5357
54- rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{
55- Url: ftsQuery,
58+ // Build FTS5 query: "word1* word2*" (implicit AND between terms)
59+ parts := make([]string, len(words))
60+ for i, word := range words {
61+ parts[i] = word + "*"
62+ }
63+ ftsQuery := strings.Join(parts, " ")
64+
65+ // Search both URL and title columns, then merge results
66+ urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{
67+ Query: ftsQuery,
5668 Limit: int64(limit),
5769 })
58- if err != nil {
59- log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results")
60- // FTS5 returns error on no match or invalid query, return empty results
61- return []entity.HistoryMatch{}, nil
70+ if urlErr != nil {
71+ log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
72+ urlRows = []sqlc.History{}
6273 }
6374
64- matches := make([]entity.HistoryMatch, len(rows))
65- for i := range rows {
66- row := rows[i]
67- matches[i] = entity.HistoryMatch{
68- Entry: historyFromRow(row),
69- Score: 1.0, // FTS5 already ranked by bm25
75+ titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
76+ Query: ftsQuery,
77+ Limit: int64(limit),
78+ })
79+ if titleErr != nil {
80+ log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
81+ titleRows = []sqlc.History{}
82+ }
83+
84+ // Merge results, deduplicating by ID
85+ seen := make(map[int64]bool)
86+ var matches []entity.HistoryMatch
87+
88+ for i := range urlRows {
89+ row := urlRows[i]
90+ if !seen[row.ID] {
91+ seen[row.ID] = true
92+ matches = append(matches, entity.HistoryMatch{
93+ Entry: historyFromRow(row),
94+ Score: 1.0,
95+ })
96+ }
97+ }
98+
99+ for i := range titleRows {
100+ row := titleRows[i]
101+ if !seen[row.ID] {
102+ seen[row.ID] = true
103+ matches = append(matches, entity.HistoryMatch{
104+ Entry: historyFromRow(row),
105+ Score: 1.0,
106+ })
70107 }
71108 }
109+
110+ // Limit results
111+ if len(matches) > limit {
112+ matches = matches[:limit]
113+ }
114+
72115 return matches, nil
73116 }
74117
@@ -5,6 +5,7 @@ import (
5 "database/sql"5 "database/sql"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -47,28 +48,70 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
47 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {48 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
48 log := logging.FromContext(ctx)49 log := logging.FromContext(ctx)
49 50
50- // Use FTS5 full-text search for better accuracy51+ // Split query into words and add prefix matching to each
51- // FTS5 query syntax: use * for prefix matching52+ // This enables multi-word searches like "github issues" -> "github* issues*"
52- ftsQuery := query + "*"53+ words := strings.Fields(query)
54+ if len(words) == 0 {
55+ return []entity.HistoryMatch{}, nil
56+ }
53 57
54- rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{58+ // Build FTS5 query: "word1* word2*" (implicit AND between terms)
55- Url: ftsQuery,59+ parts := make([]string, len(words))
60+ for i, word := range words {
61+ parts[i] = word + "*"
62+ }
63+ ftsQuery := strings.Join(parts, " ")
64+
65+ // Search both URL and title columns, then merge results
66+ urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{
67+ Query: ftsQuery,
56 Limit: int64(limit),68 Limit: int64(limit),
57 })69 })
58- if err != nil {70+ if urlErr != nil {
59- log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results")71+ log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
60- // FTS5 returns error on no match or invalid query, return empty results72+ urlRows = []sqlc.History{}
61- return []entity.HistoryMatch{}, nil
62 }73 }
63 74
64- matches := make([]entity.HistoryMatch, len(rows))75+ titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
65- for i := range rows {76+ Query: ftsQuery,
66- row := rows[i]77+ Limit: int64(limit),
67- matches[i] = entity.HistoryMatch{78+ })
68- Entry: historyFromRow(row),79+ if titleErr != nil {
69- Score: 1.0, // FTS5 already ranked by bm2580+ log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
81+ titleRows = []sqlc.History{}
82+ }
83+
84+ // Merge results, deduplicating by ID
85+ seen := make(map[int64]bool)
86+ var matches []entity.HistoryMatch
87+
88+ for i := range urlRows {
89+ row := urlRows[i]
90+ if !seen[row.ID] {
91+ seen[row.ID] = true
92+ matches = append(matches, entity.HistoryMatch{
93+ Entry: historyFromRow(row),
94+ Score: 1.0,
95+ })
96+ }
97+ }
98+
99+ for i := range titleRows {
100+ row := titleRows[i]
101+ if !seen[row.ID] {
102+ seen[row.ID] = true
103+ matches = append(matches, entity.HistoryMatch{
104+ Entry: historyFromRow(row),
105+ Score: 1.0,
106+ })
70 }107 }
71 }108 }
109+
110+ // Limit results
111+ if len(matches) > limit {
112+ matches = matches[:limit]
113+ }
114+
72 return matches, nil115 return matches, nil
73 }116 }
74 117
M internal/infrastructure/persistence/sqlite/history_repo_test.go +161 -0
@@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
323323 assert.Equal(t, "https://github.com", results[0].URL)
324324 assert.Equal(t, int64(4), results[0].VisitCount)
325325 }
326+
327+func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328+ ctx := historyTestCtx()
329+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
330+
331+ db, err := sqlite.NewConnection(ctx, dbPath)
332+ require.NoError(t, err)
333+ t.Cleanup(func() { _ = db.Close() })
334+
335+ repo := sqlite.NewHistoryRepository(db)
336+
337+ // Save entries
338+ entries := []*entity.HistoryEntry{
339+ {URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340+ {URL: "https://gitlab.com/project", Title: "GitLab Project"},
341+ {URL: "https://example.com", Title: "Example Site"},
342+ }
343+ for _, e := range entries {
344+ require.NoError(t, repo.Save(ctx, e))
345+ }
346+
347+ // Search for "github" - should find the github entry
348+ results, err := repo.Search(ctx, "github", 10)
349+ require.NoError(t, err)
350+ require.Len(t, results, 1)
351+ assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352+}
353+
354+func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355+ ctx := historyTestCtx()
356+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
357+
358+ db, err := sqlite.NewConnection(ctx, dbPath)
359+ require.NoError(t, err)
360+ t.Cleanup(func() { _ = db.Close() })
361+
362+ repo := sqlite.NewHistoryRepository(db)
363+
364+ // Save entries
365+ entries := []*entity.HistoryEntry{
366+ {URL: "https://github.com/issues", Title: "GitHub Issues"},
367+ {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368+ {URL: "https://jira.com/issues", Title: "Jira Issues"},
369+ }
370+ for _, e := range entries {
371+ require.NoError(t, repo.Save(ctx, e))
372+ }
373+
374+ // Search for "github issues" - should find only the github issues entry
375+ results, err := repo.Search(ctx, "github issues", 10)
376+ require.NoError(t, err)
377+ require.Len(t, results, 1)
378+ assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379+}
380+
381+func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382+ ctx := historyTestCtx()
383+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
384+
385+ db, err := sqlite.NewConnection(ctx, dbPath)
386+ require.NoError(t, err)
387+ t.Cleanup(func() { _ = db.Close() })
388+
389+ repo := sqlite.NewHistoryRepository(db)
390+
391+ // Save entry with specific title that doesn't appear in URL
392+ entry := &entity.HistoryEntry{
393+ URL: "https://docs.example.com/guide",
394+ Title: "Comprehensive Documentation Guide",
395+ }
396+ require.NoError(t, repo.Save(ctx, entry))
397+
398+ // Search for "Documentation" - should find by title
399+ results, err := repo.Search(ctx, "Documentation", 10)
400+ require.NoError(t, err)
401+ require.Len(t, results, 1)
402+ assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403+}
404+
405+func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406+ ctx := historyTestCtx()
407+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
408+
409+ db, err := sqlite.NewConnection(ctx, dbPath)
410+ require.NoError(t, err)
411+ t.Cleanup(func() { _ = db.Close() })
412+
413+ repo := sqlite.NewHistoryRepository(db)
414+
415+ // Save an entry
416+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417+ URL: "https://example.com",
418+ Title: "Example",
419+ }))
420+
421+ // Empty query should return empty results, no error
422+ results, err := repo.Search(ctx, "", 10)
423+ require.NoError(t, err)
424+ assert.Empty(t, results)
425+
426+ // Whitespace-only query should also return empty results
427+ results, err = repo.Search(ctx, " ", 10)
428+ require.NoError(t, err)
429+ assert.Empty(t, results)
430+}
431+
432+func TestHistoryRepository_Search_NoResults(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ // Save an entry
443+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444+ URL: "https://example.com",
445+ Title: "Example",
446+ }))
447+
448+ // Search for something that doesn't exist
449+ results, err := repo.Search(ctx, "nonexistent", 10)
450+ require.NoError(t, err)
451+ assert.Empty(t, results)
452+}
453+
454+func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455+ ctx := historyTestCtx()
456+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
457+
458+ db, err := sqlite.NewConnection(ctx, dbPath)
459+ require.NoError(t, err)
460+ t.Cleanup(func() { _ = db.Close() })
461+
462+ repo := sqlite.NewHistoryRepository(db)
463+
464+ // Save entries
465+ entries := []*entity.HistoryEntry{
466+ {URL: "https://github.com", Title: "GitHub"},
467+ {URL: "https://gitlab.com", Title: "GitLab"},
468+ {URL: "https://example.com", Title: "Example"},
469+ }
470+ for _, e := range entries {
471+ require.NoError(t, repo.Save(ctx, e))
472+ }
473+
474+ // Search for "git" - should find both github and gitlab (prefix match)
475+ results, err := repo.Search(ctx, "git", 10)
476+ require.NoError(t, err)
477+ require.Len(t, results, 2)
478+
479+ // Verify both git* entries are found
480+ urls := make(map[string]bool)
481+ for _, r := range results {
482+ urls[r.Entry.URL] = true
483+ }
484+ assert.True(t, urls["https://github.com"])
485+ assert.True(t, urls["https://gitlab.com"])
486+}
@@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
323 assert.Equal(t, "https://github.com", results[0].URL)323 assert.Equal(t, "https://github.com", results[0].URL)
324 assert.Equal(t, int64(4), results[0].VisitCount)324 assert.Equal(t, int64(4), results[0].VisitCount)
325 }325 }
326+
327+func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328+ ctx := historyTestCtx()
329+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
330+
331+ db, err := sqlite.NewConnection(ctx, dbPath)
332+ require.NoError(t, err)
333+ t.Cleanup(func() { _ = db.Close() })
334+
335+ repo := sqlite.NewHistoryRepository(db)
336+
337+ // Save entries
338+ entries := []*entity.HistoryEntry{
339+ {URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340+ {URL: "https://gitlab.com/project", Title: "GitLab Project"},
341+ {URL: "https://example.com", Title: "Example Site"},
342+ }
343+ for _, e := range entries {
344+ require.NoError(t, repo.Save(ctx, e))
345+ }
346+
347+ // Search for "github" - should find the github entry
348+ results, err := repo.Search(ctx, "github", 10)
349+ require.NoError(t, err)
350+ require.Len(t, results, 1)
351+ assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352+}
353+
354+func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355+ ctx := historyTestCtx()
356+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
357+
358+ db, err := sqlite.NewConnection(ctx, dbPath)
359+ require.NoError(t, err)
360+ t.Cleanup(func() { _ = db.Close() })
361+
362+ repo := sqlite.NewHistoryRepository(db)
363+
364+ // Save entries
365+ entries := []*entity.HistoryEntry{
366+ {URL: "https://github.com/issues", Title: "GitHub Issues"},
367+ {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368+ {URL: "https://jira.com/issues", Title: "Jira Issues"},
369+ }
370+ for _, e := range entries {
371+ require.NoError(t, repo.Save(ctx, e))
372+ }
373+
374+ // Search for "github issues" - should find only the github issues entry
375+ results, err := repo.Search(ctx, "github issues", 10)
376+ require.NoError(t, err)
377+ require.Len(t, results, 1)
378+ assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379+}
380+
381+func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382+ ctx := historyTestCtx()
383+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
384+
385+ db, err := sqlite.NewConnection(ctx, dbPath)
386+ require.NoError(t, err)
387+ t.Cleanup(func() { _ = db.Close() })
388+
389+ repo := sqlite.NewHistoryRepository(db)
390+
391+ // Save entry with specific title that doesn't appear in URL
392+ entry := &entity.HistoryEntry{
393+ URL: "https://docs.example.com/guide",
394+ Title: "Comprehensive Documentation Guide",
395+ }
396+ require.NoError(t, repo.Save(ctx, entry))
397+
398+ // Search for "Documentation" - should find by title
399+ results, err := repo.Search(ctx, "Documentation", 10)
400+ require.NoError(t, err)
401+ require.Len(t, results, 1)
402+ assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403+}
404+
405+func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406+ ctx := historyTestCtx()
407+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
408+
409+ db, err := sqlite.NewConnection(ctx, dbPath)
410+ require.NoError(t, err)
411+ t.Cleanup(func() { _ = db.Close() })
412+
413+ repo := sqlite.NewHistoryRepository(db)
414+
415+ // Save an entry
416+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417+ URL: "https://example.com",
418+ Title: "Example",
419+ }))
420+
421+ // Empty query should return empty results, no error
422+ results, err := repo.Search(ctx, "", 10)
423+ require.NoError(t, err)
424+ assert.Empty(t, results)
425+
426+ // Whitespace-only query should also return empty results
427+ results, err = repo.Search(ctx, " ", 10)
428+ require.NoError(t, err)
429+ assert.Empty(t, results)
430+}
431+
432+func TestHistoryRepository_Search_NoResults(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ // Save an entry
443+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444+ URL: "https://example.com",
445+ Title: "Example",
446+ }))
447+
448+ // Search for something that doesn't exist
449+ results, err := repo.Search(ctx, "nonexistent", 10)
450+ require.NoError(t, err)
451+ assert.Empty(t, results)
452+}
453+
454+func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455+ ctx := historyTestCtx()
456+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
457+
458+ db, err := sqlite.NewConnection(ctx, dbPath)
459+ require.NoError(t, err)
460+ t.Cleanup(func() { _ = db.Close() })
461+
462+ repo := sqlite.NewHistoryRepository(db)
463+
464+ // Save entries
465+ entries := []*entity.HistoryEntry{
466+ {URL: "https://github.com", Title: "GitHub"},
467+ {URL: "https://gitlab.com", Title: "GitLab"},
468+ {URL: "https://example.com", Title: "Example"},
469+ }
470+ for _, e := range entries {
471+ require.NoError(t, repo.Save(ctx, e))
472+ }
473+
474+ // Search for "git" - should find both github and gitlab (prefix match)
475+ results, err := repo.Search(ctx, "git", 10)
476+ require.NoError(t, err)
477+ require.Len(t, results, 2)
478+
479+ // Verify both git* entries are found
480+ urls := make(map[string]bool)
481+ for _, r := range results {
482+ urls[r.Entry.URL] = true
483+ }
484+ assert.True(t, urls["https://github.com"])
485+ assert.True(t, urls["https://gitlab.com"])
486+}
M internal/infrastructure/persistence/sqlite/queries/history.sql +12 -4
@@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit
4949 -- name: DeleteHistoryByDomain :exec
5050 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
5151
52--- name: SearchHistoryFTS :many
52+-- name: SearchHistoryFTSUrl :many
5353 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
5454 FROM history_fts fts
5555 JOIN history h ON fts.rowid = h.id
56-WHERE fts.url MATCH ?
57-ORDER BY bm25(history_fts)
58-LIMIT ?;
56+WHERE fts.url MATCH @query
57+ORDER BY h.visit_count DESC, h.last_visited DESC
58+LIMIT @limit;
59+
60+-- name: SearchHistoryFTSTitle :many
61+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
62+FROM history_fts fts
63+JOIN history h ON fts.rowid = h.id
64+WHERE fts.title MATCH @query
65+ORDER BY h.visit_count DESC, h.last_visited DESC
66+LIMIT @limit;
5967
6068 -- name: GetRecentHistorySince :many
6169 SELECT * FROM history
@@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit
49 -- name: DeleteHistoryByDomain :exec49 -- name: DeleteHistoryByDomain :exec
50 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;50 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
51 51
52--- name: SearchHistoryFTS :many52+-- name: SearchHistoryFTSUrl :many
53 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at53 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
54 FROM history_fts fts54 FROM history_fts fts
55 JOIN history h ON fts.rowid = h.id55 JOIN history h ON fts.rowid = h.id
56-WHERE fts.url MATCH ?56+WHERE fts.url MATCH @query
57-ORDER BY bm25(history_fts)57+ORDER BY h.visit_count DESC, h.last_visited DESC
58-LIMIT ?;58+LIMIT @limit;
59+
60+-- name: SearchHistoryFTSTitle :many
61+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
62+FROM history_fts fts
63+JOIN history h ON fts.rowid = h.id
64+WHERE fts.title MATCH @query
65+ORDER BY h.visit_count DESC, h.last_visited DESC
66+LIMIT @limit;
59 67
60 -- name: GetRecentHistorySince :many68 -- name: GetRecentHistorySince :many
61 SELECT * FROM history69 SELECT * FROM history
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +53 -8
@@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
439439 return items, nil
440440 }
441441
442-const SearchHistoryFTS = `-- name: SearchHistoryFTS :many
442+const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
443443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
444444 FROM history_fts fts
445445 JOIN history h ON fts.rowid = h.id
446-WHERE fts.url MATCH ?
447-ORDER BY bm25(history_fts)
448-LIMIT ?
446+WHERE fts.title MATCH ?1
447+ORDER BY h.visit_count DESC, h.last_visited DESC
448+LIMIT ?2
449+`
450+
451+type SearchHistoryFTSTitleParams struct {
452+ Query string `json:"query"`
453+ Limit int64 `json:"limit"`
454+}
455+
456+func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
457+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)
458+ if err != nil {
459+ return nil, err
460+ }
461+ defer rows.Close()
462+ items := []History{}
463+ for rows.Next() {
464+ var i History
465+ if err := rows.Scan(
466+ &i.ID,
467+ &i.Url,
468+ &i.Title,
469+ &i.FaviconUrl,
470+ &i.VisitCount,
471+ &i.LastVisited,
472+ &i.CreatedAt,
473+ ); err != nil {
474+ return nil, err
475+ }
476+ items = append(items, i)
477+ }
478+ if err := rows.Close(); err != nil {
479+ return nil, err
480+ }
481+ if err := rows.Err(); err != nil {
482+ return nil, err
483+ }
484+ return items, nil
485+}
486+
487+const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
488+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
489+FROM history_fts fts
490+JOIN history h ON fts.rowid = h.id
491+WHERE fts.url MATCH ?1
492+ORDER BY h.visit_count DESC, h.last_visited DESC
493+LIMIT ?2
449494 `
450495
451-type SearchHistoryFTSParams struct {
452- Url string `json:"url"`
496+type SearchHistoryFTSUrlParams struct {
497+ Query string `json:"query"`
453498 Limit int64 `json:"limit"`
454499 }
455500
456-func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) {
457- rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit)
501+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
502+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
458503 if err != nil {
459504 return nil, err
460505 }
@@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
439 return items, nil439 return items, nil
440 }440 }
441 441
442-const SearchHistoryFTS = `-- name: SearchHistoryFTS :many442+const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
444 FROM history_fts fts444 FROM history_fts fts
445 JOIN history h ON fts.rowid = h.id445 JOIN history h ON fts.rowid = h.id
446-WHERE fts.url MATCH ?446+WHERE fts.title MATCH ?1
447-ORDER BY bm25(history_fts)447+ORDER BY h.visit_count DESC, h.last_visited DESC
448-LIMIT ?448+LIMIT ?2
449+`
450+
451+type SearchHistoryFTSTitleParams struct {
452+ Query string `json:"query"`
453+ Limit int64 `json:"limit"`
454+}
455+
456+func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
457+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)
458+ if err != nil {
459+ return nil, err
460+ }
461+ defer rows.Close()
462+ items := []History{}
463+ for rows.Next() {
464+ var i History
465+ if err := rows.Scan(
466+ &i.ID,
467+ &i.Url,
468+ &i.Title,
469+ &i.FaviconUrl,
470+ &i.VisitCount,
471+ &i.LastVisited,
472+ &i.CreatedAt,
473+ ); err != nil {
474+ return nil, err
475+ }
476+ items = append(items, i)
477+ }
478+ if err := rows.Close(); err != nil {
479+ return nil, err
480+ }
481+ if err := rows.Err(); err != nil {
482+ return nil, err
483+ }
484+ return items, nil
485+}
486+
487+const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
488+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
489+FROM history_fts fts
490+JOIN history h ON fts.rowid = h.id
491+WHERE fts.url MATCH ?1
492+ORDER BY h.visit_count DESC, h.last_visited DESC
493+LIMIT ?2
449 `494 `
450 495
451-type SearchHistoryFTSParams struct {496+type SearchHistoryFTSUrlParams struct {
452- Url string `json:"url"`497+ Query string `json:"query"`
453 Limit int64 `json:"limit"`498 Limit int64 `json:"limit"`
454 }499 }
455 500
456-func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) {501+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
457- rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit)502+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
458 if err != nil {503 if err != nil {
459 return nil, err504 return nil, err
460 }505 }
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
@@ -70,7 +70,8 @@ type Querier interface {
7070 RemoveFromWhitelist(ctx context.Context, domain string) error
7171 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
7272 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
73- SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error)
73+ SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
74+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
7475 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
7576 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
7677 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
@@ -70,7 +70,8 @@ type Querier interface {
70 RemoveFromWhitelist(ctx context.Context, domain string) error70 RemoveFromWhitelist(ctx context.Context, domain string) error
71 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error71 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
72 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)72 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
73- SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error)73+ SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
74+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
74 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error75 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
75 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error76 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
76 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error77 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
A internal/infrastructure/webkit/handlers/clipboard.go +86 -0
@@ -0,0 +1,86 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ClipboardHandler handles clipboard-related messages from webviews.
14+type ClipboardHandler struct {
15+ clipboard port.Clipboard
16+ configGetter func() *config.Config
17+ onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
18+}
19+
20+// NewClipboardHandler creates a new ClipboardHandler.
21+func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler {
22+ return &ClipboardHandler{
23+ clipboard: clipboard,
24+ configGetter: configGetter,
25+ onCopied: onCopied,
26+ }
27+}
28+
29+// autoCopyRequest represents the payload for auto-copy selection messages.
30+type autoCopyRequest struct {
31+ Text string `json:"text"`
32+}
33+
34+// HandleAutoCopySelection handles the auto_copy_selection message from JS.
35+// It copies the selected text to the clipboard if the feature is enabled.
36+func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
37+ return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
38+ log := logging.FromContext(ctx)
39+
40+ // Check if feature is enabled
41+ cfg := h.configGetter()
42+ if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection {
43+ return nil, nil
44+ }
45+
46+ var req autoCopyRequest
47+ if err := json.Unmarshal(payload, &req); err != nil {
48+ log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
49+ return nil, nil // Silently ignore malformed requests
50+ }
51+
52+ // Skip empty or very short selections (< 2 chars)
53+ if len(req.Text) < 2 {
54+ return nil, nil
55+ }
56+
57+ if err := h.clipboard.WriteText(ctx, req.Text); err != nil {
58+ log.Debug().Err(err).Msg("failed to write selection to clipboard")
59+ return nil, nil // Don't propagate error to JS
60+ }
61+
62+ log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
63+
64+ // Notify UI for toast feedback (if callback is set)
65+ if h.onCopied != nil {
66+ h.onCopied(len(req.Text))
67+ }
68+
69+ return nil, nil
70+ })
71+}
72+
73+// RegisterClipboardHandlers registers clipboard handlers with the router.
74+func RegisterClipboardHandlers(ctx context.Context, router *webkit.MessageRouter, clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) error {
75+ handler := NewClipboardHandler(clipboard, configGetter, onCopied)
76+
77+ // Register auto_copy_selection handler
78+ if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
79+ return err
80+ }
81+
82+ log := logging.FromContext(ctx)
83+ log.Info().Msg("registered clipboard handlers")
84+
85+ return nil
86+}
@@ -0,0 +1,86 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ClipboardHandler handles clipboard-related messages from webviews.
14+type ClipboardHandler struct {
15+ clipboard port.Clipboard
16+ configGetter func() *config.Config
17+ onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
18+}
19+
20+// NewClipboardHandler creates a new ClipboardHandler.
21+func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler {
22+ return &ClipboardHandler{
23+ clipboard: clipboard,
24+ configGetter: configGetter,
25+ onCopied: onCopied,
26+ }
27+}
28+
29+// autoCopyRequest represents the payload for auto-copy selection messages.
30+type autoCopyRequest struct {
31+ Text string `json:"text"`
32+}
33+
34+// HandleAutoCopySelection handles the auto_copy_selection message from JS.
35+// It copies the selected text to the clipboard if the feature is enabled.
36+func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
37+ return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
38+ log := logging.FromContext(ctx)
39+
40+ // Check if feature is enabled
41+ cfg := h.configGetter()
42+ if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection {
43+ return nil, nil
44+ }
45+
46+ var req autoCopyRequest
47+ if err := json.Unmarshal(payload, &req); err != nil {
48+ log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
49+ return nil, nil // Silently ignore malformed requests
50+ }
51+
52+ // Skip empty or very short selections (< 2 chars)
53+ if len(req.Text) < 2 {
54+ return nil, nil
55+ }
56+
57+ if err := h.clipboard.WriteText(ctx, req.Text); err != nil {
58+ log.Debug().Err(err).Msg("failed to write selection to clipboard")
59+ return nil, nil // Don't propagate error to JS
60+ }
61+
62+ log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
63+
64+ // Notify UI for toast feedback (if callback is set)
65+ if h.onCopied != nil {
66+ h.onCopied(len(req.Text))
67+ }
68+
69+ return nil, nil
70+ })
71+}
72+
73+// RegisterClipboardHandlers registers clipboard handlers with the router.
74+func RegisterClipboardHandlers(ctx context.Context, router *webkit.MessageRouter, clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) error {
75+ handler := NewClipboardHandler(clipboard, configGetter, onCopied)
76+
77+ // Register auto_copy_selection handler
78+ if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
79+ return err
80+ }
81+
82+ log := logging.FromContext(ctx)
83+ log.Info().Msg("registered clipboard handlers")
84+
85+ return nil
86+}
M internal/infrastructure/webkit/handlers/register.go +14 -2
@@ -3,15 +3,20 @@ package handlers
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/port"
67 "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
79 "github.com/bnema/dumber/internal/infrastructure/webkit"
810 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
911 )
1012
1113 // Config holds all dependencies for message handlers.
1214 type Config struct {
13- HistoryUC *usecase.SearchHistoryUseCase
14- FavoritesUC *usecase.ManageFavoritesUseCase
15+ HistoryUC *usecase.SearchHistoryUseCase
16+ FavoritesUC *usecase.ManageFavoritesUseCase
17+ Clipboard port.Clipboard
18+ ConfigGetter func() *config.Config
19+ OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
1520 }
1621
1722 // RegisterAll registers all message handlers with the router.
@@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
3136 return err
3237 }
3338
39+ // Clipboard handlers (for auto-copy on selection feature)
40+ if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
41+ if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil {
42+ return err
43+ }
44+ }
45+
3446 // Future handler groups go here:
3547 // - settings handlers
3648 // - downloads handlers
@@ -3,15 +3,20 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/port"
6 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
7 "github.com/bnema/dumber/internal/infrastructure/webkit"9 "github.com/bnema/dumber/internal/infrastructure/webkit"
8 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"10 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
9 )11 )
10 12
11 // Config holds all dependencies for message handlers.13 // Config holds all dependencies for message handlers.
12 type Config struct {14 type Config struct {
13- HistoryUC *usecase.SearchHistoryUseCase15+ HistoryUC *usecase.SearchHistoryUseCase
14- FavoritesUC *usecase.ManageFavoritesUseCase16+ FavoritesUC *usecase.ManageFavoritesUseCase
17+ Clipboard port.Clipboard
18+ ConfigGetter func() *config.Config
19+ OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
15 }20 }
16 21
17 // RegisterAll registers all message handlers with the router.22 // RegisterAll registers all message handlers with the router.
@@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
31 return err36 return err
32 }37 }
33 38
39+ // Clipboard handlers (for auto-copy on selection feature)
40+ if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
41+ if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil {
42+ return err
43+ }
44+ }
45+
34 // Future handler groups go here:46 // Future handler groups go here:
35 // - settings handlers47 // - settings handlers
36 // - downloads handlers48 // - downloads handlers
M internal/infrastructure/webkit/injector.go +55 -4
@@ -114,15 +114,44 @@ const internalDarkModeScriptTemplate = `(function() {
114114 // internalPageAllowList restricts script injection to internal dumb:// pages only.
115115 var internalPageAllowList = []string{"dumb://*"}
116116
117+// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118+// It listens for selection changes, debounces them, and posts to the Go message handler.
119+// Skips selections in input fields/textareas and requires minimum 2 characters.
120+const autoCopySelectionScript = `(function() {
121+ var debounceTimer = null;
122+ var MIN_LENGTH = 2;
123+
124+ document.addEventListener('selectionchange', function() {
125+ // Skip if selection is inside input/textarea/contenteditable
126+ var activeEl = document.activeElement;
127+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128+ return;
129+ }
130+
131+ clearTimeout(debounceTimer);
132+ debounceTimer = setTimeout(function() {
133+ var text = window.getSelection().toString().trim();
134+ if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135+ window.webkit.messageHandlers.dumber.postMessage({
136+ type: 'auto_copy_selection',
137+ payload: { text: text }
138+ });
139+ }
140+ }, 300);
141+ });
142+})();`
143+
117144 // ContentInjector encapsulates script injection into WebViews.
118145 // It injects dark mode detection scripts for internal pages (dumb://)
119146 // and theme CSS variables for WebUI styling.
120147 // External pages receive dark mode preference via libadwaita's StyleManager.
121148 // Implements port.ContentInjector interface.
122149 type ContentInjector struct {
123- colorResolver port.ColorSchemeResolver
124- themeCSSVars string // CSS custom property declarations for WebUI
125- findCSS string // CSS for find-in-page highlight styling
150+ colorResolver port.ColorSchemeResolver
151+ themeCSSVars string // CSS custom property declarations for WebUI
152+ findCSS string // CSS for find-in-page highlight styling
153+ autoCopyOnSelection bool // Whether to inject auto-copy selection script
154+ autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
126155 }
127156
128157 // NewContentInjector creates a new injector instance.
@@ -133,6 +162,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
133162 }
134163 }
135164
165+// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
166+// This is called during script injection to determine whether to inject the selection listener.
167+func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
168+ ci.autoCopyConfigGetter = getter
169+}
170+
136171 // InjectThemeCSS stores CSS variables for injection into internal pages.
137172 // Implements port.ContentInjector interface.
138173 // The CSS will be injected when InjectScripts is called on WebView creation.
@@ -252,5 +287,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
252287 }
253288 }
254289
255- log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
290+ // 5. Inject auto-copy selection script for all pages (if enabled)
291+ autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
292+ if autoCopyEnabled {
293+ addScript(
294+ webkit.NewUserScript(
295+ autoCopySelectionScript,
296+ webkit.UserContentInjectTopFrameValue,
297+ webkit.UserScriptInjectAtDocumentEndValue,
298+ nil, // All pages
299+ nil,
300+ ),
301+ "auto-copy-selection",
302+ )
303+ log.Debug().Msg("auto-copy selection script injected")
304+ }
305+
306+ log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
256307 }
@@ -114,15 +114,44 @@ const internalDarkModeScriptTemplate = `(function() {
114 // internalPageAllowList restricts script injection to internal dumb:// pages only.114 // internalPageAllowList restricts script injection to internal dumb:// pages only.
115 var internalPageAllowList = []string{"dumb://*"}115 var internalPageAllowList = []string{"dumb://*"}
116 116
117+// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118+// It listens for selection changes, debounces them, and posts to the Go message handler.
119+// Skips selections in input fields/textareas and requires minimum 2 characters.
120+const autoCopySelectionScript = `(function() {
121+ var debounceTimer = null;
122+ var MIN_LENGTH = 2;
123+
124+ document.addEventListener('selectionchange', function() {
125+ // Skip if selection is inside input/textarea/contenteditable
126+ var activeEl = document.activeElement;
127+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128+ return;
129+ }
130+
131+ clearTimeout(debounceTimer);
132+ debounceTimer = setTimeout(function() {
133+ var text = window.getSelection().toString().trim();
134+ if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135+ window.webkit.messageHandlers.dumber.postMessage({
136+ type: 'auto_copy_selection',
137+ payload: { text: text }
138+ });
139+ }
140+ }, 300);
141+ });
142+})();`
143+
117 // ContentInjector encapsulates script injection into WebViews.144 // ContentInjector encapsulates script injection into WebViews.
118 // It injects dark mode detection scripts for internal pages (dumb://)145 // It injects dark mode detection scripts for internal pages (dumb://)
119 // and theme CSS variables for WebUI styling.146 // and theme CSS variables for WebUI styling.
120 // External pages receive dark mode preference via libadwaita's StyleManager.147 // External pages receive dark mode preference via libadwaita's StyleManager.
121 // Implements port.ContentInjector interface.148 // Implements port.ContentInjector interface.
122 type ContentInjector struct {149 type ContentInjector struct {
123- colorResolver port.ColorSchemeResolver150+ colorResolver port.ColorSchemeResolver
124- themeCSSVars string // CSS custom property declarations for WebUI151+ themeCSSVars string // CSS custom property declarations for WebUI
125- findCSS string // CSS for find-in-page highlight styling152+ findCSS string // CSS for find-in-page highlight styling
153+ autoCopyOnSelection bool // Whether to inject auto-copy selection script
154+ autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
126 }155 }
127 156
128 // NewContentInjector creates a new injector instance.157 // NewContentInjector creates a new injector instance.
@@ -133,6 +162,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
133 }162 }
134 }163 }
135 164
165+// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
166+// This is called during script injection to determine whether to inject the selection listener.
167+func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
168+ ci.autoCopyConfigGetter = getter
169+}
170+
136 // InjectThemeCSS stores CSS variables for injection into internal pages.171 // InjectThemeCSS stores CSS variables for injection into internal pages.
137 // Implements port.ContentInjector interface.172 // Implements port.ContentInjector interface.
138 // The CSS will be injected when InjectScripts is called on WebView creation.173 // The CSS will be injected when InjectScripts is called on WebView creation.
@@ -252,5 +287,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
252 }287 }
253 }288 }
254 289
255- log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")290+ // 5. Inject auto-copy selection script for all pages (if enabled)
291+ autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
292+ if autoCopyEnabled {
293+ addScript(
294+ webkit.NewUserScript(
295+ autoCopySelectionScript,
296+ webkit.UserContentInjectTopFrameValue,
297+ webkit.UserScriptInjectAtDocumentEndValue,
298+ nil, // All pages
299+ nil,
300+ ),
301+ "auto-copy-selection",
302+ )
303+ log.Debug().Msg("auto-copy selection script injected")
304+ }
305+
306+ log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
256 }307 }
M internal/ui/app.go +18 -2
@@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) {
146146 // Register message handlers
147147 if app.router != nil {
148148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
149- HistoryUC: deps.HistoryUC,
150- FavoritesUC: deps.FavoritesUC,
149+ HistoryUC: deps.HistoryUC,
150+ FavoritesUC: deps.FavoritesUC,
151+ Clipboard: deps.Clipboard,
152+ ConfigGetter: config.Get,
153+ OnClipboardCopied: func(textLen int) {
154+ // Show brief toast on auto-copy (similar to zellij footer notification)
155+ // Must schedule on GTK main thread since this is called from WebKit handler
156+ cb := glib.SourceFunc(func(_ uintptr) bool {
157+ if app.appToaster != nil {
158+ app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
159+ component.WithDuration(800), // Brief notification
160+ component.WithPosition(component.ToastPositionBottomRight),
161+ )
162+ }
163+ return false
164+ })
165+ glib.IdleAdd(&cb, 0)
166+ },
151167 }); err != nil {
152168 return nil, err
153169 }
@@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) {
146 // Register message handlers146 // Register message handlers
147 if app.router != nil {147 if app.router != nil {
148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
149- HistoryUC: deps.HistoryUC,149+ HistoryUC: deps.HistoryUC,
150- FavoritesUC: deps.FavoritesUC,150+ FavoritesUC: deps.FavoritesUC,
151+ Clipboard: deps.Clipboard,
152+ ConfigGetter: config.Get,
153+ OnClipboardCopied: func(textLen int) {
154+ // Show brief toast on auto-copy (similar to zellij footer notification)
155+ // Must schedule on GTK main thread since this is called from WebKit handler
156+ cb := glib.SourceFunc(func(_ uintptr) bool {
157+ if app.appToaster != nil {
158+ app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
159+ component.WithDuration(800), // Brief notification
160+ component.WithPosition(component.ToastPositionBottomRight),
161+ )
162+ }
163+ return false
164+ })
165+ glib.IdleAdd(&cb, 0)
166+ },
151 }); err != nil {167 }); err != nil {
152 return nil, err168 return nil, err
153 }169 }
M internal/ui/component/link_status.go +40 -4
@@ -13,6 +13,8 @@ import (
1313 const (
1414 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
1515 linkStatusShowDelayMs = 100
16+ // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video).
17+ linkStatusAutoHideMs = 10000
1618 // linkStatusMaxChars is the maximum characters before truncation.
1719 linkStatusMaxChars = 80
1820 )
@@ -24,10 +26,11 @@ type LinkStatusOverlay struct {
2426 container layout.BoxWidget
2527 label layout.LabelWidget
2628
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
29+ pendingURI string // URI waiting to be shown after delay
30+ showTimer uint // GLib timer for delayed show
31+ autoHideTimer uint // GLib timer for auto-hide after timeout
32+ visible bool // Whether overlay is currently visible (has .visible class)
33+ mu sync.Mutex
3134 }
3235
3336 // NewLinkStatusOverlay creates a new link status overlay component.
@@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) {
9699 l.visible = true
97100 l.container.AddCssClass("visible")
98101 }
102+ // Start or reset auto-hide timer
103+ l.resetAutoHideTimerLocked()
99104 }
100105 l.showTimer = 0
101106 return false // Don't repeat
@@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() {
118123 glib.SourceRemove(l.showTimer)
119124 l.showTimer = 0
120125 }
126+ // Cancel auto-hide timer
127+ if l.autoHideTimer != 0 {
128+ glib.SourceRemove(l.autoHideTimer)
129+ l.autoHideTimer = 0
130+ }
121131 l.pendingURI = ""
122132
123133 if l.visible {
@@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() {
126136 }
127137 }
128138
139+// resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held).
140+func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
141+ // Cancel existing auto-hide timer
142+ if l.autoHideTimer != 0 {
143+ glib.SourceRemove(l.autoHideTimer)
144+ l.autoHideTimer = 0
145+ }
146+
147+ // Start new auto-hide timer
148+ cb := glib.SourceFunc(func(_ uintptr) bool {
149+ l.mu.Lock()
150+ defer l.mu.Unlock()
151+
152+ l.autoHideTimer = 0
153+ l.hide()
154+ return false // Don't repeat
155+ })
156+
157+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158+}
159+
129160 // Widget returns the underlying widget for embedding in overlays.
130161 func (l *LinkStatusOverlay) Widget() layout.Widget {
131162 return l.container
@@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() {
149180 glib.SourceRemove(l.showTimer)
150181 l.showTimer = 0
151182 }
183+ // Cancel auto-hide timer
184+ if l.autoHideTimer != 0 {
185+ glib.SourceRemove(l.autoHideTimer)
186+ l.autoHideTimer = 0
187+ }
152188 l.pendingURI = ""
153189 l.visible = false
154190 }
@@ -13,6 +13,8 @@ import (
13 const (13 const (
14 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.14 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
15 linkStatusShowDelayMs = 10015 linkStatusShowDelayMs = 100
16+ // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video).
17+ linkStatusAutoHideMs = 10000
16 // linkStatusMaxChars is the maximum characters before truncation.18 // linkStatusMaxChars is the maximum characters before truncation.
17 linkStatusMaxChars = 8019 linkStatusMaxChars = 80
18 )20 )
@@ -24,10 +26,11 @@ type LinkStatusOverlay struct {
24 container layout.BoxWidget26 container layout.BoxWidget
25 label layout.LabelWidget27 label layout.LabelWidget
26 28
27- pendingURI string // URI waiting to be shown after delay29+ pendingURI string // URI waiting to be shown after delay
28- showTimer uint // GLib timer for delayed show30+ showTimer uint // GLib timer for delayed show
29- visible bool // Whether overlay is currently visible (has .visible class)31+ autoHideTimer uint // GLib timer for auto-hide after timeout
30- mu sync.Mutex32+ visible bool // Whether overlay is currently visible (has .visible class)
33+ mu sync.Mutex
31 }34 }
32 35
33 // NewLinkStatusOverlay creates a new link status overlay component.36 // NewLinkStatusOverlay creates a new link status overlay component.
@@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) {
96 l.visible = true99 l.visible = true
97 l.container.AddCssClass("visible")100 l.container.AddCssClass("visible")
98 }101 }
102+ // Start or reset auto-hide timer
103+ l.resetAutoHideTimerLocked()
99 }104 }
100 l.showTimer = 0105 l.showTimer = 0
101 return false // Don't repeat106 return false // Don't repeat
@@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() {
118 glib.SourceRemove(l.showTimer)123 glib.SourceRemove(l.showTimer)
119 l.showTimer = 0124 l.showTimer = 0
120 }125 }
126+ // Cancel auto-hide timer
127+ if l.autoHideTimer != 0 {
128+ glib.SourceRemove(l.autoHideTimer)
129+ l.autoHideTimer = 0
130+ }
121 l.pendingURI = ""131 l.pendingURI = ""
122 132
123 if l.visible {133 if l.visible {
@@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() {
126 }136 }
127 }137 }
128 138
139+// resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held).
140+func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
141+ // Cancel existing auto-hide timer
142+ if l.autoHideTimer != 0 {
143+ glib.SourceRemove(l.autoHideTimer)
144+ l.autoHideTimer = 0
145+ }
146+
147+ // Start new auto-hide timer
148+ cb := glib.SourceFunc(func(_ uintptr) bool {
149+ l.mu.Lock()
150+ defer l.mu.Unlock()
151+
152+ l.autoHideTimer = 0
153+ l.hide()
154+ return false // Don't repeat
155+ })
156+
157+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158+}
159+
129 // Widget returns the underlying widget for embedding in overlays.160 // Widget returns the underlying widget for embedding in overlays.
130 func (l *LinkStatusOverlay) Widget() layout.Widget {161 func (l *LinkStatusOverlay) Widget() layout.Widget {
131 return l.container162 return l.container
@@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() {
149 glib.SourceRemove(l.showTimer)180 glib.SourceRemove(l.showTimer)
150 l.showTimer = 0181 l.showTimer = 0
151 }182 }
183+ // Cancel auto-hide timer
184+ if l.autoHideTimer != 0 {
185+ glib.SourceRemove(l.autoHideTimer)
186+ l.autoHideTimer = 0
187+ }
152 l.pendingURI = ""188 l.pendingURI = ""
153 l.visible = false189 l.visible = false
154 }190 }

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