detached abd344b

feat(favorites): add keyboard-driven native sidebar

Add native keyboard-first favorites sidebar, Ctrl+B/Ctrl+D wiring, docs, and review fixes.
commit
abd344bb4ce21aba3ba39470ac1220b6de775026
author
bnema · · 2026-06-28 16:56 UTC
committer
GitHub · · 2026-06-28 16:56 UTC
merge of
7246c1f + 284f18b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +3080 -62
M docs/reference/keybindings.md +8 -2
@@ -84,7 +84,8 @@ These work outside modal modes:
8484 |--------|------|
8585 | Toggle floating pane | `Alt+F` |
8686 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
87-| Toggle Favorites system view in right split | unbound by default |
87+| Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` |
88+| Toggle current page favorite/bookmark | `Ctrl+D` |
8889 | Toggle Config system view in right split | unbound by default |
8990 | Close pane (or release floating pane) | `Ctrl+W` |
9091 | Next tab | `Ctrl+Tab` |
@@ -97,6 +98,8 @@ These work outside modal modes:
9798 - `Alt+F` is the only floating-pane shortcut enabled by default.
9899 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
99100 - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.
101+- `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` cycle sidebar zones; `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `t` opens tag mode, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters.
102+- `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching.
100103 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
101104 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
102105 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
@@ -125,7 +128,10 @@ keys = ["alt+f"]
125128 keys = ["ctrl+h"]
126129
127130 [workspace.shortcuts.actions.toggle-favorites-systemview]
128-keys = []
131+keys = ["ctrl+b"]
132+
133+[workspace.shortcuts.actions.toggle-current-page-favorite]
134+keys = ["ctrl+d"]
129135
130136 [workspace.shortcuts.actions.toggle-config-systemview]
131137 keys = []
@@ -84,7 +84,8 @@ These work outside modal modes:
84 |--------|------|84 |--------|------|
85 | Toggle floating pane | `Alt+F` |85 | Toggle floating pane | `Alt+F` |
86 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |86 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
87-| Toggle Favorites system view in right split | unbound by default |87+| Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` |
88+| Toggle current page favorite/bookmark | `Ctrl+D` |
88 | Toggle Config system view in right split | unbound by default |89 | Toggle Config system view in right split | unbound by default |
89 | Close pane (or release floating pane) | `Ctrl+W` |90 | Close pane (or release floating pane) | `Ctrl+W` |
90 | Next tab | `Ctrl+Tab` |91 | Next tab | `Ctrl+Tab` |
@@ -97,6 +98,8 @@ These work outside modal modes:
97 - `Alt+F` is the only floating-pane shortcut enabled by default.98 - `Alt+F` is the only floating-pane shortcut enabled by default.
98 - `Alt+F` toggles floating visibility and keeps floating pane state intact.99 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
99 - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.100 - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`.
101+- `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` cycle sidebar zones; `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `t` opens tag mode, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters.
102+- `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching.
100 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.103 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
101 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.104 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
102 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).105 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
@@ -125,7 +128,10 @@ keys = ["alt+f"]
125 keys = ["ctrl+h"]128 keys = ["ctrl+h"]
126 129
127 [workspace.shortcuts.actions.toggle-favorites-systemview]130 [workspace.shortcuts.actions.toggle-favorites-systemview]
128-keys = []131+keys = ["ctrl+b"]
132+
133+[workspace.shortcuts.actions.toggle-current-page-favorite]
134+keys = ["ctrl+d"]
129 135
130 [workspace.shortcuts.actions.toggle-config-systemview]136 [workspace.shortcuts.actions.toggle-config-systemview]
131 keys = []137 keys = []
A internal/application/port/favorites_sidebar.go +20 -0
@@ -0,0 +1,20 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// FavoritesSidebarFavorites provides favorite and tag operations needed by the native favorites sidebar.
11+type FavoritesSidebarFavorites interface {
12+ GetAll(ctx context.Context) ([]*entity.Favorite, error)
13+ GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
15+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
16+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
17+ SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
18+ TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
19+ UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
20+}
@@ -0,0 +1,20 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// FavoritesSidebarFavorites provides favorite and tag operations needed by the native favorites sidebar.
11+type FavoritesSidebarFavorites interface {
12+ GetAll(ctx context.Context) ([]*entity.Favorite, error)
13+ GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
15+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
16+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
17+ SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
18+ TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
19+ UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
20+}
A internal/application/usecase/favorite_sidebar_filter.go +176 -0
@@ -0,0 +1,176 @@
1+package usecase
2+
3+import (
4+ "net/url"
5+ "sort"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+const (
13+ favoriteSidebarExactMatchScore = 100
14+ favoriteSidebarTitlePrefixScore = 90
15+ favoriteSidebarHostPrefixScore = 80
16+ favoriteSidebarTagNameScore = 70
17+ favoriteSidebarTitleContainsScore = 60
18+ favoriteSidebarURLContainsScore = 50
19+)
20+
21+// FavoriteSidebarQuery describes the pure filtering inputs for the native favorites sidebar.
22+type FavoriteSidebarQuery struct {
23+ Text string
24+ TagIDs []entity.TagID
25+}
26+
27+// FilterFavoritesForSidebar filters, deduplicates, and ranks favorites for the native favorites sidebar.
28+func FilterFavoritesForSidebar(favorites []*entity.Favorite, query FavoriteSidebarQuery) []*entity.Favorite {
29+ text := strings.ToLower(strings.TrimSpace(query.Text))
30+ selectedTagIDs := favoriteSidebarSelectedTagSet(query.TagIDs)
31+
32+ rows := make([]favoriteSidebarFilterRow, 0, len(favorites))
33+ seen := make(map[string]struct{}, len(favorites))
34+ for index, favorite := range favorites {
35+ if favorite == nil {
36+ continue
37+ }
38+ if len(selectedTagIDs) > 0 && !favoriteSidebarHasAnyTag(favorite, selectedTagIDs) {
39+ continue
40+ }
41+
42+ score := 0
43+ if text != "" {
44+ var matched bool
45+ score, matched = favoriteSidebarMatchScore(favorite, text)
46+ if !matched {
47+ continue
48+ }
49+ }
50+
51+ key := favoriteSidebarDedupeKey(favorite)
52+ if key == "" {
53+ continue
54+ }
55+ if _, ok := seen[key]; ok {
56+ continue
57+ }
58+ seen[key] = struct{}{}
59+
60+ rows = append(rows, favoriteSidebarFilterRow{
61+ favorite: favorite,
62+ index: index,
63+ score: score,
64+ })
65+ }
66+
67+ if text != "" || len(selectedTagIDs) > 0 {
68+ sort.SliceStable(rows, func(i, j int) bool {
69+ left, right := rows[i], rows[j]
70+ if left.score != right.score {
71+ return left.score > right.score
72+ }
73+ if left.favorite.Position != right.favorite.Position {
74+ return left.favorite.Position < right.favorite.Position
75+ }
76+ if left.favorite.URL != right.favorite.URL {
77+ return left.favorite.URL < right.favorite.URL
78+ }
79+ return left.index < right.index
80+ })
81+ }
82+
83+ result := make([]*entity.Favorite, 0, len(rows))
84+ for _, row := range rows {
85+ result = append(result, row.favorite)
86+ }
87+ return result
88+}
89+
90+type favoriteSidebarFilterRow struct {
91+ favorite *entity.Favorite
92+ index int
93+ score int
94+}
95+
96+func favoriteSidebarSelectedTagSet(tagIDs []entity.TagID) map[entity.TagID]struct{} {
97+ selectedTagIDs := make(map[entity.TagID]struct{}, len(tagIDs))
98+ for _, tagID := range tagIDs {
99+ selectedTagIDs[tagID] = struct{}{}
100+ }
101+ return selectedTagIDs
102+}
103+
104+func favoriteSidebarHasAnyTag(favorite *entity.Favorite, selectedTagIDs map[entity.TagID]struct{}) bool {
105+ for _, tag := range favorite.Tags {
106+ if _, ok := selectedTagIDs[tag.ID]; ok {
107+ return true
108+ }
109+ }
110+ return false
111+}
112+
113+func favoriteSidebarDedupeKey(favorite *entity.Favorite) string {
114+ if favorite.ID != 0 {
115+ return "id:" + strconv.FormatInt(int64(favorite.ID), 10)
116+ }
117+ if favorite.URL == "" {
118+ return ""
119+ }
120+ return "url:" + favorite.URL
121+}
122+
123+func favoriteSidebarMatchScore(favorite *entity.Favorite, text string) (int, bool) {
124+ title := strings.ToLower(favorite.Title)
125+ favoriteURL := strings.ToLower(favorite.URL)
126+ host := favoriteSidebarURLHost(favorite.URL)
127+
128+ switch {
129+ case title == text || favoriteURL == text:
130+ return favoriteSidebarExactMatchScore, true
131+ case strings.HasPrefix(title, text):
132+ return favoriteSidebarTitlePrefixScore, true
133+ case favoriteSidebarHostOrDomainHasPrefix(host, text):
134+ return favoriteSidebarHostPrefixScore, true
135+ case favoriteSidebarTagNameContains(favorite, text):
136+ return favoriteSidebarTagNameScore, true
137+ case strings.Contains(title, text):
138+ return favoriteSidebarTitleContainsScore, true
139+ case strings.Contains(favoriteURL, text):
140+ return favoriteSidebarURLContainsScore, true
141+ default:
142+ return 0, false
143+ }
144+}
145+
146+func favoriteSidebarTagNameContains(favorite *entity.Favorite, text string) bool {
147+ for _, tag := range favorite.Tags {
148+ if strings.Contains(strings.ToLower(tag.Name), text) {
149+ return true
150+ }
151+ }
152+ return false
153+}
154+
155+func favoriteSidebarHostOrDomainHasPrefix(host, text string) bool {
156+ host = strings.TrimPrefix(host, "www.")
157+ for host != "" {
158+ if strings.HasPrefix(host, text) {
159+ return true
160+ }
161+ dot := strings.IndexByte(host, '.')
162+ if dot < 0 {
163+ return false
164+ }
165+ host = host[dot+1:]
166+ }
167+ return false
168+}
169+
170+func favoriteSidebarURLHost(rawURL string) string {
171+ parsed, err := url.Parse(rawURL)
172+ if err != nil {
173+ return ""
174+ }
175+ return strings.ToLower(parsed.Hostname())
176+}
@@ -0,0 +1,176 @@
1+package usecase
2+
3+import (
4+ "net/url"
5+ "sort"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+const (
13+ favoriteSidebarExactMatchScore = 100
14+ favoriteSidebarTitlePrefixScore = 90
15+ favoriteSidebarHostPrefixScore = 80
16+ favoriteSidebarTagNameScore = 70
17+ favoriteSidebarTitleContainsScore = 60
18+ favoriteSidebarURLContainsScore = 50
19+)
20+
21+// FavoriteSidebarQuery describes the pure filtering inputs for the native favorites sidebar.
22+type FavoriteSidebarQuery struct {
23+ Text string
24+ TagIDs []entity.TagID
25+}
26+
27+// FilterFavoritesForSidebar filters, deduplicates, and ranks favorites for the native favorites sidebar.
28+func FilterFavoritesForSidebar(favorites []*entity.Favorite, query FavoriteSidebarQuery) []*entity.Favorite {
29+ text := strings.ToLower(strings.TrimSpace(query.Text))
30+ selectedTagIDs := favoriteSidebarSelectedTagSet(query.TagIDs)
31+
32+ rows := make([]favoriteSidebarFilterRow, 0, len(favorites))
33+ seen := make(map[string]struct{}, len(favorites))
34+ for index, favorite := range favorites {
35+ if favorite == nil {
36+ continue
37+ }
38+ if len(selectedTagIDs) > 0 && !favoriteSidebarHasAnyTag(favorite, selectedTagIDs) {
39+ continue
40+ }
41+
42+ score := 0
43+ if text != "" {
44+ var matched bool
45+ score, matched = favoriteSidebarMatchScore(favorite, text)
46+ if !matched {
47+ continue
48+ }
49+ }
50+
51+ key := favoriteSidebarDedupeKey(favorite)
52+ if key == "" {
53+ continue
54+ }
55+ if _, ok := seen[key]; ok {
56+ continue
57+ }
58+ seen[key] = struct{}{}
59+
60+ rows = append(rows, favoriteSidebarFilterRow{
61+ favorite: favorite,
62+ index: index,
63+ score: score,
64+ })
65+ }
66+
67+ if text != "" || len(selectedTagIDs) > 0 {
68+ sort.SliceStable(rows, func(i, j int) bool {
69+ left, right := rows[i], rows[j]
70+ if left.score != right.score {
71+ return left.score > right.score
72+ }
73+ if left.favorite.Position != right.favorite.Position {
74+ return left.favorite.Position < right.favorite.Position
75+ }
76+ if left.favorite.URL != right.favorite.URL {
77+ return left.favorite.URL < right.favorite.URL
78+ }
79+ return left.index < right.index
80+ })
81+ }
82+
83+ result := make([]*entity.Favorite, 0, len(rows))
84+ for _, row := range rows {
85+ result = append(result, row.favorite)
86+ }
87+ return result
88+}
89+
90+type favoriteSidebarFilterRow struct {
91+ favorite *entity.Favorite
92+ index int
93+ score int
94+}
95+
96+func favoriteSidebarSelectedTagSet(tagIDs []entity.TagID) map[entity.TagID]struct{} {
97+ selectedTagIDs := make(map[entity.TagID]struct{}, len(tagIDs))
98+ for _, tagID := range tagIDs {
99+ selectedTagIDs[tagID] = struct{}{}
100+ }
101+ return selectedTagIDs
102+}
103+
104+func favoriteSidebarHasAnyTag(favorite *entity.Favorite, selectedTagIDs map[entity.TagID]struct{}) bool {
105+ for _, tag := range favorite.Tags {
106+ if _, ok := selectedTagIDs[tag.ID]; ok {
107+ return true
108+ }
109+ }
110+ return false
111+}
112+
113+func favoriteSidebarDedupeKey(favorite *entity.Favorite) string {
114+ if favorite.ID != 0 {
115+ return "id:" + strconv.FormatInt(int64(favorite.ID), 10)
116+ }
117+ if favorite.URL == "" {
118+ return ""
119+ }
120+ return "url:" + favorite.URL
121+}
122+
123+func favoriteSidebarMatchScore(favorite *entity.Favorite, text string) (int, bool) {
124+ title := strings.ToLower(favorite.Title)
125+ favoriteURL := strings.ToLower(favorite.URL)
126+ host := favoriteSidebarURLHost(favorite.URL)
127+
128+ switch {
129+ case title == text || favoriteURL == text:
130+ return favoriteSidebarExactMatchScore, true
131+ case strings.HasPrefix(title, text):
132+ return favoriteSidebarTitlePrefixScore, true
133+ case favoriteSidebarHostOrDomainHasPrefix(host, text):
134+ return favoriteSidebarHostPrefixScore, true
135+ case favoriteSidebarTagNameContains(favorite, text):
136+ return favoriteSidebarTagNameScore, true
137+ case strings.Contains(title, text):
138+ return favoriteSidebarTitleContainsScore, true
139+ case strings.Contains(favoriteURL, text):
140+ return favoriteSidebarURLContainsScore, true
141+ default:
142+ return 0, false
143+ }
144+}
145+
146+func favoriteSidebarTagNameContains(favorite *entity.Favorite, text string) bool {
147+ for _, tag := range favorite.Tags {
148+ if strings.Contains(strings.ToLower(tag.Name), text) {
149+ return true
150+ }
151+ }
152+ return false
153+}
154+
155+func favoriteSidebarHostOrDomainHasPrefix(host, text string) bool {
156+ host = strings.TrimPrefix(host, "www.")
157+ for host != "" {
158+ if strings.HasPrefix(host, text) {
159+ return true
160+ }
161+ dot := strings.IndexByte(host, '.')
162+ if dot < 0 {
163+ return false
164+ }
165+ host = host[dot+1:]
166+ }
167+ return false
168+}
169+
170+func favoriteSidebarURLHost(rawURL string) string {
171+ parsed, err := url.Parse(rawURL)
172+ if err != nil {
173+ return ""
174+ }
175+ return strings.ToLower(parsed.Hostname())
176+}
A internal/application/usecase/favorite_sidebar_filter_test.go +158 -0
@@ -0,0 +1,158 @@
1+package usecase
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestFilterFavoritesForSidebarEmptyQueryReturnsStableOrder(t *testing.T) {
11+ favorites := []*entity.Favorite{
12+ favoriteSidebarTestFavorite(1, "https://third.example", "Third", 30),
13+ favoriteSidebarTestFavorite(2, "https://first.example", "First", 10),
14+ favoriteSidebarTestFavorite(3, "https://second.example", "Second", 20),
15+ }
16+
17+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
18+
19+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 2, 3})
20+ if len(got) > 0 && got[0] != favorites[0] {
21+ t.Fatalf("FilterFavoritesForSidebar should return original favorite pointers in a new slice")
22+ }
23+ if len(favorites) != 3 || favorites[0].ID != 1 || favorites[1].ID != 2 || favorites[2].ID != 3 {
24+ t.Fatalf("FilterFavoritesForSidebar mutated input order: %#v", favorites)
25+ }
26+}
27+
28+func TestFilterFavoritesForSidebarMatchesTitleURLAndTagNames(t *testing.T) {
29+ favorites := []*entity.Favorite{
30+ favoriteSidebarTestFavorite(1, "https://docs.example/go", "Go Docs", 1, favoriteSidebarTestTag(1, "reference")),
31+ favoriteSidebarTestFavorite(2, "https://example.com/articles", "Reading List", 2, favoriteSidebarTestTag(2, "Research")),
32+ favoriteSidebarTestFavorite(3, "https://calendar.example", "Planner", 3, favoriteSidebarTestTag(3, "Work")),
33+ }
34+
35+ t.Run("title", func(t *testing.T) {
36+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
37+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
38+ })
39+
40+ t.Run("url", func(t *testing.T) {
41+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "articles"})
42+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
43+ })
44+
45+ t.Run("tag", func(t *testing.T) {
46+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "research"})
47+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
48+ })
49+}
50+
51+func TestFilterFavoritesForSidebarSelectedTagsMatchAny(t *testing.T) {
52+ favorites := []*entity.Favorite{
53+ favoriteSidebarTestFavorite(1, "https://go.dev", "Go", 1, favoriteSidebarTestTag(1, "dev")),
54+ favoriteSidebarTestFavorite(2, "https://recipes.example", "Recipes", 2, favoriteSidebarTestTag(2, "home")),
55+ favoriteSidebarTestFavorite(3, "https://garden.example", "Garden", 3, favoriteSidebarTestTag(3, "outdoor")),
56+ favoriteSidebarTestFavorite(4, "https://untagged.example", "Untagged", 4),
57+ }
58+
59+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{TagIDs: []entity.TagID{1, 3}})
60+
61+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 3})
62+}
63+
64+func TestFilterFavoritesForSidebarCombinesTextWithinSelectedTags(t *testing.T) {
65+ favorites := []*entity.Favorite{
66+ favoriteSidebarTestFavorite(1, "https://go.dev/doc", "Go Docs", 1, favoriteSidebarTestTag(1, "dev")),
67+ favoriteSidebarTestFavorite(2, "https://go.example/home", "Go Home", 2, favoriteSidebarTestTag(2, "home")),
68+ favoriteSidebarTestFavorite(3, "https://rust.dev", "Rust", 3, favoriteSidebarTestTag(1, "dev")),
69+ }
70+
71+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go", TagIDs: []entity.TagID{1}})
72+
73+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
74+}
75+
76+func TestFilterFavoritesForSidebarRanksExactAndPrefixMatches(t *testing.T) {
77+ favorites := []*entity.Favorite{
78+ favoriteSidebarTestFavorite(1, "https://example.com/go-notes", "My Go Notes", 1),
79+ favoriteSidebarTestFavorite(2, "https://go.example.com", "Other", 2),
80+ favoriteSidebarTestFavorite(3, "https://example.com/other", "Go", 3),
81+ favoriteSidebarTestFavorite(4, "https://example.com/go", "Go Handbook", 4),
82+ }
83+
84+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
85+
86+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 4, 2, 1})
87+}
88+
89+func TestFilterFavoritesForSidebarRanksHostPrefixAndTagNameMatches(t *testing.T) {
90+ favorites := []*entity.Favorite{
91+ favoriteSidebarTestFavorite(1, "https://example.com/golang", "Notes", 1),
92+ favoriteSidebarTestFavorite(2, "https://pkg.go.dev", "Packages", 2),
93+ favoriteSidebarTestFavorite(3, "https://example.org/tools", "Tools", 3, favoriteSidebarTestTag(9, "Go Tools")),
94+ favoriteSidebarTestFavorite(4, "https://example.net/articles", "Long Go Article", 4),
95+ }
96+
97+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
98+
99+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2, 3, 4, 1})
100+}
101+
102+func TestFilterFavoritesForSidebarDeduplicatesRepeatedPointers(t *testing.T) {
103+ favorite := favoriteSidebarTestFavorite(1, "https://example.com/one", "One", 1)
104+ favorites := []*entity.Favorite{
105+ favorite,
106+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "First no ID", 2),
107+ favorite,
108+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "Second no ID", 3),
109+ nil,
110+ }
111+
112+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
113+
114+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 0})
115+ if len(got) != 2 || got[1].Title != "First no ID" {
116+ t.Fatalf("expected duplicate no-ID URL to keep first favorite, got %#v", got)
117+ }
118+}
119+
120+func TestFilterFavoritesForSidebarUsesPositionAndURLTieBreakers(t *testing.T) {
121+ favorites := []*entity.Favorite{
122+ favoriteSidebarTestFavorite(1, "https://z.example/go", "Notes about Go", 2),
123+ favoriteSidebarTestFavorite(2, "https://a.example/go", "More Go Notes", 2),
124+ favoriteSidebarTestFavorite(3, "https://m.example/go", "Go snippets", 1),
125+ }
126+
127+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
128+
129+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 2, 1})
130+}
131+
132+func favoriteSidebarTestFavorite(id entity.FavoriteID, rawURL, title string, position int, tags ...entity.Tag) *entity.Favorite {
133+ return &entity.Favorite{
134+ ID: id,
135+ URL: rawURL,
136+ Title: title,
137+ Position: position,
138+ Tags: append([]entity.Tag(nil), tags...),
139+ }
140+}
141+
142+func favoriteSidebarTestTag(id entity.TagID, name string) entity.Tag {
143+ return entity.Tag{ID: id, Name: name}
144+}
145+
146+func assertFavoriteSidebarIDs(t *testing.T, favorites []*entity.Favorite, want []entity.FavoriteID) {
147+ t.Helper()
148+ got := make([]entity.FavoriteID, 0, len(favorites))
149+ for _, favorite := range favorites {
150+ if favorite == nil {
151+ t.Fatalf("result contains nil favorite")
152+ }
153+ got = append(got, favorite.ID)
154+ }
155+ if !reflect.DeepEqual(got, want) {
156+ t.Fatalf("favorite IDs = %v, want %v", got, want)
157+ }
158+}
@@ -0,0 +1,158 @@
1+package usecase
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestFilterFavoritesForSidebarEmptyQueryReturnsStableOrder(t *testing.T) {
11+ favorites := []*entity.Favorite{
12+ favoriteSidebarTestFavorite(1, "https://third.example", "Third", 30),
13+ favoriteSidebarTestFavorite(2, "https://first.example", "First", 10),
14+ favoriteSidebarTestFavorite(3, "https://second.example", "Second", 20),
15+ }
16+
17+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
18+
19+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 2, 3})
20+ if len(got) > 0 && got[0] != favorites[0] {
21+ t.Fatalf("FilterFavoritesForSidebar should return original favorite pointers in a new slice")
22+ }
23+ if len(favorites) != 3 || favorites[0].ID != 1 || favorites[1].ID != 2 || favorites[2].ID != 3 {
24+ t.Fatalf("FilterFavoritesForSidebar mutated input order: %#v", favorites)
25+ }
26+}
27+
28+func TestFilterFavoritesForSidebarMatchesTitleURLAndTagNames(t *testing.T) {
29+ favorites := []*entity.Favorite{
30+ favoriteSidebarTestFavorite(1, "https://docs.example/go", "Go Docs", 1, favoriteSidebarTestTag(1, "reference")),
31+ favoriteSidebarTestFavorite(2, "https://example.com/articles", "Reading List", 2, favoriteSidebarTestTag(2, "Research")),
32+ favoriteSidebarTestFavorite(3, "https://calendar.example", "Planner", 3, favoriteSidebarTestTag(3, "Work")),
33+ }
34+
35+ t.Run("title", func(t *testing.T) {
36+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
37+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
38+ })
39+
40+ t.Run("url", func(t *testing.T) {
41+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "articles"})
42+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
43+ })
44+
45+ t.Run("tag", func(t *testing.T) {
46+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "research"})
47+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
48+ })
49+}
50+
51+func TestFilterFavoritesForSidebarSelectedTagsMatchAny(t *testing.T) {
52+ favorites := []*entity.Favorite{
53+ favoriteSidebarTestFavorite(1, "https://go.dev", "Go", 1, favoriteSidebarTestTag(1, "dev")),
54+ favoriteSidebarTestFavorite(2, "https://recipes.example", "Recipes", 2, favoriteSidebarTestTag(2, "home")),
55+ favoriteSidebarTestFavorite(3, "https://garden.example", "Garden", 3, favoriteSidebarTestTag(3, "outdoor")),
56+ favoriteSidebarTestFavorite(4, "https://untagged.example", "Untagged", 4),
57+ }
58+
59+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{TagIDs: []entity.TagID{1, 3}})
60+
61+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 3})
62+}
63+
64+func TestFilterFavoritesForSidebarCombinesTextWithinSelectedTags(t *testing.T) {
65+ favorites := []*entity.Favorite{
66+ favoriteSidebarTestFavorite(1, "https://go.dev/doc", "Go Docs", 1, favoriteSidebarTestTag(1, "dev")),
67+ favoriteSidebarTestFavorite(2, "https://go.example/home", "Go Home", 2, favoriteSidebarTestTag(2, "home")),
68+ favoriteSidebarTestFavorite(3, "https://rust.dev", "Rust", 3, favoriteSidebarTestTag(1, "dev")),
69+ }
70+
71+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go", TagIDs: []entity.TagID{1}})
72+
73+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
74+}
75+
76+func TestFilterFavoritesForSidebarRanksExactAndPrefixMatches(t *testing.T) {
77+ favorites := []*entity.Favorite{
78+ favoriteSidebarTestFavorite(1, "https://example.com/go-notes", "My Go Notes", 1),
79+ favoriteSidebarTestFavorite(2, "https://go.example.com", "Other", 2),
80+ favoriteSidebarTestFavorite(3, "https://example.com/other", "Go", 3),
81+ favoriteSidebarTestFavorite(4, "https://example.com/go", "Go Handbook", 4),
82+ }
83+
84+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
85+
86+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 4, 2, 1})
87+}
88+
89+func TestFilterFavoritesForSidebarRanksHostPrefixAndTagNameMatches(t *testing.T) {
90+ favorites := []*entity.Favorite{
91+ favoriteSidebarTestFavorite(1, "https://example.com/golang", "Notes", 1),
92+ favoriteSidebarTestFavorite(2, "https://pkg.go.dev", "Packages", 2),
93+ favoriteSidebarTestFavorite(3, "https://example.org/tools", "Tools", 3, favoriteSidebarTestTag(9, "Go Tools")),
94+ favoriteSidebarTestFavorite(4, "https://example.net/articles", "Long Go Article", 4),
95+ }
96+
97+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
98+
99+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2, 3, 4, 1})
100+}
101+
102+func TestFilterFavoritesForSidebarDeduplicatesRepeatedPointers(t *testing.T) {
103+ favorite := favoriteSidebarTestFavorite(1, "https://example.com/one", "One", 1)
104+ favorites := []*entity.Favorite{
105+ favorite,
106+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "First no ID", 2),
107+ favorite,
108+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "Second no ID", 3),
109+ nil,
110+ }
111+
112+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
113+
114+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 0})
115+ if len(got) != 2 || got[1].Title != "First no ID" {
116+ t.Fatalf("expected duplicate no-ID URL to keep first favorite, got %#v", got)
117+ }
118+}
119+
120+func TestFilterFavoritesForSidebarUsesPositionAndURLTieBreakers(t *testing.T) {
121+ favorites := []*entity.Favorite{
122+ favoriteSidebarTestFavorite(1, "https://z.example/go", "Notes about Go", 2),
123+ favoriteSidebarTestFavorite(2, "https://a.example/go", "More Go Notes", 2),
124+ favoriteSidebarTestFavorite(3, "https://m.example/go", "Go snippets", 1),
125+ }
126+
127+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
128+
129+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 2, 1})
130+}
131+
132+func favoriteSidebarTestFavorite(id entity.FavoriteID, rawURL, title string, position int, tags ...entity.Tag) *entity.Favorite {
133+ return &entity.Favorite{
134+ ID: id,
135+ URL: rawURL,
136+ Title: title,
137+ Position: position,
138+ Tags: append([]entity.Tag(nil), tags...),
139+ }
140+}
141+
142+func favoriteSidebarTestTag(id entity.TagID, name string) entity.Tag {
143+ return entity.Tag{ID: id, Name: name}
144+}
145+
146+func assertFavoriteSidebarIDs(t *testing.T, favorites []*entity.Favorite, want []entity.FavoriteID) {
147+ t.Helper()
148+ got := make([]entity.FavoriteID, 0, len(favorites))
149+ for _, favorite := range favorites {
150+ if favorite == nil {
151+ t.Fatalf("result contains nil favorite")
152+ }
153+ got = append(got, favorite.ID)
154+ }
155+ if !reflect.DeepEqual(got, want) {
156+ t.Fatalf("favorite IDs = %v, want %v", got, want)
157+ }
158+}
M internal/application/usecase/manage_favorites.go +2 -1
@@ -17,8 +17,9 @@ import (
1717 "github.com/bnema/dumber/internal/logging"
1818 )
1919
20-// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
20+// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
2121 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22+var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
2223
2324 // ManageFavoritesUseCase handles favorite and tag operations.
2425 type ManageFavoritesUseCase struct {
@@ -17,8 +17,9 @@ import (
17 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
18 )18 )
19 19
20-// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.20+// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22+var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
22 23
23 // ManageFavoritesUseCase handles favorite and tag operations.24 // ManageFavoritesUseCase handles favorite and tag operations.
24 type ManageFavoritesUseCase struct {25 type ManageFavoritesUseCase struct {
M internal/infrastructure/config/defaults.go +12 -11
@@ -322,17 +322,18 @@ func DefaultConfig() *Config {
322322 },
323323 Shortcuts: GlobalShortcutsConfig{
324324 Actions: map[string]ActionBinding{
325- "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326- "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327- "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},
328- "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
329- "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
330- "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
331- "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
332- "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
333- "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
334- "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
335- "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
325+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327+ "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
328+ "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
331+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
332+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
333+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
334+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
335+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
336+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
336337 },
337338 },
338339 FloatingPane: FloatingPaneConfig{
@@ -322,17 +322,18 @@ func DefaultConfig() *Config {
322 },322 },
323 Shortcuts: GlobalShortcutsConfig{323 Shortcuts: GlobalShortcutsConfig{
324 Actions: map[string]ActionBinding{324 Actions: map[string]ActionBinding{
325- "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},325+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326- "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},326+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327- "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},327+ "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
328- "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},328+ "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329- "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},329+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330- "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},330+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
331- "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},331+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
332- "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},332+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
333- "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},333+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
334- "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},334+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
335- "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},335+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
336+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
336 },337 },
337 },338 },
338 FloatingPane: FloatingPaneConfig{339 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +2 -1
@@ -41,7 +41,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
4141
4242 // System view shortcuts are first-class global actions.
4343 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
44- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{"ctrl+b"})
45+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
4546 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
4647
4748 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
@@ -41,7 +41,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
41 41
42 // System view shortcuts are first-class global actions.42 // System view shortcuts are first-class global actions.
43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
44- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{"ctrl+b"})
45+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
45 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})46 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
46 47
47 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.48 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
M internal/ui/app.go +8 -1
@@ -2040,7 +2040,12 @@ func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
20402040 if bw == nil {
20412041 return
20422042 }
2043- bw.hideHistorySidebar()
2043+ switch bw.activeSidebarKind {
2044+ case nativeSidebarFavorites:
2045+ bw.hideFavoritesSidebar()
2046+ default:
2047+ bw.hideHistorySidebar()
2048+ }
20442049 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
20452050 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
20462051 wsView.FocusPane(ws.ActivePaneID)
@@ -3357,6 +3362,8 @@ func (a *App) wireKeyboardActions() {
33573362 return a.EjectActivePaneToWindow(ctx, paneID)
33583363 })
33593364 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3365+ a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction)
3366+ a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction)
33603367 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
33613368 return a.ToggleFloatingPane(ctx)
33623369 })
@@ -2040,7 +2040,12 @@ func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
2040 if bw == nil {2040 if bw == nil {
2041 return2041 return
2042 }2042 }
2043- bw.hideHistorySidebar()2043+ switch bw.activeSidebarKind {
2044+ case nativeSidebarFavorites:
2045+ bw.hideFavoritesSidebar()
2046+ default:
2047+ bw.hideHistorySidebar()
2048+ }
2044 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {2049 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2045 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {2050 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
2046 wsView.FocusPane(ws.ActivePaneID)2051 wsView.FocusPane(ws.ActivePaneID)
@@ -3357,6 +3362,8 @@ func (a *App) wireKeyboardActions() {
3357 return a.EjectActivePaneToWindow(ctx, paneID)3362 return a.EjectActivePaneToWindow(ctx, paneID)
3358 })3363 })
3359 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)3364 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3365+ a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction)
3366+ a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction)
3360 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {3367 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3361 return a.ToggleFloatingPane(ctx)3368 return a.ToggleFloatingPane(ctx)
3362 })3369 })
M internal/ui/browser_window.go +16 -0
@@ -19,6 +19,14 @@ import (
1919 "github.com/bnema/puregotk/v4/glib"
2020 )
2121
22+type nativeSidebarKind string
23+
24+const (
25+ nativeSidebarNone nativeSidebarKind = ""
26+ nativeSidebarHistory nativeSidebarKind = "history"
27+ nativeSidebarFavorites nativeSidebarKind = "favorites"
28+)
29+
2230 type browserWindow struct {
2331 id string
2432 initialURL string
@@ -39,8 +47,10 @@ type browserWindow struct {
3947 permissionDialog port.PermissionDialogPresenter
4048 webrtcIndicator *component.WebRTCPermissionIndicator
4149 historySidebar *component.HistorySidebar
50+ favoritesSidebar *component.FavoritesSidebar
4251 historySidebarReloader historySidebarReloader
4352 sidebarVisible bool
53+ activeSidebarKind nativeSidebarKind
4454 }
4555
4656 func (bw *browserWindow) detachInputForDestroy() {
@@ -73,6 +83,9 @@ func (bw *browserWindow) clearShellState() {
7383 if bw.historySidebar != nil {
7484 bw.historySidebar.Destroy()
7585 }
86+ if bw.favoritesSidebar != nil {
87+ bw.favoritesSidebar.Destroy()
88+ }
7689 if bw.touchpadNavIndicator != nil {
7790 bw.touchpadNavIndicator.Destroy()
7891 }
@@ -91,7 +104,9 @@ func (bw *browserWindow) clearShellState() {
91104 bw.permissionDialog = nil
92105 bw.webrtcIndicator = nil
93106 bw.historySidebar = nil
107+ bw.favoritesSidebar = nil
94108 bw.historySidebarReloader = nil
109+ bw.activeSidebarKind = nativeSidebarNone
95110 }
96111
97112 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -106,6 +121,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
106121 bw.initSessionManager(ctx, a)
107122 bw.initTabPicker(ctx, a)
108123 bw.initHistorySidebar(ctx, a)
124+ bw.initFavoritesSidebar(ctx, a)
109125 }
110126
111127 func (bw *browserWindow) initToasterOverlay(a *App) {
@@ -19,6 +19,14 @@ import (
19 "github.com/bnema/puregotk/v4/glib"19 "github.com/bnema/puregotk/v4/glib"
20 )20 )
21 21
22+type nativeSidebarKind string
23+
24+const (
25+ nativeSidebarNone nativeSidebarKind = ""
26+ nativeSidebarHistory nativeSidebarKind = "history"
27+ nativeSidebarFavorites nativeSidebarKind = "favorites"
28+)
29+
22 type browserWindow struct {30 type browserWindow struct {
23 id string31 id string
24 initialURL string32 initialURL string
@@ -39,8 +47,10 @@ type browserWindow struct {
39 permissionDialog port.PermissionDialogPresenter47 permissionDialog port.PermissionDialogPresenter
40 webrtcIndicator *component.WebRTCPermissionIndicator48 webrtcIndicator *component.WebRTCPermissionIndicator
41 historySidebar *component.HistorySidebar49 historySidebar *component.HistorySidebar
50+ favoritesSidebar *component.FavoritesSidebar
42 historySidebarReloader historySidebarReloader51 historySidebarReloader historySidebarReloader
43 sidebarVisible bool52 sidebarVisible bool
53+ activeSidebarKind nativeSidebarKind
44 }54 }
45 55
46 func (bw *browserWindow) detachInputForDestroy() {56 func (bw *browserWindow) detachInputForDestroy() {
@@ -73,6 +83,9 @@ func (bw *browserWindow) clearShellState() {
73 if bw.historySidebar != nil {83 if bw.historySidebar != nil {
74 bw.historySidebar.Destroy()84 bw.historySidebar.Destroy()
75 }85 }
86+ if bw.favoritesSidebar != nil {
87+ bw.favoritesSidebar.Destroy()
88+ }
76 if bw.touchpadNavIndicator != nil {89 if bw.touchpadNavIndicator != nil {
77 bw.touchpadNavIndicator.Destroy()90 bw.touchpadNavIndicator.Destroy()
78 }91 }
@@ -91,7 +104,9 @@ func (bw *browserWindow) clearShellState() {
91 bw.permissionDialog = nil104 bw.permissionDialog = nil
92 bw.webrtcIndicator = nil105 bw.webrtcIndicator = nil
93 bw.historySidebar = nil106 bw.historySidebar = nil
107+ bw.favoritesSidebar = nil
94 bw.historySidebarReloader = nil108 bw.historySidebarReloader = nil
109+ bw.activeSidebarKind = nativeSidebarNone
95 }110 }
96 111
97 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {112 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -106,6 +121,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
106 bw.initSessionManager(ctx, a)121 bw.initSessionManager(ctx, a)
107 bw.initTabPicker(ctx, a)122 bw.initTabPicker(ctx, a)
108 bw.initHistorySidebar(ctx, a)123 bw.initHistorySidebar(ctx, a)
124+ bw.initFavoritesSidebar(ctx, a)
109 }125 }
110 126
111 func (bw *browserWindow) initToasterOverlay(a *App) {127 func (bw *browserWindow) initToasterOverlay(a *App) {
A internal/ui/browser_window_favorites_sidebar.go +148 -0
@@ -0,0 +1,148 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// initFavoritesSidebar creates the native favorites sidebar when favorites are configured.
15+func (bw *browserWindow) initFavoritesSidebar(ctx context.Context, a *App) {
16+ if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.FavoritesUC == nil {
17+ return
18+ }
19+ log := logging.FromContext(ctx)
20+
21+ cfg := a.buildFavoritesSidebarConfig(bw)
22+ sidebar := component.NewFavoritesSidebar(ctx, cfg)
23+ if sidebar == nil {
24+ log.Warn().Msg("failed to create favorites sidebar")
25+ return
26+ }
27+
28+ bw.favoritesSidebar = sidebar
29+ bw.favoritesSidebar.Hide()
30+ bw.applySidebarWidthConfig(a)
31+ log.Debug().Msg("favorites sidebar initialized")
32+}
33+
34+//nolint:dupl // Favorites and history sidebar configs intentionally mirror each other for separate component types.
35+func (a *App) buildFavoritesSidebarConfig(bw *browserWindow) component.FavoritesSidebarConfig {
36+ var favoritesUC port.FavoritesSidebarFavorites
37+ if a.deps != nil {
38+ favoritesUC = a.deps.FavoritesUC
39+ }
40+ return component.FavoritesSidebarConfig{
41+ FavoritesUC: favoritesUC,
42+ OnNavigate: func(navCtx context.Context, url string) error {
43+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
44+ },
45+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
46+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
47+ },
48+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
49+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
50+ return nil
51+ }
52+ a.activateBrowserWindow(bw)
53+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
54+ },
55+ OnClose: func() {
56+ a.hideAndRestoreFocusForBrowserWindow(bw)
57+ },
58+ }
59+}
60+
61+func (a *App) navigateFavoritesSidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
62+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
63+ return nil
64+ }
65+ return a.navigateFromBrowserWindow(ctx, bw, url)
66+}
67+
68+func (bw *browserWindow) toggleFavoritesSidebar() {
69+ if bw == nil || bw.favoritesSidebar == nil {
70+ return
71+ }
72+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
73+ bw.hideFavoritesSidebar()
74+ return
75+ }
76+ bw.showFavoritesSidebar()
77+}
78+
79+func (bw *browserWindow) showFavoritesSidebar() {
80+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
81+ return
82+ }
83+ bw.mountNativeSidebar(nativeSidebarFavorites)
84+ bw.favoritesSidebar.Show()
85+ bw.mainWindow.SetSidebarVisible(true)
86+ bw.sidebarVisible = true
87+ bw.activeSidebarKind = nativeSidebarFavorites
88+}
89+
90+func (bw *browserWindow) hideFavoritesSidebar() {
91+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
92+ return
93+ }
94+ bw.favoritesSidebar.Hide()
95+ bw.mainWindow.SetSidebarVisible(false)
96+ bw.sidebarVisible = false
97+ if bw.activeSidebarKind == nativeSidebarFavorites {
98+ bw.activeSidebarKind = nativeSidebarNone
99+ }
100+}
101+
102+func (a *App) toggleFavoritesSidebarAction(ctx context.Context) error {
103+ bw := a.lastFocusedBrowserWindow()
104+ if bw == nil {
105+ return fmt.Errorf("favorites sidebar unavailable: no focused browser window")
106+ }
107+ if bw.favoritesSidebar == nil {
108+ return fmt.Errorf("favorites sidebar unavailable: native sidebar not initialized")
109+ }
110+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
111+ a.hideAndRestoreFocusForBrowserWindow(bw)
112+ return nil
113+ }
114+ bw.toggleFavoritesSidebar()
115+ return nil
116+}
117+
118+func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119+ if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120+ return fmt.Errorf("favorites unavailable: usecase not configured")
121+ }
122+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
123+ if wv == nil {
124+ return fmt.Errorf("favorites unavailable: no active webview")
125+ }
126+ uri := strings.TrimSpace(wv.URI())
127+ if uri == "" {
128+ return fmt.Errorf("favorites unavailable: active page has no URI")
129+ }
130+ _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131+ if err != nil {
132+ return err
133+ }
134+ a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135+ return nil
136+}
137+
138+func (a *App) reloadVisibleFavoritesSidebars(reason string) {
139+ if a == nil {
140+ return
141+ }
142+ for _, bw := range a.browserWindows {
143+ if bw == nil || bw.favoritesSidebar == nil {
144+ continue
145+ }
146+ bw.favoritesSidebar.RequestReloadIfVisible(reason)
147+ }
148+}
@@ -0,0 +1,148 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// initFavoritesSidebar creates the native favorites sidebar when favorites are configured.
15+func (bw *browserWindow) initFavoritesSidebar(ctx context.Context, a *App) {
16+ if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.FavoritesUC == nil {
17+ return
18+ }
19+ log := logging.FromContext(ctx)
20+
21+ cfg := a.buildFavoritesSidebarConfig(bw)
22+ sidebar := component.NewFavoritesSidebar(ctx, cfg)
23+ if sidebar == nil {
24+ log.Warn().Msg("failed to create favorites sidebar")
25+ return
26+ }
27+
28+ bw.favoritesSidebar = sidebar
29+ bw.favoritesSidebar.Hide()
30+ bw.applySidebarWidthConfig(a)
31+ log.Debug().Msg("favorites sidebar initialized")
32+}
33+
34+//nolint:dupl // Favorites and history sidebar configs intentionally mirror each other for separate component types.
35+func (a *App) buildFavoritesSidebarConfig(bw *browserWindow) component.FavoritesSidebarConfig {
36+ var favoritesUC port.FavoritesSidebarFavorites
37+ if a.deps != nil {
38+ favoritesUC = a.deps.FavoritesUC
39+ }
40+ return component.FavoritesSidebarConfig{
41+ FavoritesUC: favoritesUC,
42+ OnNavigate: func(navCtx context.Context, url string) error {
43+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
44+ },
45+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
46+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
47+ },
48+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
49+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
50+ return nil
51+ }
52+ a.activateBrowserWindow(bw)
53+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
54+ },
55+ OnClose: func() {
56+ a.hideAndRestoreFocusForBrowserWindow(bw)
57+ },
58+ }
59+}
60+
61+func (a *App) navigateFavoritesSidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
62+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
63+ return nil
64+ }
65+ return a.navigateFromBrowserWindow(ctx, bw, url)
66+}
67+
68+func (bw *browserWindow) toggleFavoritesSidebar() {
69+ if bw == nil || bw.favoritesSidebar == nil {
70+ return
71+ }
72+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
73+ bw.hideFavoritesSidebar()
74+ return
75+ }
76+ bw.showFavoritesSidebar()
77+}
78+
79+func (bw *browserWindow) showFavoritesSidebar() {
80+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
81+ return
82+ }
83+ bw.mountNativeSidebar(nativeSidebarFavorites)
84+ bw.favoritesSidebar.Show()
85+ bw.mainWindow.SetSidebarVisible(true)
86+ bw.sidebarVisible = true
87+ bw.activeSidebarKind = nativeSidebarFavorites
88+}
89+
90+func (bw *browserWindow) hideFavoritesSidebar() {
91+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
92+ return
93+ }
94+ bw.favoritesSidebar.Hide()
95+ bw.mainWindow.SetSidebarVisible(false)
96+ bw.sidebarVisible = false
97+ if bw.activeSidebarKind == nativeSidebarFavorites {
98+ bw.activeSidebarKind = nativeSidebarNone
99+ }
100+}
101+
102+func (a *App) toggleFavoritesSidebarAction(ctx context.Context) error {
103+ bw := a.lastFocusedBrowserWindow()
104+ if bw == nil {
105+ return fmt.Errorf("favorites sidebar unavailable: no focused browser window")
106+ }
107+ if bw.favoritesSidebar == nil {
108+ return fmt.Errorf("favorites sidebar unavailable: native sidebar not initialized")
109+ }
110+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
111+ a.hideAndRestoreFocusForBrowserWindow(bw)
112+ return nil
113+ }
114+ bw.toggleFavoritesSidebar()
115+ return nil
116+}
117+
118+func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119+ if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120+ return fmt.Errorf("favorites unavailable: usecase not configured")
121+ }
122+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
123+ if wv == nil {
124+ return fmt.Errorf("favorites unavailable: no active webview")
125+ }
126+ uri := strings.TrimSpace(wv.URI())
127+ if uri == "" {
128+ return fmt.Errorf("favorites unavailable: active page has no URI")
129+ }
130+ _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131+ if err != nil {
132+ return err
133+ }
134+ a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135+ return nil
136+}
137+
138+func (a *App) reloadVisibleFavoritesSidebars(reason string) {
139+ if a == nil {
140+ return
141+ }
142+ for _, bw := range a.browserWindows {
143+ if bw == nil || bw.favoritesSidebar == nil {
144+ continue
145+ }
146+ bw.favoritesSidebar.RequestReloadIfVisible(reason)
147+ }
148+}
A internal/ui/browser_window_favorites_sidebar_test.go +208 -0
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
12+ "github.com/bnema/dumber/internal/ui/window"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestBrowserWindow_FavoritesSidebarToggle_CoexistsWithHistorySidebar(t *testing.T) {
18+ bw := &browserWindow{
19+ id: "test-window",
20+ mainWindow: &window.MainWindow{},
21+ historySidebar: &component.HistorySidebar{},
22+ favoritesSidebar: &component.FavoritesSidebar{},
23+ }
24+
25+ bw.showHistorySidebar()
26+ assert.True(t, bw.sidebarVisible)
27+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
28+
29+ bw.toggleFavoritesSidebar()
30+ assert.True(t, bw.sidebarVisible)
31+ assert.Equal(t, nativeSidebarFavorites, bw.activeSidebarKind)
32+
33+ bw.toggleHistorySidebar()
34+ assert.True(t, bw.sidebarVisible)
35+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
36+
37+ bw.toggleHistorySidebar()
38+ assert.False(t, bw.sidebarVisible)
39+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
40+}
41+
42+func TestApp_ToggleFavoritesSidebarActionHidesActiveFavoritesSidebar(t *testing.T) {
43+ bw := &browserWindow{
44+ id: "w",
45+ mainWindow: &window.MainWindow{},
46+ favoritesSidebar: &component.FavoritesSidebar{},
47+ sidebarVisible: true,
48+ activeSidebarKind: nativeSidebarFavorites,
49+ }
50+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
51+
52+ require.NoError(t, app.toggleFavoritesSidebarAction(context.Background()))
53+
54+ assert.False(t, bw.sidebarVisible)
55+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
56+}
57+
58+func TestFavoritesSidebarConfigOnCloseHidesFavoritesSidebar(t *testing.T) {
59+ bw := &browserWindow{
60+ id: "w",
61+ mainWindow: &window.MainWindow{},
62+ favoritesSidebar: &component.FavoritesSidebar{},
63+ sidebarVisible: true,
64+ activeSidebarKind: nativeSidebarFavorites,
65+ }
66+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
67+
68+ cfg := app.buildFavoritesSidebarConfig(bw)
69+ require.NotNil(t, cfg.OnClose)
70+ cfg.OnClose()
71+
72+ assert.False(t, bw.sidebarVisible)
73+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
74+}
75+
76+func TestApp_ToggleFavoritesSidebarActionErrorsWhenUnavailable(t *testing.T) {
77+ app := &App{browserWindows: map[string]*browserWindow{"w": {id: "w"}}, lastFocusedWindowID: "w"}
78+ err := app.toggleFavoritesSidebarAction(context.Background())
79+ require.Error(t, err)
80+ assert.ErrorContains(t, err, "favorites sidebar unavailable")
81+}
82+
83+func TestApp_ToggleCurrentPageFavoriteTogglesActiveWebViewURI(t *testing.T) {
84+ ctx := context.Background()
85+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
86+ tabs := entity.NewTabList()
87+ tabs.Add(tab)
88+ tabs.SetActive(tab.ID)
89+ bw := &browserWindow{id: "w", tabs: tabs}
90+
91+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
92+ wv := &recordingWebView{id: 1, loadURILastURI: "https://example.com/page"}
93+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), wv)
94+
95+ favRepo := newMemoryFavoriteRepo()
96+ app := &App{
97+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(favRepo, &memoryTagRepo{})},
98+ browserWindows: map[string]*browserWindow{bw.id: bw},
99+ lastFocusedWindowID: bw.id,
100+ contentCoord: contentCoord,
101+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
102+ }
103+
104+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
105+ assert.Len(t, favRepo.byURL, 1)
106+ _, ok := favRepo.byURL["https://example.com/page"]
107+ assert.True(t, ok)
108+
109+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
110+ assert.Empty(t, favRepo.byURL)
111+}
112+
113+func TestApp_ToggleCurrentPageFavoriteErrorCases(t *testing.T) {
114+ ctx := context.Background()
115+ require.ErrorContains(t, (&App{}).toggleCurrentPageFavoriteAction(ctx), "usecase not configured")
116+
117+ app := &App{deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})}}
118+ require.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "no active webview")
119+
120+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
121+ tabs := entity.NewTabList()
122+ tabs.Add(tab)
123+ tabs.SetActive(tab.ID)
124+ bw := &browserWindow{id: "w", tabs: tabs}
125+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
126+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), &recordingWebView{id: 2})
127+ app = &App{
128+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})},
129+ browserWindows: map[string]*browserWindow{bw.id: bw},
130+ lastFocusedWindowID: bw.id,
131+ contentCoord: contentCoord,
132+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
133+ }
134+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "active page has no URI")
135+}
136+
137+type memoryFavoriteRepo struct {
138+ nextID entity.FavoriteID
139+ byID map[entity.FavoriteID]*entity.Favorite
140+ byURL map[string]*entity.Favorite
141+ err error
142+}
143+
144+func newMemoryFavoriteRepo() *memoryFavoriteRepo {
145+ return &memoryFavoriteRepo{nextID: 1, byID: map[entity.FavoriteID]*entity.Favorite{}, byURL: map[string]*entity.Favorite{}}
146+}
147+
148+func (r *memoryFavoriteRepo) Save(_ context.Context, fav *entity.Favorite) error {
149+ if r.err != nil {
150+ return r.err
151+ }
152+ if fav.ID == 0 {
153+ fav.ID = r.nextID
154+ r.nextID++
155+ }
156+ cp := *fav
157+ r.byID[fav.ID] = &cp
158+ r.byURL[fav.URL] = &cp
159+ return nil
160+}
161+func (r *memoryFavoriteRepo) FindByID(context.Context, entity.FavoriteID) (*entity.Favorite, error) {
162+ return nil, r.err
163+}
164+func (r *memoryFavoriteRepo) FindByURL(_ context.Context, url string) (*entity.Favorite, error) {
165+ return r.byURL[url], r.err
166+}
167+func (r *memoryFavoriteRepo) GetAll(context.Context) ([]*entity.Favorite, error) { return nil, r.err }
168+func (r *memoryFavoriteRepo) GetByTag(context.Context, entity.TagID) ([]*entity.Favorite, error) {
169+ return nil, r.err
170+}
171+func (r *memoryFavoriteRepo) GetByShortcut(context.Context, int) (*entity.Favorite, error) {
172+ return nil, r.err
173+}
174+func (r *memoryFavoriteRepo) UpdatePosition(context.Context, entity.FavoriteID, int) error {
175+ return r.err
176+}
177+func (r *memoryFavoriteRepo) SetShortcut(context.Context, entity.FavoriteID, *int) error {
178+ return r.err
179+}
180+func (r *memoryFavoriteRepo) Delete(_ context.Context, id entity.FavoriteID) error {
181+ if r.err != nil {
182+ return r.err
183+ }
184+ fav := r.byID[id]
185+ if fav == nil {
186+ return errors.New("favorite not found")
187+ }
188+ delete(r.byID, id)
189+ delete(r.byURL, fav.URL)
190+ return nil
191+}
192+
193+type memoryTagRepo struct{}
194+
195+func (*memoryTagRepo) Save(context.Context, *entity.Tag) error { return nil }
196+func (*memoryTagRepo) FindByID(context.Context, entity.TagID) (*entity.Tag, error) { return nil, nil }
197+func (*memoryTagRepo) FindByName(context.Context, string) (*entity.Tag, error) { return nil, nil }
198+func (*memoryTagRepo) GetAll(context.Context) ([]*entity.Tag, error) { return nil, nil }
199+func (*memoryTagRepo) AssignToFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
200+ return nil
201+}
202+func (*memoryTagRepo) RemoveFromFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
203+ return nil
204+}
205+func (*memoryTagRepo) GetForFavorite(context.Context, entity.FavoriteID) ([]*entity.Tag, error) {
206+ return nil, nil
207+}
208+func (*memoryTagRepo) Delete(context.Context, entity.TagID) error { return nil }
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
12+ "github.com/bnema/dumber/internal/ui/window"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestBrowserWindow_FavoritesSidebarToggle_CoexistsWithHistorySidebar(t *testing.T) {
18+ bw := &browserWindow{
19+ id: "test-window",
20+ mainWindow: &window.MainWindow{},
21+ historySidebar: &component.HistorySidebar{},
22+ favoritesSidebar: &component.FavoritesSidebar{},
23+ }
24+
25+ bw.showHistorySidebar()
26+ assert.True(t, bw.sidebarVisible)
27+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
28+
29+ bw.toggleFavoritesSidebar()
30+ assert.True(t, bw.sidebarVisible)
31+ assert.Equal(t, nativeSidebarFavorites, bw.activeSidebarKind)
32+
33+ bw.toggleHistorySidebar()
34+ assert.True(t, bw.sidebarVisible)
35+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
36+
37+ bw.toggleHistorySidebar()
38+ assert.False(t, bw.sidebarVisible)
39+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
40+}
41+
42+func TestApp_ToggleFavoritesSidebarActionHidesActiveFavoritesSidebar(t *testing.T) {
43+ bw := &browserWindow{
44+ id: "w",
45+ mainWindow: &window.MainWindow{},
46+ favoritesSidebar: &component.FavoritesSidebar{},
47+ sidebarVisible: true,
48+ activeSidebarKind: nativeSidebarFavorites,
49+ }
50+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
51+
52+ require.NoError(t, app.toggleFavoritesSidebarAction(context.Background()))
53+
54+ assert.False(t, bw.sidebarVisible)
55+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
56+}
57+
58+func TestFavoritesSidebarConfigOnCloseHidesFavoritesSidebar(t *testing.T) {
59+ bw := &browserWindow{
60+ id: "w",
61+ mainWindow: &window.MainWindow{},
62+ favoritesSidebar: &component.FavoritesSidebar{},
63+ sidebarVisible: true,
64+ activeSidebarKind: nativeSidebarFavorites,
65+ }
66+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
67+
68+ cfg := app.buildFavoritesSidebarConfig(bw)
69+ require.NotNil(t, cfg.OnClose)
70+ cfg.OnClose()
71+
72+ assert.False(t, bw.sidebarVisible)
73+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
74+}
75+
76+func TestApp_ToggleFavoritesSidebarActionErrorsWhenUnavailable(t *testing.T) {
77+ app := &App{browserWindows: map[string]*browserWindow{"w": {id: "w"}}, lastFocusedWindowID: "w"}
78+ err := app.toggleFavoritesSidebarAction(context.Background())
79+ require.Error(t, err)
80+ assert.ErrorContains(t, err, "favorites sidebar unavailable")
81+}
82+
83+func TestApp_ToggleCurrentPageFavoriteTogglesActiveWebViewURI(t *testing.T) {
84+ ctx := context.Background()
85+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
86+ tabs := entity.NewTabList()
87+ tabs.Add(tab)
88+ tabs.SetActive(tab.ID)
89+ bw := &browserWindow{id: "w", tabs: tabs}
90+
91+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
92+ wv := &recordingWebView{id: 1, loadURILastURI: "https://example.com/page"}
93+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), wv)
94+
95+ favRepo := newMemoryFavoriteRepo()
96+ app := &App{
97+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(favRepo, &memoryTagRepo{})},
98+ browserWindows: map[string]*browserWindow{bw.id: bw},
99+ lastFocusedWindowID: bw.id,
100+ contentCoord: contentCoord,
101+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
102+ }
103+
104+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
105+ assert.Len(t, favRepo.byURL, 1)
106+ _, ok := favRepo.byURL["https://example.com/page"]
107+ assert.True(t, ok)
108+
109+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
110+ assert.Empty(t, favRepo.byURL)
111+}
112+
113+func TestApp_ToggleCurrentPageFavoriteErrorCases(t *testing.T) {
114+ ctx := context.Background()
115+ require.ErrorContains(t, (&App{}).toggleCurrentPageFavoriteAction(ctx), "usecase not configured")
116+
117+ app := &App{deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})}}
118+ require.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "no active webview")
119+
120+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
121+ tabs := entity.NewTabList()
122+ tabs.Add(tab)
123+ tabs.SetActive(tab.ID)
124+ bw := &browserWindow{id: "w", tabs: tabs}
125+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
126+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), &recordingWebView{id: 2})
127+ app = &App{
128+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})},
129+ browserWindows: map[string]*browserWindow{bw.id: bw},
130+ lastFocusedWindowID: bw.id,
131+ contentCoord: contentCoord,
132+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
133+ }
134+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "active page has no URI")
135+}
136+
137+type memoryFavoriteRepo struct {
138+ nextID entity.FavoriteID
139+ byID map[entity.FavoriteID]*entity.Favorite
140+ byURL map[string]*entity.Favorite
141+ err error
142+}
143+
144+func newMemoryFavoriteRepo() *memoryFavoriteRepo {
145+ return &memoryFavoriteRepo{nextID: 1, byID: map[entity.FavoriteID]*entity.Favorite{}, byURL: map[string]*entity.Favorite{}}
146+}
147+
148+func (r *memoryFavoriteRepo) Save(_ context.Context, fav *entity.Favorite) error {
149+ if r.err != nil {
150+ return r.err
151+ }
152+ if fav.ID == 0 {
153+ fav.ID = r.nextID
154+ r.nextID++
155+ }
156+ cp := *fav
157+ r.byID[fav.ID] = &cp
158+ r.byURL[fav.URL] = &cp
159+ return nil
160+}
161+func (r *memoryFavoriteRepo) FindByID(context.Context, entity.FavoriteID) (*entity.Favorite, error) {
162+ return nil, r.err
163+}
164+func (r *memoryFavoriteRepo) FindByURL(_ context.Context, url string) (*entity.Favorite, error) {
165+ return r.byURL[url], r.err
166+}
167+func (r *memoryFavoriteRepo) GetAll(context.Context) ([]*entity.Favorite, error) { return nil, r.err }
168+func (r *memoryFavoriteRepo) GetByTag(context.Context, entity.TagID) ([]*entity.Favorite, error) {
169+ return nil, r.err
170+}
171+func (r *memoryFavoriteRepo) GetByShortcut(context.Context, int) (*entity.Favorite, error) {
172+ return nil, r.err
173+}
174+func (r *memoryFavoriteRepo) UpdatePosition(context.Context, entity.FavoriteID, int) error {
175+ return r.err
176+}
177+func (r *memoryFavoriteRepo) SetShortcut(context.Context, entity.FavoriteID, *int) error {
178+ return r.err
179+}
180+func (r *memoryFavoriteRepo) Delete(_ context.Context, id entity.FavoriteID) error {
181+ if r.err != nil {
182+ return r.err
183+ }
184+ fav := r.byID[id]
185+ if fav == nil {
186+ return errors.New("favorite not found")
187+ }
188+ delete(r.byID, id)
189+ delete(r.byURL, fav.URL)
190+ return nil
191+}
192+
193+type memoryTagRepo struct{}
194+
195+func (*memoryTagRepo) Save(context.Context, *entity.Tag) error { return nil }
196+func (*memoryTagRepo) FindByID(context.Context, entity.TagID) (*entity.Tag, error) { return nil, nil }
197+func (*memoryTagRepo) FindByName(context.Context, string) (*entity.Tag, error) { return nil, nil }
198+func (*memoryTagRepo) GetAll(context.Context) ([]*entity.Tag, error) { return nil, nil }
199+func (*memoryTagRepo) AssignToFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
200+ return nil
201+}
202+func (*memoryTagRepo) RemoveFromFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
203+ return nil
204+}
205+func (*memoryTagRepo) GetForFavorite(context.Context, entity.FavoriteID) ([]*entity.Tag, error) {
206+ return nil, nil
207+}
208+func (*memoryTagRepo) Delete(context.Context, entity.TagID) error { return nil }
M internal/ui/browser_window_history_sidebar.go +32 -2
@@ -34,6 +34,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
3434 bw.historySidebar.Hide()
3535 bw.mainWindow.SetSidebarVisible(false)
3636 bw.sidebarVisible = false
37+ bw.activeSidebarKind = nativeSidebarNone
3738
3839 // Apply sidebar width from config, falling back to the default 320px.
3940 // The width is clamped to [280, 380] by SetSidebarWidth internally.
@@ -44,6 +45,8 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
4445
4546 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
4647 // browser window. Extracted from initHistorySidebar for testability.
48+//
49+//nolint:dupl // History and favorites sidebar configs intentionally mirror each other for separate component types.
4750 func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig {
4851 var historyUC port.HistorySidebarHistory
4952 if a.deps != nil {
@@ -84,7 +87,7 @@ func (bw *browserWindow) toggleHistorySidebar() {
8487 return
8588 }
8689
87- if bw.sidebarVisible {
90+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
8891 bw.hideHistorySidebar()
8992 } else {
9093 bw.showHistorySidebar()
@@ -97,9 +100,11 @@ func (bw *browserWindow) showHistorySidebar() {
97100 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
98101 return
99102 }
103+ bw.mountNativeSidebar(nativeSidebarHistory)
100104 bw.historySidebar.Show()
101105 bw.mainWindow.SetSidebarVisible(true)
102106 bw.sidebarVisible = true
107+ bw.activeSidebarKind = nativeSidebarHistory
103108 }
104109
105110 // hideHistorySidebar hides the sidebar. Callers should also restore focus
@@ -111,6 +116,31 @@ func (bw *browserWindow) hideHistorySidebar() {
111116 bw.historySidebar.Hide()
112117 bw.mainWindow.SetSidebarVisible(false)
113118 bw.sidebarVisible = false
119+ if bw.activeSidebarKind == nativeSidebarHistory {
120+ bw.activeSidebarKind = nativeSidebarNone
121+ }
122+}
123+
124+func (bw *browserWindow) mountNativeSidebar(kind nativeSidebarKind) {
125+ if bw == nil || bw.mainWindow == nil || bw.activeSidebarKind == kind {
126+ return
127+ }
128+ if bw.historySidebar != nil {
129+ bw.historySidebar.Hide()
130+ }
131+ if bw.favoritesSidebar != nil {
132+ bw.favoritesSidebar.Hide()
133+ }
134+ switch kind {
135+ case nativeSidebarHistory:
136+ if bw.historySidebar != nil {
137+ bw.mainWindow.SetSidebarWidget(bw.historySidebar.Widget())
138+ }
139+ case nativeSidebarFavorites:
140+ if bw.favoritesSidebar != nil {
141+ bw.mainWindow.SetSidebarWidget(bw.favoritesSidebar.Widget())
142+ }
143+ }
114144 }
115145
116146 // historySidebarWidthConfig extracts the config-backed sidebar width and
@@ -143,7 +173,7 @@ func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
143173 if bw.historySidebar == nil {
144174 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
145175 }
146- if bw.sidebarVisible {
176+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
147177 a.hideAndRestoreFocusForBrowserWindow(bw)
148178 return nil
149179 }
@@ -34,6 +34,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
34 bw.historySidebar.Hide()34 bw.historySidebar.Hide()
35 bw.mainWindow.SetSidebarVisible(false)35 bw.mainWindow.SetSidebarVisible(false)
36 bw.sidebarVisible = false36 bw.sidebarVisible = false
37+ bw.activeSidebarKind = nativeSidebarNone
37 38
38 // Apply sidebar width from config, falling back to the default 320px.39 // Apply sidebar width from config, falling back to the default 320px.
39 // The width is clamped to [280, 380] by SetSidebarWidth internally.40 // The width is clamped to [280, 380] by SetSidebarWidth internally.
@@ -44,6 +45,8 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
44 45
45 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given46 // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given
46 // browser window. Extracted from initHistorySidebar for testability.47 // browser window. Extracted from initHistorySidebar for testability.
48+//
49+//nolint:dupl // History and favorites sidebar configs intentionally mirror each other for separate component types.
47 func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig {50 func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig {
48 var historyUC port.HistorySidebarHistory51 var historyUC port.HistorySidebarHistory
49 if a.deps != nil {52 if a.deps != nil {
@@ -84,7 +87,7 @@ func (bw *browserWindow) toggleHistorySidebar() {
84 return87 return
85 }88 }
86 89
87- if bw.sidebarVisible {90+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
88 bw.hideHistorySidebar()91 bw.hideHistorySidebar()
89 } else {92 } else {
90 bw.showHistorySidebar()93 bw.showHistorySidebar()
@@ -97,9 +100,11 @@ func (bw *browserWindow) showHistorySidebar() {
97 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {100 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
98 return101 return
99 }102 }
103+ bw.mountNativeSidebar(nativeSidebarHistory)
100 bw.historySidebar.Show()104 bw.historySidebar.Show()
101 bw.mainWindow.SetSidebarVisible(true)105 bw.mainWindow.SetSidebarVisible(true)
102 bw.sidebarVisible = true106 bw.sidebarVisible = true
107+ bw.activeSidebarKind = nativeSidebarHistory
103 }108 }
104 109
105 // hideHistorySidebar hides the sidebar. Callers should also restore focus110 // hideHistorySidebar hides the sidebar. Callers should also restore focus
@@ -111,6 +116,31 @@ func (bw *browserWindow) hideHistorySidebar() {
111 bw.historySidebar.Hide()116 bw.historySidebar.Hide()
112 bw.mainWindow.SetSidebarVisible(false)117 bw.mainWindow.SetSidebarVisible(false)
113 bw.sidebarVisible = false118 bw.sidebarVisible = false
119+ if bw.activeSidebarKind == nativeSidebarHistory {
120+ bw.activeSidebarKind = nativeSidebarNone
121+ }
122+}
123+
124+func (bw *browserWindow) mountNativeSidebar(kind nativeSidebarKind) {
125+ if bw == nil || bw.mainWindow == nil || bw.activeSidebarKind == kind {
126+ return
127+ }
128+ if bw.historySidebar != nil {
129+ bw.historySidebar.Hide()
130+ }
131+ if bw.favoritesSidebar != nil {
132+ bw.favoritesSidebar.Hide()
133+ }
134+ switch kind {
135+ case nativeSidebarHistory:
136+ if bw.historySidebar != nil {
137+ bw.mainWindow.SetSidebarWidget(bw.historySidebar.Widget())
138+ }
139+ case nativeSidebarFavorites:
140+ if bw.favoritesSidebar != nil {
141+ bw.mainWindow.SetSidebarWidget(bw.favoritesSidebar.Widget())
142+ }
143+ }
114 }144 }
115 145
116 // historySidebarWidthConfig extracts the config-backed sidebar width and146 // historySidebarWidthConfig extracts the config-backed sidebar width and
@@ -143,7 +173,7 @@ func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
143 if bw.historySidebar == nil {173 if bw.historySidebar == nil {
144 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")174 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
145 }175 }
146- if bw.sidebarVisible {176+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
147 a.hideAndRestoreFocusForBrowserWindow(bw)177 a.hideAndRestoreFocusForBrowserWindow(bw)
148 return nil178 return nil
149 }179 }
A internal/ui/component/favorites_sidebar.go +372 -0
@@ -0,0 +1,372 @@
1+package component
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+const favoritesSidebarOuterSpacing = 6
17+
18+// FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
19+type FavoritesSidebarConfig struct {
20+ FavoritesUC port.FavoritesSidebarFavorites
21+ OnNavigate func(ctx context.Context, url string) error
22+ OnNavigateKeepOpen func(ctx context.Context, url string) error
23+ OnOpenInNewPane func(ctx context.Context, url string) error
24+ OnClose func()
25+}
26+
27+// FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
28+type FavoritesSidebar struct {
29+ outerBox *gtk.Box
30+ searchEntry *gtk.SearchEntry
31+ tagBox *gtk.Box
32+ scrolledWin *gtk.ScrolledWindow
33+ listBox *gtk.ListBox
34+ formBox *gtk.Box
35+
36+ formURLEntry *gtk.SearchEntry
37+ formTitleEntry *gtk.SearchEntry
38+ formTagsEntry *gtk.SearchEntry
39+ formShortcutEntry *gtk.SearchEntry
40+ formSaveButton *gtk.Button
41+ formURL string
42+ formTitle string
43+ formTags string
44+ formShortcut string
45+
46+ favoritesUC port.FavoritesSidebarFavorites
47+ onNavigate func(ctx context.Context, url string) error
48+ onNavigateKeepOpen func(ctx context.Context, url string) error
49+ onOpenInNewPane func(ctx context.Context, url string) error
50+ onClose func()
51+
52+ allFavorites []*entity.Favorite
53+ allTags []*entity.Tag
54+ selectedTagIDs map[entity.TagID]struct{}
55+ displayRows []favoriteSidebarDisplayRow
56+ currentQuery string
57+ notice string
58+ loadGen uint64
59+ destroyed bool
60+ visible bool
61+ focusZone favoritesSidebarFocusZone
62+ mode favoritesSidebarMode
63+ editingID entity.FavoriteID
64+ confirmDelete bool
65+ confirmDeleteID entity.FavoriteID
66+
67+ retainedCallbacks []interface{}
68+ tagCallbacks []interface{}
69+ ctx context.Context
70+ cancel context.CancelFunc
71+ mu sync.RWMutex
72+
73+ idleScheduler func(glib.SourceFunc)
74+}
75+
76+// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
77+func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
78+ ctx, cancel := context.WithCancel(ctx)
79+ fs := &FavoritesSidebar{
80+ favoritesUC: cfg.FavoritesUC,
81+ onNavigate: cfg.OnNavigate,
82+ onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
83+ onOpenInNewPane: cfg.OnOpenInNewPane,
84+ onClose: cfg.OnClose,
85+ selectedTagIDs: make(map[entity.TagID]struct{}),
86+ ctx: ctx,
87+ cancel: cancel,
88+ }
89+ if err := fs.createWidgets(); err != nil {
90+ cancel()
91+ return nil
92+ }
93+ fs.setupSearchHandler()
94+ fs.setupKeyboardNavigation()
95+ fs.startLoad()
96+ return fs
97+}
98+
99+// Widget returns the raw GTK widget.
100+func (fs *FavoritesSidebar) Widget() *gtk.Widget {
101+ if fs == nil || fs.outerBox == nil {
102+ return nil
103+ }
104+ return &fs.outerBox.Widget
105+}
106+
107+// Show displays the sidebar and focuses search. Nil-safe.
108+func (fs *FavoritesSidebar) Show() {
109+ if fs == nil {
110+ return
111+ }
112+ fs.mu.Lock()
113+ if fs.destroyed || fs.outerBox == nil {
114+ fs.mu.Unlock()
115+ return
116+ }
117+ fs.outerBox.SetVisible(true)
118+ fs.visible = true
119+ fs.mu.Unlock()
120+ fs.startLoad()
121+ fs.focusSearch()
122+}
123+
124+// Hide hides the sidebar. Nil-safe.
125+func (fs *FavoritesSidebar) Hide() {
126+ if fs == nil {
127+ return
128+ }
129+ fs.mu.Lock()
130+ if fs.destroyed {
131+ fs.mu.Unlock()
132+ return
133+ }
134+ if fs.outerBox != nil {
135+ fs.outerBox.SetVisible(false)
136+ }
137+ fs.visible = false
138+ fs.mu.Unlock()
139+}
140+
141+// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
142+func (fs *FavoritesSidebar) Destroy() {
143+ if fs == nil {
144+ return
145+ }
146+ fs.mu.Lock()
147+ if fs.destroyed {
148+ fs.mu.Unlock()
149+ return
150+ }
151+ fs.destroyed = true
152+ fs.mu.Unlock()
153+ if fs.cancel != nil {
154+ fs.cancel()
155+ }
156+}
157+
158+func (fs *FavoritesSidebar) createWidgets() error {
159+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing)
160+ if fs.outerBox == nil {
161+ return fmt.Errorf("favorites sidebar: outer box creation failed")
162+ }
163+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
164+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
165+ fs.outerBox.SetHexpand(false)
166+ fs.outerBox.SetVexpand(true)
167+ fs.outerBox.SetVisible(false)
168+
169+ fs.searchEntry = gtk.NewSearchEntry()
170+ if fs.searchEntry == nil {
171+ return fmt.Errorf("favorites sidebar: search entry creation failed")
172+ }
173+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
174+ fs.searchEntry.SetHexpand(true)
175+ placeholder := "Search favorites"
176+ fs.searchEntry.SetPlaceholderText(&placeholder)
177+ fs.outerBox.Append(&fs.searchEntry.Widget)
178+
179+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
180+ if fs.tagBox == nil {
181+ return fmt.Errorf("favorites sidebar: tag box creation failed")
182+ }
183+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
184+ fs.tagBox.SetHexpand(true)
185+ fs.outerBox.Append(&fs.tagBox.Widget)
186+
187+ fs.scrolledWin = gtk.NewScrolledWindow()
188+ if fs.scrolledWin == nil {
189+ return fmt.Errorf("favorites sidebar: scrolled window creation failed")
190+ }
191+ fs.scrolledWin.SetVexpand(true)
192+ fs.scrolledWin.SetHexpand(true)
193+ fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
194+
195+ fs.listBox = gtk.NewListBox()
196+ if fs.listBox == nil {
197+ return fmt.Errorf("favorites sidebar: list box creation failed")
198+ }
199+ fs.listBox.AddCssClass("favorites-sidebar-list")
200+ fs.listBox.SetActivateOnSingleClick(true)
201+ fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
202+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
203+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
204+ fs.onRowActivated(row)
205+ }
206+ fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
207+ fs.listBox.ConnectRowActivated(&rowActivatedCb)
208+
209+ fs.scrolledWin.SetChild(&fs.listBox.Widget)
210+ fs.outerBox.Append(&fs.scrolledWin.Widget)
211+
212+ fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
213+ if fs.formBox == nil {
214+ return fmt.Errorf("favorites sidebar: form box creation failed")
215+ }
216+ fs.formBox.AddCssClass("favorites-sidebar-form")
217+ fs.formBox.SetVisible(false)
218+ fs.outerBox.Append(&fs.formBox.Widget)
219+ return nil
220+}
221+
222+func (fs *FavoritesSidebar) focusSearch() {
223+ if fs == nil {
224+ return
225+ }
226+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
227+ fs.mu.RLock()
228+ destroyed := fs.destroyed
229+ entry := fs.searchEntry
230+ fs.mu.RUnlock()
231+ if !destroyed && entry != nil {
232+ entry.GrabFocus()
233+ }
234+ return false
235+ }))
236+}
237+
238+// RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible.
239+func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) {
240+ if fs == nil {
241+ return
242+ }
243+ fs.mu.RLock()
244+ visible := fs.visible && !fs.destroyed
245+ fs.mu.RUnlock()
246+ if visible {
247+ fs.startLoad()
248+ }
249+}
250+
251+func (fs *FavoritesSidebar) startLoad() {
252+ if fs == nil {
253+ return
254+ }
255+ fs.mu.Lock()
256+ if fs.destroyed {
257+ fs.mu.Unlock()
258+ return
259+ }
260+ fs.loadGen++
261+ gen := fs.loadGen
262+ uc := fs.favoritesUC
263+ ctx := fs.ctx
264+ visible := fs.visible
265+ fs.notice = "Loading favorites..."
266+ fs.mu.Unlock()
267+ if visible {
268+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
269+ fs.mu.RLock()
270+ destroyed := fs.destroyed
271+ fs.mu.RUnlock()
272+ if !destroyed {
273+ fs.rebuildList()
274+ }
275+ return false
276+ }))
277+ }
278+
279+ if uc == nil || ctx == nil {
280+ fs.applyLoadedData(nil, nil, gen, nil)
281+ return
282+ }
283+ go func() {
284+ favorites, favErr := uc.GetAll(ctx)
285+ tags, tagErr := uc.GetAllTags(ctx)
286+ err := favErr
287+ if err == nil {
288+ err = tagErr
289+ }
290+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
291+ fs.applyLoadedData(favorites, tags, gen, err)
292+ return false
293+ }))
294+ }()
295+}
296+
297+func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
298+ if fs == nil {
299+ return false
300+ }
301+ fs.mu.Lock()
302+ if fs.destroyed || gen != fs.loadGen {
303+ fs.mu.Unlock()
304+ return false
305+ }
306+ if favorites == nil {
307+ favorites = []*entity.Favorite{}
308+ }
309+ if tags == nil {
310+ tags = []*entity.Tag{}
311+ }
312+ fs.allFavorites = favorites
313+ fs.allTags = tags
314+ if err != nil {
315+ fs.notice = err.Error()
316+ } else {
317+ fs.notice = ""
318+ }
319+ fs.rebuildDisplayRowsLocked()
320+ fs.mu.Unlock()
321+ fs.renderTags()
322+ fs.rebuildList()
323+ return true
324+}
325+
326+func (fs *FavoritesSidebar) setupSearchHandler() {
327+ if fs == nil || fs.searchEntry == nil {
328+ return
329+ }
330+ changedCb := func(_ gtk.SearchEntry) {
331+ fs.mu.RLock()
332+ destroyed := fs.destroyed
333+ fs.mu.RUnlock()
334+ if destroyed {
335+ return
336+ }
337+ fs.onSearchChanged()
338+ }
339+ fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
340+ fs.searchEntry.ConnectSearchChanged(&changedCb)
341+}
342+
343+func (fs *FavoritesSidebar) onSearchChanged() {
344+ fs.mu.Lock()
345+ if fs.destroyed {
346+ fs.mu.Unlock()
347+ return
348+ }
349+ if fs.searchEntry != nil {
350+ fs.currentQuery = fs.searchEntry.GetText()
351+ }
352+ fs.rebuildDisplayRowsLocked()
353+ fs.mu.Unlock()
354+ fs.rebuildList()
355+}
356+
357+func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
358+ query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
359+ for id := range fs.selectedTagIDs {
360+ query.TagIDs = append(query.TagIDs, id)
361+ }
362+ filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
363+ fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
364+}
365+
366+func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
367+ if fs != nil && fs.idleScheduler != nil {
368+ fs.idleScheduler(cb)
369+ return
370+ }
371+ glib.IdleAdd(&cb, 0)
372+}
@@ -0,0 +1,372 @@
1+package component
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+const favoritesSidebarOuterSpacing = 6
17+
18+// FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
19+type FavoritesSidebarConfig struct {
20+ FavoritesUC port.FavoritesSidebarFavorites
21+ OnNavigate func(ctx context.Context, url string) error
22+ OnNavigateKeepOpen func(ctx context.Context, url string) error
23+ OnOpenInNewPane func(ctx context.Context, url string) error
24+ OnClose func()
25+}
26+
27+// FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
28+type FavoritesSidebar struct {
29+ outerBox *gtk.Box
30+ searchEntry *gtk.SearchEntry
31+ tagBox *gtk.Box
32+ scrolledWin *gtk.ScrolledWindow
33+ listBox *gtk.ListBox
34+ formBox *gtk.Box
35+
36+ formURLEntry *gtk.SearchEntry
37+ formTitleEntry *gtk.SearchEntry
38+ formTagsEntry *gtk.SearchEntry
39+ formShortcutEntry *gtk.SearchEntry
40+ formSaveButton *gtk.Button
41+ formURL string
42+ formTitle string
43+ formTags string
44+ formShortcut string
45+
46+ favoritesUC port.FavoritesSidebarFavorites
47+ onNavigate func(ctx context.Context, url string) error
48+ onNavigateKeepOpen func(ctx context.Context, url string) error
49+ onOpenInNewPane func(ctx context.Context, url string) error
50+ onClose func()
51+
52+ allFavorites []*entity.Favorite
53+ allTags []*entity.Tag
54+ selectedTagIDs map[entity.TagID]struct{}
55+ displayRows []favoriteSidebarDisplayRow
56+ currentQuery string
57+ notice string
58+ loadGen uint64
59+ destroyed bool
60+ visible bool
61+ focusZone favoritesSidebarFocusZone
62+ mode favoritesSidebarMode
63+ editingID entity.FavoriteID
64+ confirmDelete bool
65+ confirmDeleteID entity.FavoriteID
66+
67+ retainedCallbacks []interface{}
68+ tagCallbacks []interface{}
69+ ctx context.Context
70+ cancel context.CancelFunc
71+ mu sync.RWMutex
72+
73+ idleScheduler func(glib.SourceFunc)
74+}
75+
76+// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
77+func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
78+ ctx, cancel := context.WithCancel(ctx)
79+ fs := &FavoritesSidebar{
80+ favoritesUC: cfg.FavoritesUC,
81+ onNavigate: cfg.OnNavigate,
82+ onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
83+ onOpenInNewPane: cfg.OnOpenInNewPane,
84+ onClose: cfg.OnClose,
85+ selectedTagIDs: make(map[entity.TagID]struct{}),
86+ ctx: ctx,
87+ cancel: cancel,
88+ }
89+ if err := fs.createWidgets(); err != nil {
90+ cancel()
91+ return nil
92+ }
93+ fs.setupSearchHandler()
94+ fs.setupKeyboardNavigation()
95+ fs.startLoad()
96+ return fs
97+}
98+
99+// Widget returns the raw GTK widget.
100+func (fs *FavoritesSidebar) Widget() *gtk.Widget {
101+ if fs == nil || fs.outerBox == nil {
102+ return nil
103+ }
104+ return &fs.outerBox.Widget
105+}
106+
107+// Show displays the sidebar and focuses search. Nil-safe.
108+func (fs *FavoritesSidebar) Show() {
109+ if fs == nil {
110+ return
111+ }
112+ fs.mu.Lock()
113+ if fs.destroyed || fs.outerBox == nil {
114+ fs.mu.Unlock()
115+ return
116+ }
117+ fs.outerBox.SetVisible(true)
118+ fs.visible = true
119+ fs.mu.Unlock()
120+ fs.startLoad()
121+ fs.focusSearch()
122+}
123+
124+// Hide hides the sidebar. Nil-safe.
125+func (fs *FavoritesSidebar) Hide() {
126+ if fs == nil {
127+ return
128+ }
129+ fs.mu.Lock()
130+ if fs.destroyed {
131+ fs.mu.Unlock()
132+ return
133+ }
134+ if fs.outerBox != nil {
135+ fs.outerBox.SetVisible(false)
136+ }
137+ fs.visible = false
138+ fs.mu.Unlock()
139+}
140+
141+// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
142+func (fs *FavoritesSidebar) Destroy() {
143+ if fs == nil {
144+ return
145+ }
146+ fs.mu.Lock()
147+ if fs.destroyed {
148+ fs.mu.Unlock()
149+ return
150+ }
151+ fs.destroyed = true
152+ fs.mu.Unlock()
153+ if fs.cancel != nil {
154+ fs.cancel()
155+ }
156+}
157+
158+func (fs *FavoritesSidebar) createWidgets() error {
159+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing)
160+ if fs.outerBox == nil {
161+ return fmt.Errorf("favorites sidebar: outer box creation failed")
162+ }
163+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
164+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
165+ fs.outerBox.SetHexpand(false)
166+ fs.outerBox.SetVexpand(true)
167+ fs.outerBox.SetVisible(false)
168+
169+ fs.searchEntry = gtk.NewSearchEntry()
170+ if fs.searchEntry == nil {
171+ return fmt.Errorf("favorites sidebar: search entry creation failed")
172+ }
173+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
174+ fs.searchEntry.SetHexpand(true)
175+ placeholder := "Search favorites"
176+ fs.searchEntry.SetPlaceholderText(&placeholder)
177+ fs.outerBox.Append(&fs.searchEntry.Widget)
178+
179+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
180+ if fs.tagBox == nil {
181+ return fmt.Errorf("favorites sidebar: tag box creation failed")
182+ }
183+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
184+ fs.tagBox.SetHexpand(true)
185+ fs.outerBox.Append(&fs.tagBox.Widget)
186+
187+ fs.scrolledWin = gtk.NewScrolledWindow()
188+ if fs.scrolledWin == nil {
189+ return fmt.Errorf("favorites sidebar: scrolled window creation failed")
190+ }
191+ fs.scrolledWin.SetVexpand(true)
192+ fs.scrolledWin.SetHexpand(true)
193+ fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
194+
195+ fs.listBox = gtk.NewListBox()
196+ if fs.listBox == nil {
197+ return fmt.Errorf("favorites sidebar: list box creation failed")
198+ }
199+ fs.listBox.AddCssClass("favorites-sidebar-list")
200+ fs.listBox.SetActivateOnSingleClick(true)
201+ fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
202+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
203+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
204+ fs.onRowActivated(row)
205+ }
206+ fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
207+ fs.listBox.ConnectRowActivated(&rowActivatedCb)
208+
209+ fs.scrolledWin.SetChild(&fs.listBox.Widget)
210+ fs.outerBox.Append(&fs.scrolledWin.Widget)
211+
212+ fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
213+ if fs.formBox == nil {
214+ return fmt.Errorf("favorites sidebar: form box creation failed")
215+ }
216+ fs.formBox.AddCssClass("favorites-sidebar-form")
217+ fs.formBox.SetVisible(false)
218+ fs.outerBox.Append(&fs.formBox.Widget)
219+ return nil
220+}
221+
222+func (fs *FavoritesSidebar) focusSearch() {
223+ if fs == nil {
224+ return
225+ }
226+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
227+ fs.mu.RLock()
228+ destroyed := fs.destroyed
229+ entry := fs.searchEntry
230+ fs.mu.RUnlock()
231+ if !destroyed && entry != nil {
232+ entry.GrabFocus()
233+ }
234+ return false
235+ }))
236+}
237+
238+// RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible.
239+func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) {
240+ if fs == nil {
241+ return
242+ }
243+ fs.mu.RLock()
244+ visible := fs.visible && !fs.destroyed
245+ fs.mu.RUnlock()
246+ if visible {
247+ fs.startLoad()
248+ }
249+}
250+
251+func (fs *FavoritesSidebar) startLoad() {
252+ if fs == nil {
253+ return
254+ }
255+ fs.mu.Lock()
256+ if fs.destroyed {
257+ fs.mu.Unlock()
258+ return
259+ }
260+ fs.loadGen++
261+ gen := fs.loadGen
262+ uc := fs.favoritesUC
263+ ctx := fs.ctx
264+ visible := fs.visible
265+ fs.notice = "Loading favorites..."
266+ fs.mu.Unlock()
267+ if visible {
268+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
269+ fs.mu.RLock()
270+ destroyed := fs.destroyed
271+ fs.mu.RUnlock()
272+ if !destroyed {
273+ fs.rebuildList()
274+ }
275+ return false
276+ }))
277+ }
278+
279+ if uc == nil || ctx == nil {
280+ fs.applyLoadedData(nil, nil, gen, nil)
281+ return
282+ }
283+ go func() {
284+ favorites, favErr := uc.GetAll(ctx)
285+ tags, tagErr := uc.GetAllTags(ctx)
286+ err := favErr
287+ if err == nil {
288+ err = tagErr
289+ }
290+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
291+ fs.applyLoadedData(favorites, tags, gen, err)
292+ return false
293+ }))
294+ }()
295+}
296+
297+func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
298+ if fs == nil {
299+ return false
300+ }
301+ fs.mu.Lock()
302+ if fs.destroyed || gen != fs.loadGen {
303+ fs.mu.Unlock()
304+ return false
305+ }
306+ if favorites == nil {
307+ favorites = []*entity.Favorite{}
308+ }
309+ if tags == nil {
310+ tags = []*entity.Tag{}
311+ }
312+ fs.allFavorites = favorites
313+ fs.allTags = tags
314+ if err != nil {
315+ fs.notice = err.Error()
316+ } else {
317+ fs.notice = ""
318+ }
319+ fs.rebuildDisplayRowsLocked()
320+ fs.mu.Unlock()
321+ fs.renderTags()
322+ fs.rebuildList()
323+ return true
324+}
325+
326+func (fs *FavoritesSidebar) setupSearchHandler() {
327+ if fs == nil || fs.searchEntry == nil {
328+ return
329+ }
330+ changedCb := func(_ gtk.SearchEntry) {
331+ fs.mu.RLock()
332+ destroyed := fs.destroyed
333+ fs.mu.RUnlock()
334+ if destroyed {
335+ return
336+ }
337+ fs.onSearchChanged()
338+ }
339+ fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
340+ fs.searchEntry.ConnectSearchChanged(&changedCb)
341+}
342+
343+func (fs *FavoritesSidebar) onSearchChanged() {
344+ fs.mu.Lock()
345+ if fs.destroyed {
346+ fs.mu.Unlock()
347+ return
348+ }
349+ if fs.searchEntry != nil {
350+ fs.currentQuery = fs.searchEntry.GetText()
351+ }
352+ fs.rebuildDisplayRowsLocked()
353+ fs.mu.Unlock()
354+ fs.rebuildList()
355+}
356+
357+func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
358+ query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
359+ for id := range fs.selectedTagIDs {
360+ query.TagIDs = append(query.TagIDs, id)
361+ }
362+ filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
363+ fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
364+}
365+
366+func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
367+ if fs != nil && fs.idleScheduler != nil {
368+ fs.idleScheduler(cb)
369+ return
370+ }
371+ glib.IdleAdd(&cb, 0)
372+}
A internal/ui/component/favorites_sidebar_forms.go +460 -0
@@ -0,0 +1,460 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+type favoritesSidebarMode int
16+
17+const (
18+ favoritesSidebarModeNone favoritesSidebarMode = iota
19+ favoritesSidebarModeAdd
20+ favoritesSidebarModeEdit
21+ favoritesSidebarModeTag
22+ favoritesSidebarModeShortcut
23+)
24+
25+type favoritesSidebarFocusZone int
26+
27+const (
28+ favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota
29+ favoritesSidebarFocusTags
30+ favoritesSidebarFocusList
31+ favoritesSidebarFocusForm
32+ favoritesSidebarFocusConfirm
33+)
34+
35+func (fs *FavoritesSidebar) beginAddForm() {
36+ fs.mu.Lock()
37+ if fs.destroyed {
38+ fs.mu.Unlock()
39+ return
40+ }
41+ fs.mode = favoritesSidebarModeAdd
42+ fs.editingID = 0
43+ fs.confirmDelete = false
44+ fs.confirmDeleteID = 0
45+ fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save."
46+ fs.mu.Unlock()
47+ fs.renderForm(nil)
48+ fs.rebuildList()
49+}
50+
51+func (fs *FavoritesSidebar) beginEditForm() {
52+ fav := fs.selectedFavorite()
53+ if fav == nil {
54+ fs.setNotice("Select a favorite to edit")
55+ return
56+ }
57+ fs.mu.Lock()
58+ if fs.destroyed {
59+ fs.mu.Unlock()
60+ return
61+ }
62+ fs.mode = favoritesSidebarModeEdit
63+ fs.editingID = fav.ID
64+ fs.confirmDelete = false
65+ fs.confirmDeleteID = 0
66+ fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save."
67+ fs.mu.Unlock()
68+ fs.renderForm(fav)
69+ fs.rebuildList()
70+}
71+
72+func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
73+ if fs == nil || fs.formBox == nil {
74+ return
75+ }
76+ clearBoxChildren(fs.formBox)
77+ fs.formURLEntry = gtk.NewSearchEntry()
78+ fs.formTitleEntry = gtk.NewSearchEntry()
79+ fs.formTagsEntry = nil
80+ if fav == nil {
81+ fs.formTagsEntry = gtk.NewSearchEntry()
82+ }
83+ fs.formShortcutEntry = gtk.NewSearchEntry()
84+ entries := []struct {
85+ entry *gtk.SearchEntry
86+ label string
87+ }{
88+ {entry: fs.formURLEntry, label: "URL"},
89+ {entry: fs.formTitleEntry, label: "Title"},
90+ {entry: fs.formTagsEntry, label: "Tag IDs"},
91+ {entry: fs.formShortcutEntry, label: "Shortcut"},
92+ }
93+ for _, item := range entries {
94+ if item.entry == nil {
95+ continue
96+ }
97+ placeholder := item.label
98+ item.entry.SetPlaceholderText(&placeholder)
99+ fs.formBox.Append(&item.entry.Widget)
100+ }
101+ fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102+ if fs.formSaveButton != nil {
103+ cb := func(_ gtk.Button) {
104+ fs.submitForm()
105+ }
106+ fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
107+ fs.formSaveButton.ConnectClicked(&cb)
108+ fs.formBox.Append(&fs.formSaveButton.Widget)
109+ }
110+ if fav != nil {
111+ fs.formURL = fav.URL
112+ fs.formTitle = fav.Title
113+ fs.formTags = joinFavoriteTagIDs(fav.Tags)
114+ fs.formShortcut = ""
115+ if fav.ShortcutKey != nil {
116+ fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
117+ }
118+ if fs.formURLEntry != nil {
119+ fs.formURLEntry.SetText(fs.formURL)
120+ fs.formURLEntry.SetEditable(false)
121+ }
122+ if fs.formTitleEntry != nil {
123+ fs.formTitleEntry.SetText(fs.formTitle)
124+ }
125+ if fs.formTagsEntry != nil {
126+ fs.formTagsEntry.SetText(fs.formTags)
127+ }
128+ if fs.formShortcutEntry != nil {
129+ fs.formShortcutEntry.SetText(fs.formShortcut)
130+ }
131+ } else {
132+ fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
133+ }
134+ fs.formBox.SetVisible(true)
135+ fs.focusForm()
136+}
137+
138+func (fs *FavoritesSidebar) focusForm() {
139+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
140+ if fs == nil {
141+ return false
142+ }
143+ fs.mu.RLock()
144+ mode := fs.mode
145+ destroyed := fs.destroyed
146+ fs.mu.RUnlock()
147+ if destroyed {
148+ return false
149+ }
150+ if mode == favoritesSidebarModeEdit && fs.formTitleEntry != nil {
151+ fs.formTitleEntry.GrabFocus()
152+ return false
153+ }
154+ if fs.formURLEntry != nil {
155+ fs.formURLEntry.GrabFocus()
156+ }
157+ return false
158+ }))
159+}
160+
161+func (fs *FavoritesSidebar) cancelManagement() bool {
162+ fs.mu.Lock()
163+ if fs.destroyed {
164+ fs.mu.Unlock()
165+ return false
166+ }
167+ active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete
168+ fs.mode = favoritesSidebarModeNone
169+ fs.editingID = 0
170+ fs.confirmDelete = false
171+ fs.confirmDeleteID = 0
172+ if active {
173+ fs.notice = ""
174+ }
175+ fs.mu.Unlock()
176+ if fs.formBox != nil {
177+ fs.formBox.SetVisible(false)
178+ clearBoxChildren(fs.formBox)
179+ }
180+ fs.rebuildList()
181+ return active
182+}
183+
184+func (fs *FavoritesSidebar) submitForm() bool {
185+ if fs == nil {
186+ return false
187+ }
188+ fs.mu.RLock()
189+ mode := fs.mode
190+ id := fs.editingID
191+ uc := fs.favoritesUC
192+ ctx := fs.ctx
193+ fs.mu.RUnlock()
194+ if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195+ return false
196+ }
197+ url, title, tagsText, shortcutText := fs.formValues()
198+ if mode == favoritesSidebarModeAdd {
199+ tags, err := parseTagIDs(tagsText)
200+ if err != nil {
201+ fs.setNotice(err.Error())
202+ return true
203+ }
204+ key, err := parseShortcut(shortcutText)
205+ if err != nil {
206+ fs.setNotice(err.Error())
207+ return true
208+ }
209+ input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags}
210+ fav, err := uc.AddFavorite(ctx, input)
211+ if err != nil {
212+ fs.setNotice(err.Error())
213+ return true
214+ }
215+ if key != nil && fav != nil {
216+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
217+ fs.cancelManagement()
218+ fs.startLoad()
219+ fs.setNotice("Added favorite, but failed to set shortcut: " + err.Error())
220+ return true
221+ }
222+ }
223+ } else {
224+ key, err := parseShortcut(shortcutText)
225+ if err != nil {
226+ fs.setNotice(err.Error())
227+ return true
228+ }
229+ _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: true})
230+ if err != nil {
231+ fs.setNotice(err.Error())
232+ return true
233+ }
234+ }
235+ fs.cancelManagement()
236+ fs.startLoad()
237+ return true
238+}
239+
240+func (fs *FavoritesSidebar) enterTagMode() {
241+ fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242+}
243+func (fs *FavoritesSidebar) enterShortcutMode() {
244+ fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245+}
246+
247+func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) {
248+ fs.mu.Lock()
249+ if !fs.destroyed {
250+ fs.mode = mode
251+ fs.confirmDelete = false
252+ fs.confirmDeleteID = 0
253+ fs.notice = notice
254+ }
255+ fs.mu.Unlock()
256+ fs.rebuildList()
257+}
258+
259+func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool {
260+ fav := fs.selectedFavorite()
261+ if fav == nil || ord < 1 {
262+ fs.setNotice("Select a favorite and tag 1-9")
263+ return true
264+ }
265+ fs.mu.RLock()
266+ if ord > len(fs.allTags) || fs.favoritesUC == nil {
267+ fs.mu.RUnlock()
268+ fs.setNotice("No tag for key")
269+ return true
270+ }
271+ tagID := fs.allTags[ord-1].ID
272+ uc := fs.favoritesUC
273+ ctx := fs.ctx
274+ fs.mu.RUnlock()
275+ has := false
276+ for _, tag := range fav.Tags {
277+ if tag.ID == tagID {
278+ has = true
279+ break
280+ }
281+ }
282+ var err error
283+ if has {
284+ err = uc.UntagFavorite(ctx, fav.ID, tagID)
285+ } else {
286+ err = uc.TagFavorite(ctx, fav.ID, tagID)
287+ }
288+ if err != nil {
289+ fs.setNotice(err.Error())
290+ return true
291+ }
292+ fs.startLoad()
293+ return true
294+}
295+
296+func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297+ fav := fs.selectedFavorite()
298+ if fav == nil {
299+ fs.setNotice("Select a favorite for shortcut")
300+ return true
301+ }
302+ fs.mu.RLock()
303+ uc := fs.favoritesUC
304+ ctx := fs.ctx
305+ fs.mu.RUnlock()
306+ if uc == nil {
307+ return true
308+ }
309+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
310+ fs.setNotice(err.Error())
311+ return true
312+ }
313+ fs.startLoad()
314+ return true
315+}
316+
317+func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
318+ fav := fs.selectedFavorite()
319+ if fav == nil {
320+ fs.setNotice("Select a favorite to delete")
321+ return true
322+ }
323+ fs.mu.Lock()
324+ if !fs.destroyed {
325+ fs.confirmDelete = true
326+ fs.confirmDeleteID = fav.ID
327+ fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel."
328+ }
329+ fs.mu.Unlock()
330+ fs.rebuildList()
331+ return true
332+}
333+
334+func (fs *FavoritesSidebar) formModeActive() bool {
335+ if fs == nil {
336+ return false
337+ }
338+ fs.mu.RLock()
339+ defer fs.mu.RUnlock()
340+ return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
341+}
342+
343+func (fs *FavoritesSidebar) confirmDeleteActive() bool {
344+ if fs == nil {
345+ return false
346+ }
347+ fs.mu.RLock()
348+ defer fs.mu.RUnlock()
349+ return fs.confirmDelete
350+}
351+
352+func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
353+ fav := fs.selectedFavorite()
354+ if fav == nil {
355+ return fs.requestDeleteConfirmation()
356+ }
357+ fs.mu.RLock()
358+ confirmed := fs.confirmDelete
359+ confirmedID := fs.confirmDeleteID
360+ uc := fs.favoritesUC
361+ ctx := fs.ctx
362+ fs.mu.RUnlock()
363+ if !confirmed || confirmedID != fav.ID {
364+ return fs.requestDeleteConfirmation()
365+ }
366+ if uc == nil {
367+ return true
368+ }
369+ if err := uc.DeleteFavorite(ctx, fav.ID); err != nil {
370+ fs.setNotice(err.Error())
371+ return true
372+ }
373+ fs.cancelManagement()
374+ fs.startLoad()
375+ return true
376+}
377+
378+func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
379+ fs.mu.RLock()
380+ defer fs.mu.RUnlock()
381+ if len(fs.displayRows) == 1 {
382+ return fs.displayRows[0].Favorite
383+ }
384+ idx := firstSelectableIndex(fs.displayRows)
385+ if fs.listBox != nil {
386+ if row := fs.listBox.GetSelectedRow(); row != nil {
387+ idx = row.GetIndex()
388+ }
389+ }
390+ if idx >= 0 && idx < len(fs.displayRows) {
391+ return fs.displayRows[idx].Favorite
392+ }
393+ return nil
394+}
395+
396+func (fs *FavoritesSidebar) setNotice(notice string) {
397+ fs.mu.Lock()
398+ if !fs.destroyed {
399+ fs.notice = notice
400+ }
401+ fs.mu.Unlock()
402+ fs.rebuildList()
403+}
404+
405+func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
406+ url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
407+ if fs.formURLEntry != nil {
408+ url = fs.formURLEntry.GetText()
409+ }
410+ if fs.formTitleEntry != nil {
411+ title = fs.formTitleEntry.GetText()
412+ }
413+ if fs.formTagsEntry != nil {
414+ tagsText = fs.formTagsEntry.GetText()
415+ }
416+ if fs.formShortcutEntry != nil {
417+ shortcut = fs.formShortcutEntry.GetText()
418+ }
419+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)
420+}
421+
422+func parseTagIDs(text string) ([]entity.TagID, error) {
423+ if text == "" {
424+ return nil, nil
425+ }
426+ parts := strings.Split(text, ",")
427+ ids := make([]entity.TagID, 0, len(parts))
428+ for _, part := range parts {
429+ trimmed := strings.TrimSpace(part)
430+ if trimmed == "" {
431+ return nil, fmt.Errorf("invalid tag ID %q", part)
432+ }
433+ v, err := strconv.Atoi(trimmed)
434+ if err != nil || v <= 0 {
435+ return nil, fmt.Errorf("invalid tag ID %q", trimmed)
436+ }
437+ ids = append(ids, entity.TagID(v))
438+ }
439+ return ids, nil
440+}
441+
442+func parseShortcut(text string) (*int, error) {
443+ text = strings.TrimSpace(text)
444+ if text == "" {
445+ return nil, nil
446+ }
447+ v, err := strconv.Atoi(text)
448+ if err != nil || v < 1 || v > 9 {
449+ return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text)
450+ }
451+ return &v, nil
452+}
453+
454+func joinFavoriteTagIDs(tags []entity.Tag) string {
455+ parts := make([]string, 0, len(tags))
456+ for _, tag := range tags {
457+ parts = append(parts, fmt.Sprint(tag.ID))
458+ }
459+ return strings.Join(parts, ",")
460+}
@@ -0,0 +1,460 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+type favoritesSidebarMode int
16+
17+const (
18+ favoritesSidebarModeNone favoritesSidebarMode = iota
19+ favoritesSidebarModeAdd
20+ favoritesSidebarModeEdit
21+ favoritesSidebarModeTag
22+ favoritesSidebarModeShortcut
23+)
24+
25+type favoritesSidebarFocusZone int
26+
27+const (
28+ favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota
29+ favoritesSidebarFocusTags
30+ favoritesSidebarFocusList
31+ favoritesSidebarFocusForm
32+ favoritesSidebarFocusConfirm
33+)
34+
35+func (fs *FavoritesSidebar) beginAddForm() {
36+ fs.mu.Lock()
37+ if fs.destroyed {
38+ fs.mu.Unlock()
39+ return
40+ }
41+ fs.mode = favoritesSidebarModeAdd
42+ fs.editingID = 0
43+ fs.confirmDelete = false
44+ fs.confirmDeleteID = 0
45+ fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save."
46+ fs.mu.Unlock()
47+ fs.renderForm(nil)
48+ fs.rebuildList()
49+}
50+
51+func (fs *FavoritesSidebar) beginEditForm() {
52+ fav := fs.selectedFavorite()
53+ if fav == nil {
54+ fs.setNotice("Select a favorite to edit")
55+ return
56+ }
57+ fs.mu.Lock()
58+ if fs.destroyed {
59+ fs.mu.Unlock()
60+ return
61+ }
62+ fs.mode = favoritesSidebarModeEdit
63+ fs.editingID = fav.ID
64+ fs.confirmDelete = false
65+ fs.confirmDeleteID = 0
66+ fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save."
67+ fs.mu.Unlock()
68+ fs.renderForm(fav)
69+ fs.rebuildList()
70+}
71+
72+func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
73+ if fs == nil || fs.formBox == nil {
74+ return
75+ }
76+ clearBoxChildren(fs.formBox)
77+ fs.formURLEntry = gtk.NewSearchEntry()
78+ fs.formTitleEntry = gtk.NewSearchEntry()
79+ fs.formTagsEntry = nil
80+ if fav == nil {
81+ fs.formTagsEntry = gtk.NewSearchEntry()
82+ }
83+ fs.formShortcutEntry = gtk.NewSearchEntry()
84+ entries := []struct {
85+ entry *gtk.SearchEntry
86+ label string
87+ }{
88+ {entry: fs.formURLEntry, label: "URL"},
89+ {entry: fs.formTitleEntry, label: "Title"},
90+ {entry: fs.formTagsEntry, label: "Tag IDs"},
91+ {entry: fs.formShortcutEntry, label: "Shortcut"},
92+ }
93+ for _, item := range entries {
94+ if item.entry == nil {
95+ continue
96+ }
97+ placeholder := item.label
98+ item.entry.SetPlaceholderText(&placeholder)
99+ fs.formBox.Append(&item.entry.Widget)
100+ }
101+ fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102+ if fs.formSaveButton != nil {
103+ cb := func(_ gtk.Button) {
104+ fs.submitForm()
105+ }
106+ fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
107+ fs.formSaveButton.ConnectClicked(&cb)
108+ fs.formBox.Append(&fs.formSaveButton.Widget)
109+ }
110+ if fav != nil {
111+ fs.formURL = fav.URL
112+ fs.formTitle = fav.Title
113+ fs.formTags = joinFavoriteTagIDs(fav.Tags)
114+ fs.formShortcut = ""
115+ if fav.ShortcutKey != nil {
116+ fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
117+ }
118+ if fs.formURLEntry != nil {
119+ fs.formURLEntry.SetText(fs.formURL)
120+ fs.formURLEntry.SetEditable(false)
121+ }
122+ if fs.formTitleEntry != nil {
123+ fs.formTitleEntry.SetText(fs.formTitle)
124+ }
125+ if fs.formTagsEntry != nil {
126+ fs.formTagsEntry.SetText(fs.formTags)
127+ }
128+ if fs.formShortcutEntry != nil {
129+ fs.formShortcutEntry.SetText(fs.formShortcut)
130+ }
131+ } else {
132+ fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
133+ }
134+ fs.formBox.SetVisible(true)
135+ fs.focusForm()
136+}
137+
138+func (fs *FavoritesSidebar) focusForm() {
139+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
140+ if fs == nil {
141+ return false
142+ }
143+ fs.mu.RLock()
144+ mode := fs.mode
145+ destroyed := fs.destroyed
146+ fs.mu.RUnlock()
147+ if destroyed {
148+ return false
149+ }
150+ if mode == favoritesSidebarModeEdit && fs.formTitleEntry != nil {
151+ fs.formTitleEntry.GrabFocus()
152+ return false
153+ }
154+ if fs.formURLEntry != nil {
155+ fs.formURLEntry.GrabFocus()
156+ }
157+ return false
158+ }))
159+}
160+
161+func (fs *FavoritesSidebar) cancelManagement() bool {
162+ fs.mu.Lock()
163+ if fs.destroyed {
164+ fs.mu.Unlock()
165+ return false
166+ }
167+ active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete
168+ fs.mode = favoritesSidebarModeNone
169+ fs.editingID = 0
170+ fs.confirmDelete = false
171+ fs.confirmDeleteID = 0
172+ if active {
173+ fs.notice = ""
174+ }
175+ fs.mu.Unlock()
176+ if fs.formBox != nil {
177+ fs.formBox.SetVisible(false)
178+ clearBoxChildren(fs.formBox)
179+ }
180+ fs.rebuildList()
181+ return active
182+}
183+
184+func (fs *FavoritesSidebar) submitForm() bool {
185+ if fs == nil {
186+ return false
187+ }
188+ fs.mu.RLock()
189+ mode := fs.mode
190+ id := fs.editingID
191+ uc := fs.favoritesUC
192+ ctx := fs.ctx
193+ fs.mu.RUnlock()
194+ if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195+ return false
196+ }
197+ url, title, tagsText, shortcutText := fs.formValues()
198+ if mode == favoritesSidebarModeAdd {
199+ tags, err := parseTagIDs(tagsText)
200+ if err != nil {
201+ fs.setNotice(err.Error())
202+ return true
203+ }
204+ key, err := parseShortcut(shortcutText)
205+ if err != nil {
206+ fs.setNotice(err.Error())
207+ return true
208+ }
209+ input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags}
210+ fav, err := uc.AddFavorite(ctx, input)
211+ if err != nil {
212+ fs.setNotice(err.Error())
213+ return true
214+ }
215+ if key != nil && fav != nil {
216+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
217+ fs.cancelManagement()
218+ fs.startLoad()
219+ fs.setNotice("Added favorite, but failed to set shortcut: " + err.Error())
220+ return true
221+ }
222+ }
223+ } else {
224+ key, err := parseShortcut(shortcutText)
225+ if err != nil {
226+ fs.setNotice(err.Error())
227+ return true
228+ }
229+ _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: true})
230+ if err != nil {
231+ fs.setNotice(err.Error())
232+ return true
233+ }
234+ }
235+ fs.cancelManagement()
236+ fs.startLoad()
237+ return true
238+}
239+
240+func (fs *FavoritesSidebar) enterTagMode() {
241+ fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242+}
243+func (fs *FavoritesSidebar) enterShortcutMode() {
244+ fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245+}
246+
247+func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) {
248+ fs.mu.Lock()
249+ if !fs.destroyed {
250+ fs.mode = mode
251+ fs.confirmDelete = false
252+ fs.confirmDeleteID = 0
253+ fs.notice = notice
254+ }
255+ fs.mu.Unlock()
256+ fs.rebuildList()
257+}
258+
259+func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool {
260+ fav := fs.selectedFavorite()
261+ if fav == nil || ord < 1 {
262+ fs.setNotice("Select a favorite and tag 1-9")
263+ return true
264+ }
265+ fs.mu.RLock()
266+ if ord > len(fs.allTags) || fs.favoritesUC == nil {
267+ fs.mu.RUnlock()
268+ fs.setNotice("No tag for key")
269+ return true
270+ }
271+ tagID := fs.allTags[ord-1].ID
272+ uc := fs.favoritesUC
273+ ctx := fs.ctx
274+ fs.mu.RUnlock()
275+ has := false
276+ for _, tag := range fav.Tags {
277+ if tag.ID == tagID {
278+ has = true
279+ break
280+ }
281+ }
282+ var err error
283+ if has {
284+ err = uc.UntagFavorite(ctx, fav.ID, tagID)
285+ } else {
286+ err = uc.TagFavorite(ctx, fav.ID, tagID)
287+ }
288+ if err != nil {
289+ fs.setNotice(err.Error())
290+ return true
291+ }
292+ fs.startLoad()
293+ return true
294+}
295+
296+func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297+ fav := fs.selectedFavorite()
298+ if fav == nil {
299+ fs.setNotice("Select a favorite for shortcut")
300+ return true
301+ }
302+ fs.mu.RLock()
303+ uc := fs.favoritesUC
304+ ctx := fs.ctx
305+ fs.mu.RUnlock()
306+ if uc == nil {
307+ return true
308+ }
309+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
310+ fs.setNotice(err.Error())
311+ return true
312+ }
313+ fs.startLoad()
314+ return true
315+}
316+
317+func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
318+ fav := fs.selectedFavorite()
319+ if fav == nil {
320+ fs.setNotice("Select a favorite to delete")
321+ return true
322+ }
323+ fs.mu.Lock()
324+ if !fs.destroyed {
325+ fs.confirmDelete = true
326+ fs.confirmDeleteID = fav.ID
327+ fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel."
328+ }
329+ fs.mu.Unlock()
330+ fs.rebuildList()
331+ return true
332+}
333+
334+func (fs *FavoritesSidebar) formModeActive() bool {
335+ if fs == nil {
336+ return false
337+ }
338+ fs.mu.RLock()
339+ defer fs.mu.RUnlock()
340+ return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
341+}
342+
343+func (fs *FavoritesSidebar) confirmDeleteActive() bool {
344+ if fs == nil {
345+ return false
346+ }
347+ fs.mu.RLock()
348+ defer fs.mu.RUnlock()
349+ return fs.confirmDelete
350+}
351+
352+func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
353+ fav := fs.selectedFavorite()
354+ if fav == nil {
355+ return fs.requestDeleteConfirmation()
356+ }
357+ fs.mu.RLock()
358+ confirmed := fs.confirmDelete
359+ confirmedID := fs.confirmDeleteID
360+ uc := fs.favoritesUC
361+ ctx := fs.ctx
362+ fs.mu.RUnlock()
363+ if !confirmed || confirmedID != fav.ID {
364+ return fs.requestDeleteConfirmation()
365+ }
366+ if uc == nil {
367+ return true
368+ }
369+ if err := uc.DeleteFavorite(ctx, fav.ID); err != nil {
370+ fs.setNotice(err.Error())
371+ return true
372+ }
373+ fs.cancelManagement()
374+ fs.startLoad()
375+ return true
376+}
377+
378+func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
379+ fs.mu.RLock()
380+ defer fs.mu.RUnlock()
381+ if len(fs.displayRows) == 1 {
382+ return fs.displayRows[0].Favorite
383+ }
384+ idx := firstSelectableIndex(fs.displayRows)
385+ if fs.listBox != nil {
386+ if row := fs.listBox.GetSelectedRow(); row != nil {
387+ idx = row.GetIndex()
388+ }
389+ }
390+ if idx >= 0 && idx < len(fs.displayRows) {
391+ return fs.displayRows[idx].Favorite
392+ }
393+ return nil
394+}
395+
396+func (fs *FavoritesSidebar) setNotice(notice string) {
397+ fs.mu.Lock()
398+ if !fs.destroyed {
399+ fs.notice = notice
400+ }
401+ fs.mu.Unlock()
402+ fs.rebuildList()
403+}
404+
405+func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
406+ url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
407+ if fs.formURLEntry != nil {
408+ url = fs.formURLEntry.GetText()
409+ }
410+ if fs.formTitleEntry != nil {
411+ title = fs.formTitleEntry.GetText()
412+ }
413+ if fs.formTagsEntry != nil {
414+ tagsText = fs.formTagsEntry.GetText()
415+ }
416+ if fs.formShortcutEntry != nil {
417+ shortcut = fs.formShortcutEntry.GetText()
418+ }
419+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)
420+}
421+
422+func parseTagIDs(text string) ([]entity.TagID, error) {
423+ if text == "" {
424+ return nil, nil
425+ }
426+ parts := strings.Split(text, ",")
427+ ids := make([]entity.TagID, 0, len(parts))
428+ for _, part := range parts {
429+ trimmed := strings.TrimSpace(part)
430+ if trimmed == "" {
431+ return nil, fmt.Errorf("invalid tag ID %q", part)
432+ }
433+ v, err := strconv.Atoi(trimmed)
434+ if err != nil || v <= 0 {
435+ return nil, fmt.Errorf("invalid tag ID %q", trimmed)
436+ }
437+ ids = append(ids, entity.TagID(v))
438+ }
439+ return ids, nil
440+}
441+
442+func parseShortcut(text string) (*int, error) {
443+ text = strings.TrimSpace(text)
444+ if text == "" {
445+ return nil, nil
446+ }
447+ v, err := strconv.Atoi(text)
448+ if err != nil || v < 1 || v > 9 {
449+ return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text)
450+ }
451+ return &v, nil
452+}
453+
454+func joinFavoriteTagIDs(tags []entity.Tag) string {
455+ parts := make([]string, 0, len(tags))
456+ for _, tag := range tags {
457+ parts = append(parts, fmt.Sprint(tag.ID))
458+ }
459+ return strings.Join(parts, ",")
460+}
A internal/ui/component/favorites_sidebar_keyboard.go +476 -0
@@ -0,0 +1,476 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gdk"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+//nolint:gocyclo,funlen // Centralized local key routing keeps sidebar keyboard behavior easy to audit.
12+func (fs *FavoritesSidebar) setupKeyboardNavigation() {
13+ if fs == nil || fs.outerBox == nil {
14+ return
15+ }
16+ keyController := gtk.NewEventControllerKey()
17+ if keyController == nil {
18+ return
19+ }
20+ keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
21+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
22+ fs.mu.RLock()
23+ if fs.destroyed {
24+ fs.mu.RUnlock()
25+ return false
26+ }
27+ searchEntry := fs.searchEntry
28+ onClose := fs.onClose
29+ fs.mu.RUnlock()
30+
31+ textEditing := fs.inTextEditContext()
32+ switch keyval {
33+ case uint(gdk.KEY_Escape):
34+ if fs.cancelManagement() {
35+ return true
36+ }
37+ if searchEntry != nil && searchEntry.GetText() != "" {
38+ searchEntry.SetText("")
39+ return true
40+ }
41+ if onClose != nil {
42+ onClose()
43+ }
44+ return true
45+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
46+ return fs.handleReturnKey(state)
47+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
48+ fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
49+ return true
50+ case uint(gdk.KEY_Page_Up):
51+ if textEditing {
52+ return false
53+ }
54+ fs.selectAdjacentRow(-5)
55+ return true
56+ case uint(gdk.KEY_Page_Down):
57+ if textEditing {
58+ return false
59+ }
60+ fs.selectAdjacentRow(5)
61+ return true
62+ case uint(gdk.KEY_Home):
63+ if textEditing {
64+ return false
65+ }
66+ fs.mu.RLock()
67+ index := firstSelectableIndex(fs.displayRows)
68+ fs.mu.RUnlock()
69+ fs.selectIndex(index)
70+ return true
71+ case uint(gdk.KEY_End):
72+ if textEditing {
73+ return false
74+ }
75+ fs.mu.RLock()
76+ index := lastSelectableIndex(fs.displayRows)
77+ fs.mu.RUnlock()
78+ fs.selectIndex(index)
79+ return true
80+ case uint(gdk.KEY_Up):
81+ if textEditing {
82+ return false
83+ }
84+ fs.selectAdjacentRow(-1)
85+ return true
86+ case uint(gdk.KEY_Down):
87+ if textEditing {
88+ return false
89+ }
90+ fs.selectAdjacentRow(1)
91+ return true
92+ case uint(gdk.KEY_slash):
93+ if textEditing {
94+ return false
95+ }
96+ searchFocused := searchEntry != nil && searchEntry.HasFocus()
97+ if !shouldFocusSearchForSlash(searchFocused) {
98+ return false
99+ }
100+ fs.focusSearch()
101+ return true
102+ }
103+ return fs.handleSingleKeyCommand(keyval, state)
104+ }
105+ fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
106+ keyController.ConnectKeyPressed(&keyPressedCb)
107+ fs.outerBox.AddController(&keyController.EventController)
108+}
109+
110+func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool {
111+ if fs == nil {
112+ return false
113+ }
114+ if fs.confirmDeleteActive() {
115+ return fs.confirmDeleteFavorite()
116+ }
117+ formActive := fs.formModeActive()
118+ if state&gdk.ControlMaskValue != 0 && fs.submitForm() {
119+ return true
120+ }
121+ if formActive {
122+ return false
123+ }
124+ return fs.handleEnterKey(state)
125+}
126+
127+func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
128+ if fs == nil {
129+ return false
130+ }
131+ url := fs.selectedEnterURL()
132+ if url == "" {
133+ return false
134+ }
135+ return fs.dispatchEnterURL(state, url)
136+}
137+
138+func (fs *FavoritesSidebar) selectedEnterURL() string {
139+ if fs == nil {
140+ return ""
141+ }
142+ if fs.listBox == nil {
143+ fs.mu.RLock()
144+ defer fs.mu.RUnlock()
145+ if fs.destroyed || len(fs.displayRows) != 1 {
146+ return ""
147+ }
148+ return fs.rowURLAt(0)
149+ }
150+ row := fs.listBox.GetSelectedRow()
151+ if row == nil || !row.GetSelectable() {
152+ return ""
153+ }
154+ fs.mu.RLock()
155+ defer fs.mu.RUnlock()
156+ if fs.destroyed {
157+ return ""
158+ }
159+ return fs.rowURLAt(row.GetIndex())
160+}
161+
162+func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) {
163+ if row == nil || !row.GetSelectable() {
164+ return
165+ }
166+ fs.mu.RLock()
167+ if fs.destroyed {
168+ fs.mu.RUnlock()
169+ return
170+ }
171+ url := fs.rowURLAt(row.GetIndex())
172+ fs.mu.RUnlock()
173+ fs.navigateToURL(url)
174+}
175+
176+func (fs *FavoritesSidebar) selectAdjacentRow(delta int) {
177+ if fs == nil || fs.listBox == nil {
178+ return
179+ }
180+ current := -1
181+ if row := fs.listBox.GetSelectedRow(); row != nil {
182+ current = row.GetIndex()
183+ }
184+ fs.mu.RLock()
185+ if fs.destroyed {
186+ fs.mu.RUnlock()
187+ return
188+ }
189+ target := current
190+ step := 1
191+ if delta < 0 {
192+ step = -1
193+ }
194+ for i := 0; i < abs(delta); i++ {
195+ next := nextSelectableIndex(fs.displayRows, target, step)
196+ if next == -1 {
197+ break
198+ }
199+ target = next
200+ }
201+ if current < 0 {
202+ if step > 0 {
203+ target = firstSelectableIndex(fs.displayRows)
204+ } else {
205+ target = lastSelectableIndex(fs.displayRows)
206+ }
207+ }
208+ fs.mu.RUnlock()
209+ fs.selectIndex(target)
210+}
211+
212+func (fs *FavoritesSidebar) selectIndex(index int) {
213+ if fs == nil || index < 0 {
214+ return
215+ }
216+ fs.mu.RLock()
217+ destroyed := fs.destroyed
218+ listBox := fs.listBox
219+ fs.mu.RUnlock()
220+ if destroyed || listBox == nil {
221+ return
222+ }
223+ if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() {
224+ listBox.SelectRow(row)
225+ }
226+}
227+
228+func (fs *FavoritesSidebar) navigateToURL(url string) {
229+ if fs == nil || fs.onNavigate == nil || url == "" {
230+ return
231+ }
232+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
233+ fs.mu.RLock()
234+ destroyed := fs.destroyed
235+ fs.mu.RUnlock()
236+ if !destroyed {
237+ fs.handleNavigationError(fs.onNavigate(fs.ctx, url))
238+ }
239+ return false
240+ }))
241+}
242+
243+func (fs *FavoritesSidebar) navigateWithoutClosing(url string) {
244+ if fs == nil || url == "" {
245+ return
246+ }
247+ cb := fs.onNavigateKeepOpen
248+ if cb == nil {
249+ cb = fs.onNavigate
250+ }
251+ if cb == nil {
252+ return
253+ }
254+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
255+ fs.mu.RLock()
256+ destroyed := fs.destroyed
257+ fs.mu.RUnlock()
258+ if !destroyed {
259+ fs.handleNavigationError(cb(fs.ctx, url))
260+ }
261+ return false
262+ }))
263+}
264+
265+func (fs *FavoritesSidebar) navigateToNewPane(url string) {
266+ if fs == nil || fs.onOpenInNewPane == nil || url == "" {
267+ return
268+ }
269+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
270+ fs.mu.RLock()
271+ destroyed := fs.destroyed
272+ fs.mu.RUnlock()
273+ if !destroyed {
274+ fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url))
275+ }
276+ return false
277+ }))
278+}
279+
280+func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
281+
282+func (fs *FavoritesSidebar) inTextEditContext() bool {
283+ if fs == nil {
284+ return false
285+ }
286+ if fs.searchEntry != nil && fs.searchEntry.HasFocus() {
287+ return true
288+ }
289+ for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {
290+ if entry != nil && entry.HasFocus() {
291+ return true
292+ }
293+ }
294+ return false
295+}
296+
297+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298+ shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299+ if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
300+ return false
301+ }
302+ fs.mu.RLock()
303+ mode := fs.mode
304+ confirm := fs.confirmDelete
305+ fs.mu.RUnlock()
306+ if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
307+ return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0)))
308+ }
309+ if mode == favoritesSidebarModeShortcut {
310+ if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311+ v := int(keyval - uint(gdk.KEY_0))
312+ return fs.setShortcutKey(&v)
313+ }
314+ if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) {
315+ return fs.setShortcutKey(nil)
316+ }
317+ }
318+ if keyval == uint(gdk.KEY_Delete) {
319+ if confirm {
320+ return fs.confirmDeleteFavorite()
321+ }
322+ return fs.requestDeleteConfirmation()
323+ }
324+ switch keyval {
325+ case uint(gdk.KEY_a):
326+ fs.beginAddForm()
327+ return true
328+ case uint(gdk.KEY_e):
329+ fs.beginEditForm()
330+ return true
331+ case uint(gdk.KEY_t):
332+ fs.enterTagMode()
333+ return true
334+ case uint(gdk.KEY_s):
335+ fs.enterShortcutMode()
336+ return true
337+ case uint(gdk.KEY_r):
338+ fs.startLoad()
339+ return true
340+ case uint(gdk.KEY_c):
341+ fs.clearQueryAndTags()
342+ return true
343+ }
344+ return false
345+}
346+
347+func (fs *FavoritesSidebar) clearQueryAndTags() {
348+ if fs == nil {
349+ return
350+ }
351+ if fs.searchEntry != nil {
352+ fs.searchEntry.SetText("")
353+ }
354+ fs.mu.Lock()
355+ if !fs.destroyed {
356+ fs.currentQuery = ""
357+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
358+ fs.rebuildDisplayRowsLocked()
359+ }
360+ fs.mu.Unlock()
361+ fs.renderTags()
362+ fs.rebuildList()
363+}
364+
365+func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366+ if fs == nil {
367+ return
368+ }
369+ zones := fs.availableFocusZones()
370+ if len(zones) == 0 {
371+ return
372+ }
373+ fs.mu.RLock()
374+ current := fs.focusZone
375+ fs.mu.RUnlock()
376+ idx := -1
377+ for i, zone := range zones {
378+ if zone == current {
379+ idx = i
380+ break
381+ }
382+ }
383+ if reverse {
384+ idx--
385+ if idx < 0 {
386+ idx = len(zones) - 1
387+ }
388+ } else {
389+ idx++
390+ if idx >= len(zones) {
391+ idx = 0
392+ }
393+ }
394+ fs.focusZoneWidget(zones[idx])
395+}
396+
397+func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398+ zones := []favoritesSidebarFocusZone{}
399+ if fs.searchEntry != nil {
400+ zones = append(zones, favoritesSidebarFocusSearch)
401+ }
402+ if fs.tagBox != nil {
403+ zones = append(zones, favoritesSidebarFocusTags)
404+ }
405+ if fs.listBox != nil {
406+ zones = append(zones, favoritesSidebarFocusList)
407+ }
408+ fs.mu.RLock()
409+ formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
410+ confirmActive := fs.confirmDelete
411+ fs.mu.RUnlock()
412+ if formActive && fs.formBox != nil {
413+ zones = append(zones, favoritesSidebarFocusForm)
414+ }
415+ if confirmActive {
416+ zones = append(zones, favoritesSidebarFocusConfirm)
417+ }
418+ return zones
419+}
420+
421+func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
422+ fs.mu.Lock()
423+ if !fs.destroyed {
424+ fs.focusZone = zone
425+ }
426+ fs.mu.Unlock()
427+ switch zone {
428+ case favoritesSidebarFocusSearch:
429+ fs.focusSearch()
430+ case favoritesSidebarFocusTags:
431+ if fs.tagBox != nil {
432+ fs.tagBox.GrabFocus()
433+ }
434+ case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435+ if fs.listBox != nil {
436+ fs.listBox.GrabFocus()
437+ }
438+ case favoritesSidebarFocusForm:
439+ fs.focusForm()
440+ }
441+}
442+
443+func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
444+ if fs == nil || url == "" {
445+ return false
446+ }
447+ if state&gdk.ShiftMaskValue != 0 {
448+ fs.navigateToNewPane(url)
449+ } else if state&gdk.ControlMaskValue != 0 {
450+ fs.navigateWithoutClosing(url)
451+ } else {
452+ fs.navigateToURL(url)
453+ }
454+ return true
455+}
456+
457+func (fs *FavoritesSidebar) handleNavigationError(err error) {
458+ if fs == nil || err == nil {
459+ return
460+ }
461+ fs.mu.Lock()
462+ if fs.destroyed {
463+ fs.mu.Unlock()
464+ return
465+ }
466+ fs.notice = err.Error()
467+ fs.mu.Unlock()
468+ fs.rebuildList()
469+}
470+
471+func abs(v int) int {
472+ if v < 0 {
473+ return -v
474+ }
475+ return v
476+}
@@ -0,0 +1,476 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gdk"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+//nolint:gocyclo,funlen // Centralized local key routing keeps sidebar keyboard behavior easy to audit.
12+func (fs *FavoritesSidebar) setupKeyboardNavigation() {
13+ if fs == nil || fs.outerBox == nil {
14+ return
15+ }
16+ keyController := gtk.NewEventControllerKey()
17+ if keyController == nil {
18+ return
19+ }
20+ keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
21+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
22+ fs.mu.RLock()
23+ if fs.destroyed {
24+ fs.mu.RUnlock()
25+ return false
26+ }
27+ searchEntry := fs.searchEntry
28+ onClose := fs.onClose
29+ fs.mu.RUnlock()
30+
31+ textEditing := fs.inTextEditContext()
32+ switch keyval {
33+ case uint(gdk.KEY_Escape):
34+ if fs.cancelManagement() {
35+ return true
36+ }
37+ if searchEntry != nil && searchEntry.GetText() != "" {
38+ searchEntry.SetText("")
39+ return true
40+ }
41+ if onClose != nil {
42+ onClose()
43+ }
44+ return true
45+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
46+ return fs.handleReturnKey(state)
47+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
48+ fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
49+ return true
50+ case uint(gdk.KEY_Page_Up):
51+ if textEditing {
52+ return false
53+ }
54+ fs.selectAdjacentRow(-5)
55+ return true
56+ case uint(gdk.KEY_Page_Down):
57+ if textEditing {
58+ return false
59+ }
60+ fs.selectAdjacentRow(5)
61+ return true
62+ case uint(gdk.KEY_Home):
63+ if textEditing {
64+ return false
65+ }
66+ fs.mu.RLock()
67+ index := firstSelectableIndex(fs.displayRows)
68+ fs.mu.RUnlock()
69+ fs.selectIndex(index)
70+ return true
71+ case uint(gdk.KEY_End):
72+ if textEditing {
73+ return false
74+ }
75+ fs.mu.RLock()
76+ index := lastSelectableIndex(fs.displayRows)
77+ fs.mu.RUnlock()
78+ fs.selectIndex(index)
79+ return true
80+ case uint(gdk.KEY_Up):
81+ if textEditing {
82+ return false
83+ }
84+ fs.selectAdjacentRow(-1)
85+ return true
86+ case uint(gdk.KEY_Down):
87+ if textEditing {
88+ return false
89+ }
90+ fs.selectAdjacentRow(1)
91+ return true
92+ case uint(gdk.KEY_slash):
93+ if textEditing {
94+ return false
95+ }
96+ searchFocused := searchEntry != nil && searchEntry.HasFocus()
97+ if !shouldFocusSearchForSlash(searchFocused) {
98+ return false
99+ }
100+ fs.focusSearch()
101+ return true
102+ }
103+ return fs.handleSingleKeyCommand(keyval, state)
104+ }
105+ fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
106+ keyController.ConnectKeyPressed(&keyPressedCb)
107+ fs.outerBox.AddController(&keyController.EventController)
108+}
109+
110+func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool {
111+ if fs == nil {
112+ return false
113+ }
114+ if fs.confirmDeleteActive() {
115+ return fs.confirmDeleteFavorite()
116+ }
117+ formActive := fs.formModeActive()
118+ if state&gdk.ControlMaskValue != 0 && fs.submitForm() {
119+ return true
120+ }
121+ if formActive {
122+ return false
123+ }
124+ return fs.handleEnterKey(state)
125+}
126+
127+func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
128+ if fs == nil {
129+ return false
130+ }
131+ url := fs.selectedEnterURL()
132+ if url == "" {
133+ return false
134+ }
135+ return fs.dispatchEnterURL(state, url)
136+}
137+
138+func (fs *FavoritesSidebar) selectedEnterURL() string {
139+ if fs == nil {
140+ return ""
141+ }
142+ if fs.listBox == nil {
143+ fs.mu.RLock()
144+ defer fs.mu.RUnlock()
145+ if fs.destroyed || len(fs.displayRows) != 1 {
146+ return ""
147+ }
148+ return fs.rowURLAt(0)
149+ }
150+ row := fs.listBox.GetSelectedRow()
151+ if row == nil || !row.GetSelectable() {
152+ return ""
153+ }
154+ fs.mu.RLock()
155+ defer fs.mu.RUnlock()
156+ if fs.destroyed {
157+ return ""
158+ }
159+ return fs.rowURLAt(row.GetIndex())
160+}
161+
162+func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) {
163+ if row == nil || !row.GetSelectable() {
164+ return
165+ }
166+ fs.mu.RLock()
167+ if fs.destroyed {
168+ fs.mu.RUnlock()
169+ return
170+ }
171+ url := fs.rowURLAt(row.GetIndex())
172+ fs.mu.RUnlock()
173+ fs.navigateToURL(url)
174+}
175+
176+func (fs *FavoritesSidebar) selectAdjacentRow(delta int) {
177+ if fs == nil || fs.listBox == nil {
178+ return
179+ }
180+ current := -1
181+ if row := fs.listBox.GetSelectedRow(); row != nil {
182+ current = row.GetIndex()
183+ }
184+ fs.mu.RLock()
185+ if fs.destroyed {
186+ fs.mu.RUnlock()
187+ return
188+ }
189+ target := current
190+ step := 1
191+ if delta < 0 {
192+ step = -1
193+ }
194+ for i := 0; i < abs(delta); i++ {
195+ next := nextSelectableIndex(fs.displayRows, target, step)
196+ if next == -1 {
197+ break
198+ }
199+ target = next
200+ }
201+ if current < 0 {
202+ if step > 0 {
203+ target = firstSelectableIndex(fs.displayRows)
204+ } else {
205+ target = lastSelectableIndex(fs.displayRows)
206+ }
207+ }
208+ fs.mu.RUnlock()
209+ fs.selectIndex(target)
210+}
211+
212+func (fs *FavoritesSidebar) selectIndex(index int) {
213+ if fs == nil || index < 0 {
214+ return
215+ }
216+ fs.mu.RLock()
217+ destroyed := fs.destroyed
218+ listBox := fs.listBox
219+ fs.mu.RUnlock()
220+ if destroyed || listBox == nil {
221+ return
222+ }
223+ if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() {
224+ listBox.SelectRow(row)
225+ }
226+}
227+
228+func (fs *FavoritesSidebar) navigateToURL(url string) {
229+ if fs == nil || fs.onNavigate == nil || url == "" {
230+ return
231+ }
232+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
233+ fs.mu.RLock()
234+ destroyed := fs.destroyed
235+ fs.mu.RUnlock()
236+ if !destroyed {
237+ fs.handleNavigationError(fs.onNavigate(fs.ctx, url))
238+ }
239+ return false
240+ }))
241+}
242+
243+func (fs *FavoritesSidebar) navigateWithoutClosing(url string) {
244+ if fs == nil || url == "" {
245+ return
246+ }
247+ cb := fs.onNavigateKeepOpen
248+ if cb == nil {
249+ cb = fs.onNavigate
250+ }
251+ if cb == nil {
252+ return
253+ }
254+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
255+ fs.mu.RLock()
256+ destroyed := fs.destroyed
257+ fs.mu.RUnlock()
258+ if !destroyed {
259+ fs.handleNavigationError(cb(fs.ctx, url))
260+ }
261+ return false
262+ }))
263+}
264+
265+func (fs *FavoritesSidebar) navigateToNewPane(url string) {
266+ if fs == nil || fs.onOpenInNewPane == nil || url == "" {
267+ return
268+ }
269+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
270+ fs.mu.RLock()
271+ destroyed := fs.destroyed
272+ fs.mu.RUnlock()
273+ if !destroyed {
274+ fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url))
275+ }
276+ return false
277+ }))
278+}
279+
280+func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
281+
282+func (fs *FavoritesSidebar) inTextEditContext() bool {
283+ if fs == nil {
284+ return false
285+ }
286+ if fs.searchEntry != nil && fs.searchEntry.HasFocus() {
287+ return true
288+ }
289+ for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {
290+ if entry != nil && entry.HasFocus() {
291+ return true
292+ }
293+ }
294+ return false
295+}
296+
297+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298+ shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299+ if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
300+ return false
301+ }
302+ fs.mu.RLock()
303+ mode := fs.mode
304+ confirm := fs.confirmDelete
305+ fs.mu.RUnlock()
306+ if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
307+ return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0)))
308+ }
309+ if mode == favoritesSidebarModeShortcut {
310+ if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311+ v := int(keyval - uint(gdk.KEY_0))
312+ return fs.setShortcutKey(&v)
313+ }
314+ if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) {
315+ return fs.setShortcutKey(nil)
316+ }
317+ }
318+ if keyval == uint(gdk.KEY_Delete) {
319+ if confirm {
320+ return fs.confirmDeleteFavorite()
321+ }
322+ return fs.requestDeleteConfirmation()
323+ }
324+ switch keyval {
325+ case uint(gdk.KEY_a):
326+ fs.beginAddForm()
327+ return true
328+ case uint(gdk.KEY_e):
329+ fs.beginEditForm()
330+ return true
331+ case uint(gdk.KEY_t):
332+ fs.enterTagMode()
333+ return true
334+ case uint(gdk.KEY_s):
335+ fs.enterShortcutMode()
336+ return true
337+ case uint(gdk.KEY_r):
338+ fs.startLoad()
339+ return true
340+ case uint(gdk.KEY_c):
341+ fs.clearQueryAndTags()
342+ return true
343+ }
344+ return false
345+}
346+
347+func (fs *FavoritesSidebar) clearQueryAndTags() {
348+ if fs == nil {
349+ return
350+ }
351+ if fs.searchEntry != nil {
352+ fs.searchEntry.SetText("")
353+ }
354+ fs.mu.Lock()
355+ if !fs.destroyed {
356+ fs.currentQuery = ""
357+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
358+ fs.rebuildDisplayRowsLocked()
359+ }
360+ fs.mu.Unlock()
361+ fs.renderTags()
362+ fs.rebuildList()
363+}
364+
365+func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366+ if fs == nil {
367+ return
368+ }
369+ zones := fs.availableFocusZones()
370+ if len(zones) == 0 {
371+ return
372+ }
373+ fs.mu.RLock()
374+ current := fs.focusZone
375+ fs.mu.RUnlock()
376+ idx := -1
377+ for i, zone := range zones {
378+ if zone == current {
379+ idx = i
380+ break
381+ }
382+ }
383+ if reverse {
384+ idx--
385+ if idx < 0 {
386+ idx = len(zones) - 1
387+ }
388+ } else {
389+ idx++
390+ if idx >= len(zones) {
391+ idx = 0
392+ }
393+ }
394+ fs.focusZoneWidget(zones[idx])
395+}
396+
397+func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398+ zones := []favoritesSidebarFocusZone{}
399+ if fs.searchEntry != nil {
400+ zones = append(zones, favoritesSidebarFocusSearch)
401+ }
402+ if fs.tagBox != nil {
403+ zones = append(zones, favoritesSidebarFocusTags)
404+ }
405+ if fs.listBox != nil {
406+ zones = append(zones, favoritesSidebarFocusList)
407+ }
408+ fs.mu.RLock()
409+ formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
410+ confirmActive := fs.confirmDelete
411+ fs.mu.RUnlock()
412+ if formActive && fs.formBox != nil {
413+ zones = append(zones, favoritesSidebarFocusForm)
414+ }
415+ if confirmActive {
416+ zones = append(zones, favoritesSidebarFocusConfirm)
417+ }
418+ return zones
419+}
420+
421+func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
422+ fs.mu.Lock()
423+ if !fs.destroyed {
424+ fs.focusZone = zone
425+ }
426+ fs.mu.Unlock()
427+ switch zone {
428+ case favoritesSidebarFocusSearch:
429+ fs.focusSearch()
430+ case favoritesSidebarFocusTags:
431+ if fs.tagBox != nil {
432+ fs.tagBox.GrabFocus()
433+ }
434+ case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435+ if fs.listBox != nil {
436+ fs.listBox.GrabFocus()
437+ }
438+ case favoritesSidebarFocusForm:
439+ fs.focusForm()
440+ }
441+}
442+
443+func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
444+ if fs == nil || url == "" {
445+ return false
446+ }
447+ if state&gdk.ShiftMaskValue != 0 {
448+ fs.navigateToNewPane(url)
449+ } else if state&gdk.ControlMaskValue != 0 {
450+ fs.navigateWithoutClosing(url)
451+ } else {
452+ fs.navigateToURL(url)
453+ }
454+ return true
455+}
456+
457+func (fs *FavoritesSidebar) handleNavigationError(err error) {
458+ if fs == nil || err == nil {
459+ return
460+ }
461+ fs.mu.Lock()
462+ if fs.destroyed {
463+ fs.mu.Unlock()
464+ return
465+ }
466+ fs.notice = err.Error()
467+ fs.mu.Unlock()
468+ fs.rebuildList()
469+}
470+
471+func abs(v int) int {
472+ if v < 0 {
473+ return -v
474+ }
475+ return v
476+}
A internal/ui/component/favorites_sidebar_rows.go +330 -0
@@ -0,0 +1,330 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/bnema/puregotk/v4/pango"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+type favoriteSidebarDisplayRowKind int
13+
14+const (
15+ favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16+)
17+
18+type favoriteSidebarDisplayRow struct {
19+ Kind favoriteSidebarDisplayRowKind
20+ FavoriteID entity.FavoriteID
21+ TagID entity.TagID
22+ URL string
23+ Favorite *entity.Favorite
24+ Selectable bool
25+}
26+
27+func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28+ rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29+ for _, fav := range favorites {
30+ if fav == nil {
31+ continue
32+ }
33+ rows = append(rows, favoriteSidebarDisplayRow{
34+ Kind: favoriteSidebarDisplayRowFavorite,
35+ FavoriteID: fav.ID,
36+ URL: fav.URL,
37+ Favorite: fav,
38+ Selectable: fav.URL != "",
39+ })
40+ }
41+ return rows
42+}
43+
44+func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45+ for i := range rows {
46+ if rows[i].Selectable {
47+ return i
48+ }
49+ }
50+ return -1
51+}
52+
53+func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54+ for i := len(rows) - 1; i >= 0; i-- {
55+ if rows[i].Selectable {
56+ return i
57+ }
58+ }
59+ return -1
60+}
61+
62+func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63+ if direction != -1 && direction != 1 {
64+ return -1
65+ }
66+ for i := current + direction; i >= 0 && i < len(rows); i += direction {
67+ if rows[i].Selectable {
68+ return i
69+ }
70+ }
71+ return -1
72+}
73+
74+func (fs *FavoritesSidebar) renderTags() {
75+ if fs == nil || fs.tagBox == nil {
76+ return
77+ }
78+ fs.mu.RLock()
79+ if fs.destroyed {
80+ fs.mu.RUnlock()
81+ return
82+ }
83+ tags := append([]*entity.Tag(nil), fs.allTags...)
84+ selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85+ for id := range fs.selectedTagIDs {
86+ selectedTagIDs[id] = struct{}{}
87+ }
88+ tagBox := fs.tagBox
89+ fs.mu.RUnlock()
90+
91+ clearBoxChildren(tagBox)
92+ callbacks := make([]interface{}, 0, len(tags)+1)
93+
94+ allButton := gtk.NewButtonWithLabel("All")
95+ if allButton != nil {
96+ if len(selectedTagIDs) == 0 {
97+ allButton.AddCssClass("suggested-action")
98+ }
99+ cb := func(_ gtk.Button) {
100+ fs.mu.Lock()
101+ if fs.destroyed {
102+ fs.mu.Unlock()
103+ return
104+ }
105+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
106+ fs.rebuildDisplayRowsLocked()
107+ fs.mu.Unlock()
108+ fs.renderTags()
109+ fs.rebuildList()
110+ }
111+ callbacks = append(callbacks, cb)
112+ allButton.ConnectClicked(&cb)
113+ tagBox.Append(&allButton.Widget)
114+ }
115+
116+ for _, tag := range tags {
117+ if tag == nil {
118+ continue
119+ }
120+ t := tag
121+ button := gtk.NewButtonWithLabel(t.Name)
122+ if button == nil {
123+ continue
124+ }
125+ if _, ok := selectedTagIDs[t.ID]; ok {
126+ button.AddCssClass("suggested-action")
127+ }
128+ cb := func(_ gtk.Button) {
129+ fs.toggleTag(t.ID)
130+ }
131+ callbacks = append(callbacks, cb)
132+ button.ConnectClicked(&cb)
133+ tagBox.Append(&button.Widget)
134+ }
135+
136+ fs.mu.Lock()
137+ if !fs.destroyed {
138+ fs.tagCallbacks = callbacks
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
144+ if fs == nil {
145+ return
146+ }
147+ fs.mu.Lock()
148+ if fs.destroyed {
149+ fs.mu.Unlock()
150+ return
151+ }
152+ if fs.selectedTagIDs == nil {
153+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
154+ }
155+ if _, ok := fs.selectedTagIDs[tagID]; ok {
156+ delete(fs.selectedTagIDs, tagID)
157+ } else {
158+ fs.selectedTagIDs[tagID] = struct{}{}
159+ }
160+ fs.rebuildDisplayRowsLocked()
161+ fs.mu.Unlock()
162+ fs.renderTags()
163+ fs.rebuildList()
164+}
165+
166+func (fs *FavoritesSidebar) rebuildList() {
167+ if fs == nil {
168+ return
169+ }
170+ fs.mu.RLock()
171+ if fs.destroyed || fs.listBox == nil {
172+ fs.mu.RUnlock()
173+ return
174+ }
175+ listBox := fs.listBox
176+ rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
177+ notice := fs.notice
178+ query := fs.currentQuery
179+ fs.mu.RUnlock()
180+
181+ listBox.RemoveAll()
182+ if len(rows) == 0 {
183+ for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
184+ fs.appendNoticeRow(listBox, text)
185+ }
186+ return
187+ }
188+ for _, row := range rows {
189+ fs.appendFavoriteRow(listBox, row)
190+ }
191+ for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192+ fs.appendNoticeRow(listBox, text)
193+ }
194+ fs.ensureAtLeastOneSelectionInListBox(listBox)
195+}
196+
197+func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
198+ if notice != "" {
199+ return []string{notice}
200+ }
201+ if hasRows {
202+ return nil
203+ }
204+ if query != "" {
205+ return []string{noResultsText(query)}
206+ }
207+ return []string{"No favorites"}
208+}
209+
210+func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
211+ label := gtk.NewLabel(&text)
212+ if label == nil {
213+ return
214+ }
215+ label.AddCssClass("favorites-sidebar-empty")
216+ label.SetXalign(0.0)
217+ row := gtk.NewListBoxRow()
218+ if row == nil {
219+ return
220+ }
221+ row.SetSelectable(false)
222+ row.SetCanFocus(false)
223+ row.SetActivatable(false)
224+ row.SetChild(&label.Widget)
225+ listBox.Append(&row.Widget)
226+}
227+
228+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
229+ fav := displayRow.Favorite
230+ if fav == nil {
231+ return
232+ }
233+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
234+ if rowBox == nil {
235+ return
236+ }
237+ rowBox.SetHexpand(true)
238+ appendFavoriteRowTitle(rowBox, fav)
239+ appendFavoriteRowSubtitle(rowBox, fav)
240+ appendFavoriteRowTags(rowBox, fav.Tags)
241+
242+ row := gtk.NewListBoxRow()
243+ if row == nil {
244+ return
245+ }
246+ row.AddCssClass("favorites-sidebar-row")
247+ row.SetSelectable(displayRow.Selectable)
248+ row.SetActivatable(displayRow.Selectable)
249+ row.SetCanFocus(displayRow.Selectable)
250+ row.SetFocusOnClick(displayRow.Selectable)
251+ row.SetChild(&rowBox.Widget)
252+ listBox.Append(&row.Widget)
253+}
254+
255+func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
256+ title := gtk.NewLabel(nil)
257+ if title == nil {
258+ return
259+ }
260+ title.AddCssClass("favorites-sidebar-row-title")
261+ title.SetText(safeSidebarString(fav.Title, fav.URL))
262+ title.SetXalign(0.0)
263+ title.SetHexpand(true)
264+ title.SetEllipsize(pango.EllipsizeEndValue)
265+ rowBox.Append(&title.Widget)
266+}
267+
268+func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
269+ sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
270+ if sub == nil {
271+ return
272+ }
273+ sub.SetHexpand(true)
274+ url := gtk.NewLabel(nil)
275+ if url != nil {
276+ url.AddCssClass("favorites-sidebar-row-subtitle")
277+ url.SetText(readableURL(fav.URL))
278+ url.SetXalign(0.0)
279+ url.SetHexpand(true)
280+ url.SetEllipsize(pango.EllipsizeEndValue)
281+ sub.Append(&url.Widget)
282+ }
283+ if fav.ShortcutKey != nil {
284+ badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
285+ badgeLabel := gtk.NewLabel(&badge)
286+ if badgeLabel != nil {
287+ badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
288+ sub.Append(&badgeLabel.Widget)
289+ }
290+ }
291+ rowBox.Append(&sub.Widget)
292+}
293+
294+func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
295+ if len(favoriteTags) == 0 {
296+ return
297+ }
298+ tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
299+ if tags == nil {
300+ return
301+ }
302+ for _, tag := range favoriteTags {
303+ name := tag.Name
304+ label := gtk.NewLabel(&name)
305+ if label != nil {
306+ label.AddCssClass("favorites-sidebar-tag-chip")
307+ tags.Append(&label.Widget)
308+ }
309+ }
310+ rowBox.Append(&tags.Widget)
311+}
312+
313+func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
314+ if listBox == nil || listBox.GetSelectedRow() != nil {
315+ return
316+ }
317+ fs.mu.RLock()
318+ index := firstSelectableIndex(fs.displayRows)
319+ fs.mu.RUnlock()
320+ if row := listBox.GetRowAtIndex(index); row != nil {
321+ listBox.SelectRow(row)
322+ }
323+}
324+
325+func (fs *FavoritesSidebar) rowURLAt(index int) string {
326+ if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
327+ return ""
328+ }
329+ return fs.displayRows[index].URL
330+}
@@ -0,0 +1,330 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/bnema/puregotk/v4/pango"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+type favoriteSidebarDisplayRowKind int
13+
14+const (
15+ favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16+)
17+
18+type favoriteSidebarDisplayRow struct {
19+ Kind favoriteSidebarDisplayRowKind
20+ FavoriteID entity.FavoriteID
21+ TagID entity.TagID
22+ URL string
23+ Favorite *entity.Favorite
24+ Selectable bool
25+}
26+
27+func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28+ rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29+ for _, fav := range favorites {
30+ if fav == nil {
31+ continue
32+ }
33+ rows = append(rows, favoriteSidebarDisplayRow{
34+ Kind: favoriteSidebarDisplayRowFavorite,
35+ FavoriteID: fav.ID,
36+ URL: fav.URL,
37+ Favorite: fav,
38+ Selectable: fav.URL != "",
39+ })
40+ }
41+ return rows
42+}
43+
44+func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45+ for i := range rows {
46+ if rows[i].Selectable {
47+ return i
48+ }
49+ }
50+ return -1
51+}
52+
53+func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54+ for i := len(rows) - 1; i >= 0; i-- {
55+ if rows[i].Selectable {
56+ return i
57+ }
58+ }
59+ return -1
60+}
61+
62+func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63+ if direction != -1 && direction != 1 {
64+ return -1
65+ }
66+ for i := current + direction; i >= 0 && i < len(rows); i += direction {
67+ if rows[i].Selectable {
68+ return i
69+ }
70+ }
71+ return -1
72+}
73+
74+func (fs *FavoritesSidebar) renderTags() {
75+ if fs == nil || fs.tagBox == nil {
76+ return
77+ }
78+ fs.mu.RLock()
79+ if fs.destroyed {
80+ fs.mu.RUnlock()
81+ return
82+ }
83+ tags := append([]*entity.Tag(nil), fs.allTags...)
84+ selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85+ for id := range fs.selectedTagIDs {
86+ selectedTagIDs[id] = struct{}{}
87+ }
88+ tagBox := fs.tagBox
89+ fs.mu.RUnlock()
90+
91+ clearBoxChildren(tagBox)
92+ callbacks := make([]interface{}, 0, len(tags)+1)
93+
94+ allButton := gtk.NewButtonWithLabel("All")
95+ if allButton != nil {
96+ if len(selectedTagIDs) == 0 {
97+ allButton.AddCssClass("suggested-action")
98+ }
99+ cb := func(_ gtk.Button) {
100+ fs.mu.Lock()
101+ if fs.destroyed {
102+ fs.mu.Unlock()
103+ return
104+ }
105+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
106+ fs.rebuildDisplayRowsLocked()
107+ fs.mu.Unlock()
108+ fs.renderTags()
109+ fs.rebuildList()
110+ }
111+ callbacks = append(callbacks, cb)
112+ allButton.ConnectClicked(&cb)
113+ tagBox.Append(&allButton.Widget)
114+ }
115+
116+ for _, tag := range tags {
117+ if tag == nil {
118+ continue
119+ }
120+ t := tag
121+ button := gtk.NewButtonWithLabel(t.Name)
122+ if button == nil {
123+ continue
124+ }
125+ if _, ok := selectedTagIDs[t.ID]; ok {
126+ button.AddCssClass("suggested-action")
127+ }
128+ cb := func(_ gtk.Button) {
129+ fs.toggleTag(t.ID)
130+ }
131+ callbacks = append(callbacks, cb)
132+ button.ConnectClicked(&cb)
133+ tagBox.Append(&button.Widget)
134+ }
135+
136+ fs.mu.Lock()
137+ if !fs.destroyed {
138+ fs.tagCallbacks = callbacks
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
144+ if fs == nil {
145+ return
146+ }
147+ fs.mu.Lock()
148+ if fs.destroyed {
149+ fs.mu.Unlock()
150+ return
151+ }
152+ if fs.selectedTagIDs == nil {
153+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
154+ }
155+ if _, ok := fs.selectedTagIDs[tagID]; ok {
156+ delete(fs.selectedTagIDs, tagID)
157+ } else {
158+ fs.selectedTagIDs[tagID] = struct{}{}
159+ }
160+ fs.rebuildDisplayRowsLocked()
161+ fs.mu.Unlock()
162+ fs.renderTags()
163+ fs.rebuildList()
164+}
165+
166+func (fs *FavoritesSidebar) rebuildList() {
167+ if fs == nil {
168+ return
169+ }
170+ fs.mu.RLock()
171+ if fs.destroyed || fs.listBox == nil {
172+ fs.mu.RUnlock()
173+ return
174+ }
175+ listBox := fs.listBox
176+ rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
177+ notice := fs.notice
178+ query := fs.currentQuery
179+ fs.mu.RUnlock()
180+
181+ listBox.RemoveAll()
182+ if len(rows) == 0 {
183+ for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
184+ fs.appendNoticeRow(listBox, text)
185+ }
186+ return
187+ }
188+ for _, row := range rows {
189+ fs.appendFavoriteRow(listBox, row)
190+ }
191+ for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192+ fs.appendNoticeRow(listBox, text)
193+ }
194+ fs.ensureAtLeastOneSelectionInListBox(listBox)
195+}
196+
197+func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
198+ if notice != "" {
199+ return []string{notice}
200+ }
201+ if hasRows {
202+ return nil
203+ }
204+ if query != "" {
205+ return []string{noResultsText(query)}
206+ }
207+ return []string{"No favorites"}
208+}
209+
210+func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
211+ label := gtk.NewLabel(&text)
212+ if label == nil {
213+ return
214+ }
215+ label.AddCssClass("favorites-sidebar-empty")
216+ label.SetXalign(0.0)
217+ row := gtk.NewListBoxRow()
218+ if row == nil {
219+ return
220+ }
221+ row.SetSelectable(false)
222+ row.SetCanFocus(false)
223+ row.SetActivatable(false)
224+ row.SetChild(&label.Widget)
225+ listBox.Append(&row.Widget)
226+}
227+
228+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
229+ fav := displayRow.Favorite
230+ if fav == nil {
231+ return
232+ }
233+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
234+ if rowBox == nil {
235+ return
236+ }
237+ rowBox.SetHexpand(true)
238+ appendFavoriteRowTitle(rowBox, fav)
239+ appendFavoriteRowSubtitle(rowBox, fav)
240+ appendFavoriteRowTags(rowBox, fav.Tags)
241+
242+ row := gtk.NewListBoxRow()
243+ if row == nil {
244+ return
245+ }
246+ row.AddCssClass("favorites-sidebar-row")
247+ row.SetSelectable(displayRow.Selectable)
248+ row.SetActivatable(displayRow.Selectable)
249+ row.SetCanFocus(displayRow.Selectable)
250+ row.SetFocusOnClick(displayRow.Selectable)
251+ row.SetChild(&rowBox.Widget)
252+ listBox.Append(&row.Widget)
253+}
254+
255+func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
256+ title := gtk.NewLabel(nil)
257+ if title == nil {
258+ return
259+ }
260+ title.AddCssClass("favorites-sidebar-row-title")
261+ title.SetText(safeSidebarString(fav.Title, fav.URL))
262+ title.SetXalign(0.0)
263+ title.SetHexpand(true)
264+ title.SetEllipsize(pango.EllipsizeEndValue)
265+ rowBox.Append(&title.Widget)
266+}
267+
268+func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
269+ sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
270+ if sub == nil {
271+ return
272+ }
273+ sub.SetHexpand(true)
274+ url := gtk.NewLabel(nil)
275+ if url != nil {
276+ url.AddCssClass("favorites-sidebar-row-subtitle")
277+ url.SetText(readableURL(fav.URL))
278+ url.SetXalign(0.0)
279+ url.SetHexpand(true)
280+ url.SetEllipsize(pango.EllipsizeEndValue)
281+ sub.Append(&url.Widget)
282+ }
283+ if fav.ShortcutKey != nil {
284+ badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
285+ badgeLabel := gtk.NewLabel(&badge)
286+ if badgeLabel != nil {
287+ badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
288+ sub.Append(&badgeLabel.Widget)
289+ }
290+ }
291+ rowBox.Append(&sub.Widget)
292+}
293+
294+func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
295+ if len(favoriteTags) == 0 {
296+ return
297+ }
298+ tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
299+ if tags == nil {
300+ return
301+ }
302+ for _, tag := range favoriteTags {
303+ name := tag.Name
304+ label := gtk.NewLabel(&name)
305+ if label != nil {
306+ label.AddCssClass("favorites-sidebar-tag-chip")
307+ tags.Append(&label.Widget)
308+ }
309+ }
310+ rowBox.Append(&tags.Widget)
311+}
312+
313+func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
314+ if listBox == nil || listBox.GetSelectedRow() != nil {
315+ return
316+ }
317+ fs.mu.RLock()
318+ index := firstSelectableIndex(fs.displayRows)
319+ fs.mu.RUnlock()
320+ if row := listBox.GetRowAtIndex(index); row != nil {
321+ listBox.SelectRow(row)
322+ }
323+}
324+
325+func (fs *FavoritesSidebar) rowURLAt(index int) string {
326+ if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
327+ return ""
328+ }
329+ return fs.displayRows[index].URL
330+}
A internal/ui/component/favorites_sidebar_test.go +540 -0
@@ -0,0 +1,540 @@
1+package component
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/puregotk/v4/gdk"
13+ "github.com/bnema/puregotk/v4/glib"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+type fakeFavoritesSidebarUC struct {
19+ favorites []*entity.Favorite
20+ tags []*entity.Tag
21+
22+ addInputs []dto.FavoriteCreateInput
23+ updateInputs []dto.FavoriteUpdateInput
24+ deletedIDs []entity.FavoriteID
25+ shortcuts []struct {
26+ id entity.FavoriteID
27+ key *int
28+ }
29+ tagged []struct {
30+ favID entity.FavoriteID
31+ tagID entity.TagID
32+ }
33+ untagged []struct {
34+ favID entity.FavoriteID
35+ tagID entity.TagID
36+ }
37+ err error
38+ errOnSetShortcut error
39+ getAllCalled chan struct{}
40+}
41+
42+func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
43+ if f.getAllCalled != nil {
44+ select {
45+ case f.getAllCalled <- struct{}{}:
46+ default:
47+ }
48+ }
49+ return f.favorites, f.err
50+}
51+func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
52+ return f.tags, f.err
53+}
54+func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
55+ f.addInputs = append(f.addInputs, input)
56+ if f.err != nil {
57+ return nil, f.err
58+ }
59+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
60+}
61+func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
62+ f.updateInputs = append(f.updateInputs, input)
63+ if f.err != nil {
64+ return nil, f.err
65+ }
66+ return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
67+}
68+func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
69+ f.deletedIDs = append(f.deletedIDs, id)
70+ return f.err
71+}
72+func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
73+ f.shortcuts = append(f.shortcuts, struct {
74+ id entity.FavoriteID
75+ key *int
76+ }{id: id, key: key})
77+ if f.errOnSetShortcut != nil {
78+ return f.errOnSetShortcut
79+ }
80+ return f.err
81+}
82+func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
83+ f.tagged = append(f.tagged, struct {
84+ favID entity.FavoriteID
85+ tagID entity.TagID
86+ }{favID: favID, tagID: tagID})
87+ return f.err
88+}
89+func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
90+ f.untagged = append(f.untagged, struct {
91+ favID entity.FavoriteID
92+ tagID entity.TagID
93+ }{favID: favID, tagID: tagID})
94+ return f.err
95+}
96+
97+func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
98+ fs := &FavoritesSidebar{
99+ favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
100+ selectedTagIDs: make(map[entity.TagID]struct{}),
101+ ctx: context.Background(),
102+ idleScheduler: func(cb glib.SourceFunc) {
103+ cb(0)
104+ },
105+ }
106+ return fs
107+}
108+
109+func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
110+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
111+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
112+ fs.loadGen = 1
113+
114+ applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
115+
116+ require.True(t, applied)
117+ assert.Len(t, fs.allFavorites, 1)
118+ assert.Len(t, fs.allTags, 1)
119+ require.Len(t, fs.displayRows, 1)
120+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
121+ assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
122+}
123+
124+func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
125+ devTag := entity.Tag{ID: 1, Name: "Dev"}
126+ newsTag := entity.Tag{ID: 2, Name: "News"}
127+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
128+ {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
129+ {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
130+ }, nil)
131+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
132+
133+ fs.currentQuery = "golang"
134+ fs.rebuildDisplayRowsLocked()
135+ require.Len(t, fs.displayRows, 1)
136+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
137+
138+ fs.currentQuery = "example.com"
139+ fs.rebuildDisplayRowsLocked()
140+ require.Len(t, fs.displayRows, 1)
141+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
142+
143+ fs.currentQuery = "dev"
144+ fs.rebuildDisplayRowsLocked()
145+ require.Len(t, fs.displayRows, 1)
146+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
147+}
148+
149+func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
150+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
151+ {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
152+ {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
153+ {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
154+ }, nil)
155+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
156+ fs.selectedTagIDs[1] = struct{}{}
157+ fs.selectedTagIDs[2] = struct{}{}
158+
159+ fs.rebuildDisplayRowsLocked()
160+
161+ require.Len(t, fs.displayRows, 2)
162+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
163+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
164+}
165+
166+func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
167+ fs := newFavoritesSidebarHarness(nil, nil)
168+ fs.ctx = context.Background()
169+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
170+ var mu sync.Mutex
171+ called := []string{}
172+ fs.onNavigate = func(_ context.Context, url string) error {
173+ mu.Lock()
174+ defer mu.Unlock()
175+ called = append(called, "nav:"+url)
176+ return nil
177+ }
178+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
179+ mu.Lock()
180+ defer mu.Unlock()
181+ called = append(called, "keep:"+url)
182+ return nil
183+ }
184+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
185+ mu.Lock()
186+ defer mu.Unlock()
187+ called = append(called, "pane:"+url)
188+ return nil
189+ }
190+
191+ fs.navigateToURL("https://a.test")
192+ fs.navigateWithoutClosing("https://b.test")
193+ fs.navigateToNewPane("https://c.test")
194+
195+ assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
196+}
197+
198+func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
199+ called := make(chan struct{}, 1)
200+ uc := &fakeFavoritesSidebarUC{
201+ favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
202+ tags: []*entity.Tag{{ID: 10, Name: "dev"}},
203+ getAllCalled: called,
204+ }
205+ fs := newFavoritesSidebarHarness(nil, nil)
206+ fs.favoritesUC = uc
207+ fs.visible = true
208+
209+ fs.RequestReloadIfVisible("test")
210+
211+ require.Eventually(t, func() bool {
212+ select {
213+ case <-called:
214+ return true
215+ default:
216+ return false
217+ }
218+ }, time.Second, 10*time.Millisecond)
219+ require.Eventually(t, func() bool {
220+ fs.mu.RLock()
221+ defer fs.mu.RUnlock()
222+ return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
223+ }, time.Second, 10*time.Millisecond)
224+}
225+
226+func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
227+ fs := newFavoritesSidebarHarness(nil, nil)
228+ fs.loadGen = 2
229+
230+ applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
231+
232+ assert.False(t, applied)
233+ assert.Empty(t, fs.allFavorites)
234+ assert.Empty(t, fs.displayRows)
235+}
236+
237+func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
238+ fs := newFavoritesSidebarHarness(nil, nil)
239+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
240+ called := false
241+ fs.onNavigate = func(context.Context, string) error {
242+ called = true
243+ return nil
244+ }
245+ fs.destroyed = true
246+
247+ fs.navigateToURL("https://example.test")
248+ fs.toggleTag(1)
249+ fs.onSearchChanged()
250+
251+ assert.False(t, called)
252+ assert.Empty(t, fs.selectedTagIDs)
253+}
254+
255+func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
256+ fs := newFavoritesSidebarHarness(nil, nil)
257+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
258+ fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
259+
260+ fs.navigateToURL("https://example.test")
261+
262+ assert.Equal(t, "navigation failed", fs.notice)
263+}
264+
265+func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
266+ fs := newFavoritesSidebarHarness(nil, nil)
267+ fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
268+ fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
269+ {ID: 1, URL: "https://go.dev", Title: "Go"},
270+ {ID: 2, URL: "", Title: "Missing URL"},
271+ })
272+
273+ assert.Len(t, fs.allTags, 2)
274+ require.Len(t, fs.displayRows, 2)
275+ assert.True(t, fs.displayRows[0].Selectable)
276+ assert.False(t, fs.displayRows[1].Selectable)
277+ assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
278+ assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
279+}
280+
281+func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
282+ assert.True(t, shouldFocusSearchForSlash(false))
283+ assert.False(t, shouldFocusSearchForSlash(true))
284+}
285+
286+func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
287+ fs := newFavoritesSidebarHarness(nil, nil)
288+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
289+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
290+ var called []string
291+ fs.onNavigate = func(_ context.Context, url string) error {
292+ called = append(called, "nav:"+url)
293+ return nil
294+ }
295+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
296+ called = append(called, "keep:"+url)
297+ return nil
298+ }
299+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
300+ called = append(called, "pane:"+url)
301+ return nil
302+ }
303+
304+ assert.True(t, fs.handleEnterKey(0))
305+ assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
306+ assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
307+
308+ assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
309+}
310+
311+func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
312+ fs := newFavoritesSidebarHarness(nil, nil)
313+ fs.searchEntry = nil
314+ fs.tagBox = nil
315+ fs.listBox = nil
316+ assert.Empty(t, fs.availableFocusZones())
317+
318+ fs.formBox = nil
319+ fs.mode = favoritesSidebarModeAdd
320+ fs.confirmDelete = true
321+ assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
322+}
323+
324+func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(t *testing.T) {
325+ fs := newFavoritesSidebarHarness(nil, nil)
326+ fs.searchEntry = nil
327+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
328+
329+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
330+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
331+
332+ fs.mode = favoritesSidebarModeNone
333+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
334+ assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
335+}
336+
337+func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
338+ uc := &fakeFavoritesSidebarUC{}
339+ fs := newFavoritesSidebarHarness(nil, nil)
340+ fs.favoritesUC = uc
341+ fs.formURL = "https://add.test"
342+ fs.formTitle = "Added"
343+ fs.formTags = "1, 2"
344+ fs.formShortcut = "3"
345+ fs.mode = favoritesSidebarModeAdd
346+
347+ assert.True(t, fs.submitForm())
348+ require.Len(t, uc.addInputs, 1)
349+ assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0])
350+ require.Len(t, uc.shortcuts, 1)
351+ assert.Equal(t, 3, *uc.shortcuts[0].key)
352+
353+ fs.mode = favoritesSidebarModeEdit
354+ fs.editingID = 7
355+ fs.formTitle = "Edited"
356+ fs.formTags = "999"
357+ fs.formShortcut = "4"
358+ assert.True(t, fs.submitForm())
359+ require.Len(t, uc.updateInputs, 1)
360+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
361+ assert.Equal(t, "Edited", uc.updateInputs[0].Title)
362+ require.NotNil(t, uc.updateInputs[0].ShortcutKey)
363+ assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
364+
365+ fs.mode = favoritesSidebarModeEdit
366+ fs.editingID = 7
367+ fs.formTitle = "Edited"
368+ fs.formShortcut = ""
369+ assert.True(t, fs.submitForm())
370+ require.Len(t, uc.updateInputs, 2)
371+ assert.True(t, uc.updateInputs[1].ShortcutKeySet)
372+ assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373+}
374+
375+func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
376+ uc := &fakeFavoritesSidebarUC{}
377+ fs := newFavoritesSidebarHarness(nil, nil)
378+ fs.favoritesUC = uc
379+ fs.formURL = "https://add.test"
380+ fs.formTitle = "Added"
381+ fs.formTags = "1, nope"
382+ fs.mode = favoritesSidebarModeAdd
383+
384+ assert.True(t, fs.submitForm())
385+ assert.Empty(t, uc.addInputs)
386+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
387+ assert.Contains(t, fs.notice, "invalid tag ID")
388+ assert.Equal(t, "1, nope", fs.formTags)
389+
390+ fs.formTags = "1"
391+ fs.formShortcut = "10"
392+ assert.True(t, fs.submitForm())
393+ assert.Empty(t, uc.addInputs)
394+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
395+ assert.Contains(t, fs.notice, "invalid shortcut")
396+ assert.Equal(t, "10", fs.formShortcut)
397+}
398+
399+func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
402+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
403+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
404+ fs.confirmDelete = true
405+ fs.confirmDeleteID = fav.ID
406+
407+ assert.True(t, fs.confirmDeleteActive())
408+ assert.True(t, fs.confirmDeleteFavorite())
409+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
410+}
411+
412+func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
413+ uc := &fakeFavoritesSidebarUC{}
414+ fs := newFavoritesSidebarHarness(nil, nil)
415+ fs.favoritesUC = uc
416+ fs.formURL = "https://add.test"
417+ fs.formTitle = "Added"
418+ fs.mode = favoritesSidebarModeAdd
419+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
420+ fs.onNavigate = func(context.Context, string) error {
421+ t.Fatal("plain Enter in form mode must not activate selected row")
422+ return nil
423+ }
424+
425+ assert.False(t, fs.handleReturnKey(0))
426+ assert.Empty(t, uc.addInputs)
427+ fs.mode = favoritesSidebarModeAdd
428+ assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
429+ require.Len(t, uc.addInputs, 1)
430+}
431+
432+func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
433+ uc := &fakeFavoritesSidebarUC{}
434+ fs := newFavoritesSidebarHarness(nil, nil)
435+ fs.favoritesUC = uc
436+ fs.mode = favoritesSidebarModeEdit
437+ fs.editingID = 7
438+ fs.formTitle = "Edited"
439+ fs.formTags = "999"
440+
441+ assert.True(t, fs.submitForm())
442+ require.Len(t, uc.updateInputs, 1)
443+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
444+ assert.Empty(t, uc.tagged)
445+ assert.Empty(t, uc.untagged)
446+}
447+
448+func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449+ fs := newFavoritesSidebarHarness(nil, nil)
450+ fs.searchEntry = nil
451+
452+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
453+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
454+}
455+
456+func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
457+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
458+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
459+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
460+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
461+
462+ assert.True(t, fs.confirmDeleteFavorite())
463+ assert.Empty(t, uc.deletedIDs)
464+ assert.True(t, fs.confirmDelete)
465+}
466+
467+func TestFavoritesSidebarConfirmDeleteRepromptsWhenSelectionChanges(t *testing.T) {
468+ first := &entity.Favorite{ID: 5, URL: "https://first.test"}
469+ second := &entity.Favorite{ID: 6, URL: "https://second.test"}
470+ fs := newFavoritesSidebarHarness([]*entity.Favorite{first, second}, nil)
471+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
472+ fs.displayRows = []favoriteSidebarDisplayRow{
473+ {FavoriteID: second.ID, Favorite: second, URL: second.URL, Selectable: true},
474+ {FavoriteID: first.ID, Favorite: first, URL: first.URL, Selectable: true},
475+ }
476+ fs.confirmDelete = true
477+ fs.confirmDeleteID = first.ID
478+
479+ assert.True(t, fs.confirmDeleteFavorite())
480+ assert.Empty(t, uc.deletedIDs)
481+ assert.True(t, fs.confirmDelete)
482+ assert.Equal(t, second.ID, fs.confirmDeleteID)
483+}
484+
485+func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
486+ uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
487+ fs := newFavoritesSidebarHarness(nil, nil)
488+ fs.favoritesUC = uc
489+ fs.formURL = "https://add.test"
490+ fs.formTitle = "Added"
491+ fs.formShortcut = "3"
492+ fs.mode = favoritesSidebarModeAdd
493+
494+ assert.True(t, fs.submitForm())
495+ require.Len(t, uc.addInputs, 1)
496+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
497+ assert.Contains(t, fs.notice, "failed to set shortcut")
498+}
499+
500+func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
501+ dev := entity.Tag{ID: 10, Name: "dev"}
502+ news := entity.Tag{ID: 11, Name: "news"}
503+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
504+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
505+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
506+ fs.allTags = uc.tags
507+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
508+
509+ fs.mode = favoritesSidebarModeTag
510+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0))
511+ require.Len(t, uc.untagged, 1)
512+ assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
513+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0))
514+ require.Len(t, uc.tagged, 1)
515+ assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
516+
517+ fs.mode = favoritesSidebarModeShortcut
518+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
519+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
520+ require.GreaterOrEqual(t, len(uc.shortcuts), 2)
521+ assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
522+ assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
523+
524+ fs.mode = favoritesSidebarModeNone
525+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
526+ assert.True(t, fs.confirmDelete)
527+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
528+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
529+}
530+
531+func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
532+ fs := newFavoritesSidebarHarness(nil, nil)
533+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
534+ uc.err = errors.New("add failed")
535+ fs.mode = favoritesSidebarModeAdd
536+
537+ assert.True(t, fs.submitForm())
538+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
539+ assert.Equal(t, "add failed", fs.notice)
540+}
@@ -0,0 +1,540 @@
1+package component
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/puregotk/v4/gdk"
13+ "github.com/bnema/puregotk/v4/glib"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+type fakeFavoritesSidebarUC struct {
19+ favorites []*entity.Favorite
20+ tags []*entity.Tag
21+
22+ addInputs []dto.FavoriteCreateInput
23+ updateInputs []dto.FavoriteUpdateInput
24+ deletedIDs []entity.FavoriteID
25+ shortcuts []struct {
26+ id entity.FavoriteID
27+ key *int
28+ }
29+ tagged []struct {
30+ favID entity.FavoriteID
31+ tagID entity.TagID
32+ }
33+ untagged []struct {
34+ favID entity.FavoriteID
35+ tagID entity.TagID
36+ }
37+ err error
38+ errOnSetShortcut error
39+ getAllCalled chan struct{}
40+}
41+
42+func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
43+ if f.getAllCalled != nil {
44+ select {
45+ case f.getAllCalled <- struct{}{}:
46+ default:
47+ }
48+ }
49+ return f.favorites, f.err
50+}
51+func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
52+ return f.tags, f.err
53+}
54+func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
55+ f.addInputs = append(f.addInputs, input)
56+ if f.err != nil {
57+ return nil, f.err
58+ }
59+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
60+}
61+func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
62+ f.updateInputs = append(f.updateInputs, input)
63+ if f.err != nil {
64+ return nil, f.err
65+ }
66+ return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
67+}
68+func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
69+ f.deletedIDs = append(f.deletedIDs, id)
70+ return f.err
71+}
72+func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
73+ f.shortcuts = append(f.shortcuts, struct {
74+ id entity.FavoriteID
75+ key *int
76+ }{id: id, key: key})
77+ if f.errOnSetShortcut != nil {
78+ return f.errOnSetShortcut
79+ }
80+ return f.err
81+}
82+func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
83+ f.tagged = append(f.tagged, struct {
84+ favID entity.FavoriteID
85+ tagID entity.TagID
86+ }{favID: favID, tagID: tagID})
87+ return f.err
88+}
89+func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
90+ f.untagged = append(f.untagged, struct {
91+ favID entity.FavoriteID
92+ tagID entity.TagID
93+ }{favID: favID, tagID: tagID})
94+ return f.err
95+}
96+
97+func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
98+ fs := &FavoritesSidebar{
99+ favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
100+ selectedTagIDs: make(map[entity.TagID]struct{}),
101+ ctx: context.Background(),
102+ idleScheduler: func(cb glib.SourceFunc) {
103+ cb(0)
104+ },
105+ }
106+ return fs
107+}
108+
109+func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
110+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
111+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
112+ fs.loadGen = 1
113+
114+ applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
115+
116+ require.True(t, applied)
117+ assert.Len(t, fs.allFavorites, 1)
118+ assert.Len(t, fs.allTags, 1)
119+ require.Len(t, fs.displayRows, 1)
120+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
121+ assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
122+}
123+
124+func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
125+ devTag := entity.Tag{ID: 1, Name: "Dev"}
126+ newsTag := entity.Tag{ID: 2, Name: "News"}
127+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
128+ {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
129+ {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
130+ }, nil)
131+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
132+
133+ fs.currentQuery = "golang"
134+ fs.rebuildDisplayRowsLocked()
135+ require.Len(t, fs.displayRows, 1)
136+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
137+
138+ fs.currentQuery = "example.com"
139+ fs.rebuildDisplayRowsLocked()
140+ require.Len(t, fs.displayRows, 1)
141+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
142+
143+ fs.currentQuery = "dev"
144+ fs.rebuildDisplayRowsLocked()
145+ require.Len(t, fs.displayRows, 1)
146+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
147+}
148+
149+func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
150+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
151+ {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
152+ {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
153+ {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
154+ }, nil)
155+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
156+ fs.selectedTagIDs[1] = struct{}{}
157+ fs.selectedTagIDs[2] = struct{}{}
158+
159+ fs.rebuildDisplayRowsLocked()
160+
161+ require.Len(t, fs.displayRows, 2)
162+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
163+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
164+}
165+
166+func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
167+ fs := newFavoritesSidebarHarness(nil, nil)
168+ fs.ctx = context.Background()
169+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
170+ var mu sync.Mutex
171+ called := []string{}
172+ fs.onNavigate = func(_ context.Context, url string) error {
173+ mu.Lock()
174+ defer mu.Unlock()
175+ called = append(called, "nav:"+url)
176+ return nil
177+ }
178+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
179+ mu.Lock()
180+ defer mu.Unlock()
181+ called = append(called, "keep:"+url)
182+ return nil
183+ }
184+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
185+ mu.Lock()
186+ defer mu.Unlock()
187+ called = append(called, "pane:"+url)
188+ return nil
189+ }
190+
191+ fs.navigateToURL("https://a.test")
192+ fs.navigateWithoutClosing("https://b.test")
193+ fs.navigateToNewPane("https://c.test")
194+
195+ assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
196+}
197+
198+func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
199+ called := make(chan struct{}, 1)
200+ uc := &fakeFavoritesSidebarUC{
201+ favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
202+ tags: []*entity.Tag{{ID: 10, Name: "dev"}},
203+ getAllCalled: called,
204+ }
205+ fs := newFavoritesSidebarHarness(nil, nil)
206+ fs.favoritesUC = uc
207+ fs.visible = true
208+
209+ fs.RequestReloadIfVisible("test")
210+
211+ require.Eventually(t, func() bool {
212+ select {
213+ case <-called:
214+ return true
215+ default:
216+ return false
217+ }
218+ }, time.Second, 10*time.Millisecond)
219+ require.Eventually(t, func() bool {
220+ fs.mu.RLock()
221+ defer fs.mu.RUnlock()
222+ return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
223+ }, time.Second, 10*time.Millisecond)
224+}
225+
226+func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
227+ fs := newFavoritesSidebarHarness(nil, nil)
228+ fs.loadGen = 2
229+
230+ applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
231+
232+ assert.False(t, applied)
233+ assert.Empty(t, fs.allFavorites)
234+ assert.Empty(t, fs.displayRows)
235+}
236+
237+func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
238+ fs := newFavoritesSidebarHarness(nil, nil)
239+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
240+ called := false
241+ fs.onNavigate = func(context.Context, string) error {
242+ called = true
243+ return nil
244+ }
245+ fs.destroyed = true
246+
247+ fs.navigateToURL("https://example.test")
248+ fs.toggleTag(1)
249+ fs.onSearchChanged()
250+
251+ assert.False(t, called)
252+ assert.Empty(t, fs.selectedTagIDs)
253+}
254+
255+func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
256+ fs := newFavoritesSidebarHarness(nil, nil)
257+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
258+ fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
259+
260+ fs.navigateToURL("https://example.test")
261+
262+ assert.Equal(t, "navigation failed", fs.notice)
263+}
264+
265+func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
266+ fs := newFavoritesSidebarHarness(nil, nil)
267+ fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
268+ fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
269+ {ID: 1, URL: "https://go.dev", Title: "Go"},
270+ {ID: 2, URL: "", Title: "Missing URL"},
271+ })
272+
273+ assert.Len(t, fs.allTags, 2)
274+ require.Len(t, fs.displayRows, 2)
275+ assert.True(t, fs.displayRows[0].Selectable)
276+ assert.False(t, fs.displayRows[1].Selectable)
277+ assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
278+ assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
279+}
280+
281+func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
282+ assert.True(t, shouldFocusSearchForSlash(false))
283+ assert.False(t, shouldFocusSearchForSlash(true))
284+}
285+
286+func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
287+ fs := newFavoritesSidebarHarness(nil, nil)
288+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
289+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
290+ var called []string
291+ fs.onNavigate = func(_ context.Context, url string) error {
292+ called = append(called, "nav:"+url)
293+ return nil
294+ }
295+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
296+ called = append(called, "keep:"+url)
297+ return nil
298+ }
299+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
300+ called = append(called, "pane:"+url)
301+ return nil
302+ }
303+
304+ assert.True(t, fs.handleEnterKey(0))
305+ assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
306+ assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
307+
308+ assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
309+}
310+
311+func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
312+ fs := newFavoritesSidebarHarness(nil, nil)
313+ fs.searchEntry = nil
314+ fs.tagBox = nil
315+ fs.listBox = nil
316+ assert.Empty(t, fs.availableFocusZones())
317+
318+ fs.formBox = nil
319+ fs.mode = favoritesSidebarModeAdd
320+ fs.confirmDelete = true
321+ assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
322+}
323+
324+func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(t *testing.T) {
325+ fs := newFavoritesSidebarHarness(nil, nil)
326+ fs.searchEntry = nil
327+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
328+
329+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
330+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
331+
332+ fs.mode = favoritesSidebarModeNone
333+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
334+ assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
335+}
336+
337+func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
338+ uc := &fakeFavoritesSidebarUC{}
339+ fs := newFavoritesSidebarHarness(nil, nil)
340+ fs.favoritesUC = uc
341+ fs.formURL = "https://add.test"
342+ fs.formTitle = "Added"
343+ fs.formTags = "1, 2"
344+ fs.formShortcut = "3"
345+ fs.mode = favoritesSidebarModeAdd
346+
347+ assert.True(t, fs.submitForm())
348+ require.Len(t, uc.addInputs, 1)
349+ assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0])
350+ require.Len(t, uc.shortcuts, 1)
351+ assert.Equal(t, 3, *uc.shortcuts[0].key)
352+
353+ fs.mode = favoritesSidebarModeEdit
354+ fs.editingID = 7
355+ fs.formTitle = "Edited"
356+ fs.formTags = "999"
357+ fs.formShortcut = "4"
358+ assert.True(t, fs.submitForm())
359+ require.Len(t, uc.updateInputs, 1)
360+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
361+ assert.Equal(t, "Edited", uc.updateInputs[0].Title)
362+ require.NotNil(t, uc.updateInputs[0].ShortcutKey)
363+ assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
364+
365+ fs.mode = favoritesSidebarModeEdit
366+ fs.editingID = 7
367+ fs.formTitle = "Edited"
368+ fs.formShortcut = ""
369+ assert.True(t, fs.submitForm())
370+ require.Len(t, uc.updateInputs, 2)
371+ assert.True(t, uc.updateInputs[1].ShortcutKeySet)
372+ assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373+}
374+
375+func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
376+ uc := &fakeFavoritesSidebarUC{}
377+ fs := newFavoritesSidebarHarness(nil, nil)
378+ fs.favoritesUC = uc
379+ fs.formURL = "https://add.test"
380+ fs.formTitle = "Added"
381+ fs.formTags = "1, nope"
382+ fs.mode = favoritesSidebarModeAdd
383+
384+ assert.True(t, fs.submitForm())
385+ assert.Empty(t, uc.addInputs)
386+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
387+ assert.Contains(t, fs.notice, "invalid tag ID")
388+ assert.Equal(t, "1, nope", fs.formTags)
389+
390+ fs.formTags = "1"
391+ fs.formShortcut = "10"
392+ assert.True(t, fs.submitForm())
393+ assert.Empty(t, uc.addInputs)
394+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
395+ assert.Contains(t, fs.notice, "invalid shortcut")
396+ assert.Equal(t, "10", fs.formShortcut)
397+}
398+
399+func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
402+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
403+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
404+ fs.confirmDelete = true
405+ fs.confirmDeleteID = fav.ID
406+
407+ assert.True(t, fs.confirmDeleteActive())
408+ assert.True(t, fs.confirmDeleteFavorite())
409+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
410+}
411+
412+func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
413+ uc := &fakeFavoritesSidebarUC{}
414+ fs := newFavoritesSidebarHarness(nil, nil)
415+ fs.favoritesUC = uc
416+ fs.formURL = "https://add.test"
417+ fs.formTitle = "Added"
418+ fs.mode = favoritesSidebarModeAdd
419+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
420+ fs.onNavigate = func(context.Context, string) error {
421+ t.Fatal("plain Enter in form mode must not activate selected row")
422+ return nil
423+ }
424+
425+ assert.False(t, fs.handleReturnKey(0))
426+ assert.Empty(t, uc.addInputs)
427+ fs.mode = favoritesSidebarModeAdd
428+ assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
429+ require.Len(t, uc.addInputs, 1)
430+}
431+
432+func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
433+ uc := &fakeFavoritesSidebarUC{}
434+ fs := newFavoritesSidebarHarness(nil, nil)
435+ fs.favoritesUC = uc
436+ fs.mode = favoritesSidebarModeEdit
437+ fs.editingID = 7
438+ fs.formTitle = "Edited"
439+ fs.formTags = "999"
440+
441+ assert.True(t, fs.submitForm())
442+ require.Len(t, uc.updateInputs, 1)
443+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
444+ assert.Empty(t, uc.tagged)
445+ assert.Empty(t, uc.untagged)
446+}
447+
448+func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449+ fs := newFavoritesSidebarHarness(nil, nil)
450+ fs.searchEntry = nil
451+
452+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
453+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
454+}
455+
456+func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
457+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
458+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
459+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
460+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
461+
462+ assert.True(t, fs.confirmDeleteFavorite())
463+ assert.Empty(t, uc.deletedIDs)
464+ assert.True(t, fs.confirmDelete)
465+}
466+
467+func TestFavoritesSidebarConfirmDeleteRepromptsWhenSelectionChanges(t *testing.T) {
468+ first := &entity.Favorite{ID: 5, URL: "https://first.test"}
469+ second := &entity.Favorite{ID: 6, URL: "https://second.test"}
470+ fs := newFavoritesSidebarHarness([]*entity.Favorite{first, second}, nil)
471+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
472+ fs.displayRows = []favoriteSidebarDisplayRow{
473+ {FavoriteID: second.ID, Favorite: second, URL: second.URL, Selectable: true},
474+ {FavoriteID: first.ID, Favorite: first, URL: first.URL, Selectable: true},
475+ }
476+ fs.confirmDelete = true
477+ fs.confirmDeleteID = first.ID
478+
479+ assert.True(t, fs.confirmDeleteFavorite())
480+ assert.Empty(t, uc.deletedIDs)
481+ assert.True(t, fs.confirmDelete)
482+ assert.Equal(t, second.ID, fs.confirmDeleteID)
483+}
484+
485+func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
486+ uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
487+ fs := newFavoritesSidebarHarness(nil, nil)
488+ fs.favoritesUC = uc
489+ fs.formURL = "https://add.test"
490+ fs.formTitle = "Added"
491+ fs.formShortcut = "3"
492+ fs.mode = favoritesSidebarModeAdd
493+
494+ assert.True(t, fs.submitForm())
495+ require.Len(t, uc.addInputs, 1)
496+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
497+ assert.Contains(t, fs.notice, "failed to set shortcut")
498+}
499+
500+func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
501+ dev := entity.Tag{ID: 10, Name: "dev"}
502+ news := entity.Tag{ID: 11, Name: "news"}
503+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
504+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
505+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
506+ fs.allTags = uc.tags
507+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
508+
509+ fs.mode = favoritesSidebarModeTag
510+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0))
511+ require.Len(t, uc.untagged, 1)
512+ assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
513+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0))
514+ require.Len(t, uc.tagged, 1)
515+ assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
516+
517+ fs.mode = favoritesSidebarModeShortcut
518+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
519+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
520+ require.GreaterOrEqual(t, len(uc.shortcuts), 2)
521+ assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
522+ assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
523+
524+ fs.mode = favoritesSidebarModeNone
525+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
526+ assert.True(t, fs.confirmDelete)
527+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
528+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
529+}
530+
531+func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
532+ fs := newFavoritesSidebarHarness(nil, nil)
533+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
534+ uc.err = errors.New("add failed")
535+ fs.mode = favoritesSidebarModeAdd
536+
537+ assert.True(t, fs.submitForm())
538+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
539+ assert.Equal(t, "add failed", fs.notice)
540+}
M internal/ui/dispatcher/keyboard.go +39 -20
@@ -36,25 +36,27 @@ type KeyboardActions struct {
3636
3737 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
3838 type KeyboardDispatcher struct {
39- actions KeyboardActions
40- wsCoord *coordinator.WorkspaceCoordinator
41- navCoord *coordinator.NavigationCoordinator
42- zoomUC *usecase.ManageZoomUseCase
43- copyURLUC *usecase.CopyURLUseCase
44- actionHandlers map[input.Action]func(ctx context.Context) error
45- onQuit func()
46- onFindOpen func(ctx context.Context) error
47- onFindNext func(ctx context.Context) error
48- onFindPrev func(ctx context.Context) error
49- onFindClose func(ctx context.Context) error
50- activePaneID func(ctx context.Context) entity.PaneID
51- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54- onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55- onToggleHistorySidebar func(ctx context.Context) error
56- onToggleFloating func(ctx context.Context) error
57- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
39+ actions KeyboardActions
40+ wsCoord *coordinator.WorkspaceCoordinator
41+ navCoord *coordinator.NavigationCoordinator
42+ zoomUC *usecase.ManageZoomUseCase
43+ copyURLUC *usecase.CopyURLUseCase
44+ actionHandlers map[input.Action]func(ctx context.Context) error
45+ onQuit func()
46+ onFindOpen func(ctx context.Context) error
47+ onFindNext func(ctx context.Context) error
48+ onFindPrev func(ctx context.Context) error
49+ onFindClose func(ctx context.Context) error
50+ activePaneID func(ctx context.Context) entity.PaneID
51+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onToggleHistorySidebar func(ctx context.Context) error
56+ onToggleFavoritesSidebar func(ctx context.Context) error
57+ onToggleCurrentFavorite func(ctx context.Context) error
58+ onToggleFloating func(ctx context.Context) error
59+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
5860 }
5961
6062 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -128,6 +130,14 @@ func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Conte
128130 d.onToggleHistorySidebar = fn
129131 }
130132
133+func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
134+ d.onToggleFavoritesSidebar = fn
135+}
136+
137+func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
138+ d.onToggleCurrentFavorite = fn
139+}
140+
131141 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
132142 d.onToggleFloating = fn
133143 }
@@ -262,7 +272,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
262272 return d.onToggleHistorySidebar(ctx)
263273 },
264274 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
265- return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
275+ if d.onToggleFavoritesSidebar == nil {
276+ return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277+ }
278+ return d.onToggleFavoritesSidebar(ctx)
279+ },
280+ input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
281+ if d.onToggleCurrentFavorite == nil {
282+ return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
283+ }
284+ return d.onToggleCurrentFavorite(ctx)
266285 },
267286 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
268287 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
@@ -36,25 +36,27 @@ type KeyboardActions struct {
36 36
37 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.37 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
38 type KeyboardDispatcher struct {38 type KeyboardDispatcher struct {
39- actions KeyboardActions39+ actions KeyboardActions
40- wsCoord *coordinator.WorkspaceCoordinator40+ wsCoord *coordinator.WorkspaceCoordinator
41- navCoord *coordinator.NavigationCoordinator41+ navCoord *coordinator.NavigationCoordinator
42- zoomUC *usecase.ManageZoomUseCase42+ zoomUC *usecase.ManageZoomUseCase
43- copyURLUC *usecase.CopyURLUseCase43+ copyURLUC *usecase.CopyURLUseCase
44- actionHandlers map[input.Action]func(ctx context.Context) error44+ actionHandlers map[input.Action]func(ctx context.Context) error
45- onQuit func()45+ onQuit func()
46- onFindOpen func(ctx context.Context) error46+ onFindOpen func(ctx context.Context) error
47- onFindNext func(ctx context.Context) error47+ onFindNext func(ctx context.Context) error
48- onFindPrev func(ctx context.Context) error48+ onFindPrev func(ctx context.Context) error
49- onFindClose func(ctx context.Context) error49+ onFindClose func(ctx context.Context) error
50- activePaneID func(ctx context.Context) entity.PaneID50+ activePaneID func(ctx context.Context) entity.PaneID
51- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error51+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error52+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error53+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54- onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error54+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55- onToggleHistorySidebar func(ctx context.Context) error55+ onToggleHistorySidebar func(ctx context.Context) error
56- onToggleFloating func(ctx context.Context) error56+ onToggleFavoritesSidebar func(ctx context.Context) error
57- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error57+ onToggleCurrentFavorite func(ctx context.Context) error
58+ onToggleFloating func(ctx context.Context) error
59+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
58 }60 }
59 61
60 // NewKeyboardDispatcher creates a new KeyboardDispatcher.62 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -128,6 +130,14 @@ func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Conte
128 d.onToggleHistorySidebar = fn130 d.onToggleHistorySidebar = fn
129 }131 }
130 132
133+func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
134+ d.onToggleFavoritesSidebar = fn
135+}
136+
137+func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
138+ d.onToggleCurrentFavorite = fn
139+}
140+
131 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {141 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
132 d.onToggleFloating = fn142 d.onToggleFloating = fn
133 }143 }
@@ -262,7 +272,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
262 return d.onToggleHistorySidebar(ctx)272 return d.onToggleHistorySidebar(ctx)
263 },273 },
264 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {274 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
265- return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)275+ if d.onToggleFavoritesSidebar == nil {
276+ return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277+ }
278+ return d.onToggleFavoritesSidebar(ctx)
279+ },
280+ input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
281+ if d.onToggleCurrentFavorite == nil {
282+ return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
283+ }
284+ return d.onToggleCurrentFavorite(ctx)
266 },285 },
267 input.ActionToggleConfigSystemView: func(ctx context.Context) error {286 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
268 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)287 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
M internal/ui/dispatcher/keyboard_test.go +40 -0
@@ -115,6 +115,46 @@ func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *test
115115 assert.ErrorContains(t, err, "history sidebar unavailable")
116116 }
117117
118+func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
119+ ctx := context.Background()
120+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121+
122+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
123+ require.Error(t, missingErr)
124+ require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125+
126+ wantErr := fmt.Errorf("favorites failed")
127+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
129+ require.Error(t, err)
130+ require.ErrorIs(t, err, wantErr)
131+
132+ var called bool
133+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
134+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
135+ assert.True(t, called)
136+}
137+
138+func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
139+ ctx := context.Background()
140+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
141+
142+ missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
143+ require.Error(t, missingErr)
144+ require.ErrorContains(t, missingErr, "toggle current page handler not wired")
145+
146+ wantErr := fmt.Errorf("toggle failed")
147+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
148+ err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
149+ require.Error(t, err)
150+ require.ErrorIs(t, err, wantErr)
151+
152+ var called bool
153+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
154+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
155+ assert.True(t, called)
156+}
157+
118158 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
119159 ctx := context.Background()
120160 activePaneID := entity.PaneID("pane-1")
@@ -115,6 +115,46 @@ func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *test
115 assert.ErrorContains(t, err, "history sidebar unavailable")115 assert.ErrorContains(t, err, "history sidebar unavailable")
116 }116 }
117 117
118+func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
119+ ctx := context.Background()
120+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121+
122+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
123+ require.Error(t, missingErr)
124+ require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125+
126+ wantErr := fmt.Errorf("favorites failed")
127+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
129+ require.Error(t, err)
130+ require.ErrorIs(t, err, wantErr)
131+
132+ var called bool
133+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
134+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
135+ assert.True(t, called)
136+}
137+
138+func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
139+ ctx := context.Background()
140+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
141+
142+ missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
143+ require.Error(t, missingErr)
144+ require.ErrorContains(t, missingErr, "toggle current page handler not wired")
145+
146+ wantErr := fmt.Errorf("toggle failed")
147+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
148+ err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
149+ require.Error(t, err)
150+ require.ErrorIs(t, err, wantErr)
151+
152+ var called bool
153+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
154+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
155+ assert.True(t, called)
156+}
157+
118 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {158 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
119 ctx := context.Background()159 ctx := context.Background()
120 activePaneID := entity.PaneID("pane-1")160 activePaneID := entity.PaneID("pane-1")
M internal/ui/input/global_shortcuts.go +19 -16
@@ -484,22 +484,24 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
484484
485485 func globalShortcutActionMap() map[string]Action {
486486 return map[string]Action{
487- "toggle_floating_pane": ActionToggleFloatingPane,
488- "toggle-floating-pane": ActionToggleFloatingPane,
489- "consume_or_expel_left": ActionConsumeOrExpelLeft,
490- "consume-or-expel-left": ActionConsumeOrExpelLeft,
491- "consume_or_expel_right": ActionConsumeOrExpelRight,
492- "consume-or-expel-right": ActionConsumeOrExpelRight,
493- "consume_or_expel_up": ActionConsumeOrExpelUp,
494- "consume-or-expel-up": ActionConsumeOrExpelUp,
495- "consume_or_expel_down": ActionConsumeOrExpelDown,
496- "consume-or-expel-down": ActionConsumeOrExpelDown,
497- "toggle_history_systemview": ActionToggleHistorySystemView,
498- "toggle-history-systemview": ActionToggleHistorySystemView,
499- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501- "toggle_config_systemview": ActionToggleConfigSystemView,
502- "toggle-config-systemview": ActionToggleConfigSystemView,
487+ "toggle_floating_pane": ActionToggleFloatingPane,
488+ "toggle-floating-pane": ActionToggleFloatingPane,
489+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
490+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
491+ "consume_or_expel_right": ActionConsumeOrExpelRight,
492+ "consume-or-expel-right": ActionConsumeOrExpelRight,
493+ "consume_or_expel_up": ActionConsumeOrExpelUp,
494+ "consume-or-expel-up": ActionConsumeOrExpelUp,
495+ "consume_or_expel_down": ActionConsumeOrExpelDown,
496+ "consume-or-expel-down": ActionConsumeOrExpelDown,
497+ "toggle_history_systemview": ActionToggleHistorySystemView,
498+ "toggle-history-systemview": ActionToggleHistorySystemView,
499+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
502+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
503+ "toggle_config_systemview": ActionToggleConfigSystemView,
504+ "toggle-config-systemview": ActionToggleConfigSystemView,
503505 }
504506 }
505507
@@ -711,6 +713,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711713 ActionToggleFloatingPane,
712714 ActionToggleHistorySystemView,
713715 ActionToggleFavoritesSystemView,
716+ ActionToggleCurrentPageFavorite,
714717 ActionToggleConfigSystemView,
715718 ActionCopyURL,
716719 ActionConsumeOrExpelLeft,
@@ -484,22 +484,24 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
484 484
485 func globalShortcutActionMap() map[string]Action {485 func globalShortcutActionMap() map[string]Action {
486 return map[string]Action{486 return map[string]Action{
487- "toggle_floating_pane": ActionToggleFloatingPane,487+ "toggle_floating_pane": ActionToggleFloatingPane,
488- "toggle-floating-pane": ActionToggleFloatingPane,488+ "toggle-floating-pane": ActionToggleFloatingPane,
489- "consume_or_expel_left": ActionConsumeOrExpelLeft,489+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
490- "consume-or-expel-left": ActionConsumeOrExpelLeft,490+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
491- "consume_or_expel_right": ActionConsumeOrExpelRight,491+ "consume_or_expel_right": ActionConsumeOrExpelRight,
492- "consume-or-expel-right": ActionConsumeOrExpelRight,492+ "consume-or-expel-right": ActionConsumeOrExpelRight,
493- "consume_or_expel_up": ActionConsumeOrExpelUp,493+ "consume_or_expel_up": ActionConsumeOrExpelUp,
494- "consume-or-expel-up": ActionConsumeOrExpelUp,494+ "consume-or-expel-up": ActionConsumeOrExpelUp,
495- "consume_or_expel_down": ActionConsumeOrExpelDown,495+ "consume_or_expel_down": ActionConsumeOrExpelDown,
496- "consume-or-expel-down": ActionConsumeOrExpelDown,496+ "consume-or-expel-down": ActionConsumeOrExpelDown,
497- "toggle_history_systemview": ActionToggleHistorySystemView,497+ "toggle_history_systemview": ActionToggleHistorySystemView,
498- "toggle-history-systemview": ActionToggleHistorySystemView,498+ "toggle-history-systemview": ActionToggleHistorySystemView,
499- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,499+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,500+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501- "toggle_config_systemview": ActionToggleConfigSystemView,501+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
502- "toggle-config-systemview": ActionToggleConfigSystemView,502+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
503+ "toggle_config_systemview": ActionToggleConfigSystemView,
504+ "toggle-config-systemview": ActionToggleConfigSystemView,
503 }505 }
504 }506 }
505 507
@@ -711,6 +713,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711 ActionToggleFloatingPane,713 ActionToggleFloatingPane,
712 ActionToggleHistorySystemView,714 ActionToggleHistorySystemView,
713 ActionToggleFavoritesSystemView,715 ActionToggleFavoritesSystemView,
716+ ActionToggleCurrentPageFavorite,
714 ActionToggleConfigSystemView,717 ActionToggleConfigSystemView,
715 ActionCopyURL,718 ActionCopyURL,
716 ActionConsumeOrExpelLeft,719 ActionConsumeOrExpelLeft,
M internal/ui/input/global_shortcuts_test.go +1 -0
@@ -231,6 +231,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
231231 ActionOpenDevTools,
232232 ActionToggleFloatingPane,
233233 ActionToggleFavoritesSystemView,
234+ ActionToggleCurrentPageFavorite,
234235 ActionToggleConfigSystemView,
235236 ActionCopyURL,
236237 ActionConsumeOrExpelLeft,
@@ -231,6 +231,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
231 ActionOpenDevTools,231 ActionOpenDevTools,
232 ActionToggleFloatingPane,232 ActionToggleFloatingPane,
233 ActionToggleFavoritesSystemView,233 ActionToggleFavoritesSystemView,
234+ ActionToggleCurrentPageFavorite,
234 ActionToggleConfigSystemView,235 ActionToggleConfigSystemView,
235 ActionCopyURL,236 ActionCopyURL,
236 ActionConsumeOrExpelLeft,237 ActionConsumeOrExpelLeft,
M internal/ui/input/shortcuts.go +11 -8
@@ -190,6 +190,7 @@ const (
190190 ActionToggleFloatingPane Action = "toggle_floating_pane"
191191 ActionToggleHistorySystemView Action = "toggle_history_systemview"
192192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193+ ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
193194 ActionToggleConfigSystemView Action = "toggle_config_systemview"
194195
195196 // Clipboard
@@ -564,14 +565,16 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
564565 }
565566
566567 var configActionToAction = map[string]Action{
567- "toggle_floating_pane": ActionToggleFloatingPane,
568- "toggle-floating-pane": ActionToggleFloatingPane,
569- "toggle_history_systemview": ActionToggleHistorySystemView,
570- "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
573- "toggle_config_systemview": ActionToggleConfigSystemView,
574- "toggle-config-systemview": ActionToggleConfigSystemView,
568+ "toggle_floating_pane": ActionToggleFloatingPane,
569+ "toggle-floating-pane": ActionToggleFloatingPane,
570+ "toggle_history_systemview": ActionToggleHistorySystemView,
571+ "toggle-history-systemview": ActionToggleHistorySystemView,
572+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
573+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
574+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
575+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
576+ "toggle_config_systemview": ActionToggleConfigSystemView,
577+ "toggle-config-systemview": ActionToggleConfigSystemView,
575578
576579 // Tab actions
577580 "new_tab": ActionNewTab,
@@ -190,6 +190,7 @@ const (
190 ActionToggleFloatingPane Action = "toggle_floating_pane"190 ActionToggleFloatingPane Action = "toggle_floating_pane"
191 ActionToggleHistorySystemView Action = "toggle_history_systemview"191 ActionToggleHistorySystemView Action = "toggle_history_systemview"
192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193+ ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
193 ActionToggleConfigSystemView Action = "toggle_config_systemview"194 ActionToggleConfigSystemView Action = "toggle_config_systemview"
194 195
195 // Clipboard196 // Clipboard
@@ -564,14 +565,16 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
564 }565 }
565 566
566 var configActionToAction = map[string]Action{567 var configActionToAction = map[string]Action{
567- "toggle_floating_pane": ActionToggleFloatingPane,568+ "toggle_floating_pane": ActionToggleFloatingPane,
568- "toggle-floating-pane": ActionToggleFloatingPane,569+ "toggle-floating-pane": ActionToggleFloatingPane,
569- "toggle_history_systemview": ActionToggleHistorySystemView,570+ "toggle_history_systemview": ActionToggleHistorySystemView,
570- "toggle-history-systemview": ActionToggleHistorySystemView,571+ "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,572+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,573+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
573- "toggle_config_systemview": ActionToggleConfigSystemView,574+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
574- "toggle-config-systemview": ActionToggleConfigSystemView,575+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
576+ "toggle_config_systemview": ActionToggleConfigSystemView,
577+ "toggle-config-systemview": ActionToggleConfigSystemView,
575 578
576 // Tab actions579 // Tab actions
577 "new_tab": ActionNewTab,580 "new_tab": ActionNewTab,
M internal/ui/input/shortcuts_test.go +2 -0
@@ -402,6 +402,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402402 }{
403403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
405+ {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
405406 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
406407 }
407408
@@ -421,6 +422,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421422 }{
422423 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
423424 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
425+ {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
424426 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
425427 }
426428
@@ -402,6 +402,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402 }{402 }{
403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
405+ {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
405 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},406 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
406 }407 }
407 408
@@ -421,6 +422,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421 }{422 }{
422 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},423 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
423 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},424 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
425+ {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
424 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},426 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
425 }427 }
426 428

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