feat(favorites): add keyboard-driven native sidebar
Add native keyboard-first favorites sidebar, Ctrl+B/Ctrl+D wiring, docs, and review fixes.
23 files · +3080 -62
M docs/reference/keybindings.md +8 -2
| @@ -84,7 +84,8 @@ These work outside modal modes: | |||
| 84 | 84 | |--------|------| | |
| 85 | 85 | | Toggle floating pane | `Alt+F` | | |
| 86 | 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 | 89 | | Toggle Config system view in right split | unbound by default | | |
| 89 | 90 | | Close pane (or release floating pane) | `Ctrl+W` | | |
| 90 | 91 | | Next tab | `Ctrl+Tab` | | |
| @@ -97,6 +98,8 @@ These work outside modal modes: | |||
| 97 | 98 | - `Alt+F` is the only floating-pane shortcut enabled by default. | |
| 98 | 99 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | |
| 99 | 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 | 103 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | |
| 101 | 104 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | |
| 102 | 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 | 128 | keys = ["ctrl+h"] | |
| 126 | 129 | ||
| 127 | 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 | 136 | [workspace.shortcuts.actions.toggle-config-systemview] | |
| 131 | 137 | 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 ( | |||
| 17 | 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 | 21 | var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil) | |
| 22 | + | var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil) | |
| 22 | 23 | ||
| 23 | 24 | // ManageFavoritesUseCase handles favorite and tag operations. | |
| 24 | 25 | 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 { | |||
| 322 | 322 | }, | |
| 323 | 323 | Shortcuts: GlobalShortcutsConfig{ | |
| 324 | 324 | 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"}, | |
| 336 | 337 | }, | |
| 337 | 338 | }, | |
| 338 | 339 | 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) { | |||
| 41 | 41 | ||
| 42 | 42 | // System view shortcuts are first-class global actions. | |
| 43 | 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 | 46 | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{}) | |
| 46 | 47 | ||
| 47 | 48 | // 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) { | |||
| 2040 | 2040 | if bw == nil { | |
| 2041 | 2041 | return | |
| 2042 | 2042 | } | |
| 2043 | - | bw.hideHistorySidebar() | |
| 2043 | + | switch bw.activeSidebarKind { | |
| 2044 | + | case nativeSidebarFavorites: | |
| 2045 | + | bw.hideFavoritesSidebar() | |
| 2046 | + | default: | |
| 2047 | + | bw.hideHistorySidebar() | |
| 2048 | + | } | |
| 2044 | 2049 | if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil { | |
| 2045 | 2050 | if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil { | |
| 2046 | 2051 | wsView.FocusPane(ws.ActivePaneID) | |
| @@ -3357,6 +3362,8 @@ func (a *App) wireKeyboardActions() { | |||
| 3357 | 3362 | return a.EjectActivePaneToWindow(ctx, paneID) | |
| 3358 | 3363 | }) | |
| 3359 | 3364 | a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction) | |
| 3365 | + | a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction) | |
| 3366 | + | a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction) | |
| 3360 | 3367 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| 3361 | 3368 | return a.ToggleFloatingPane(ctx) | |
| 3362 | 3369 | }) | |
| @@ -2040,7 +2040,12 @@ func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) { | |||||
| 2040 | if bw == nil { | 2040 | if bw == nil { | ||
| 2041 | return | 2041 | 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 ( | |||
| 19 | 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 | 30 | type browserWindow struct { | |
| 23 | 31 | id string | |
| 24 | 32 | initialURL string | |
| @@ -39,8 +47,10 @@ type browserWindow struct { | |||
| 39 | 47 | permissionDialog port.PermissionDialogPresenter | |
| 40 | 48 | webrtcIndicator *component.WebRTCPermissionIndicator | |
| 41 | 49 | historySidebar *component.HistorySidebar | |
| 50 | + | favoritesSidebar *component.FavoritesSidebar | |
| 42 | 51 | historySidebarReloader historySidebarReloader | |
| 43 | 52 | sidebarVisible bool | |
| 53 | + | activeSidebarKind nativeSidebarKind | |
| 44 | 54 | } | |
| 45 | 55 | ||
| 46 | 56 | func (bw *browserWindow) detachInputForDestroy() { | |
| @@ -73,6 +83,9 @@ func (bw *browserWindow) clearShellState() { | |||
| 73 | 83 | if bw.historySidebar != nil { | |
| 74 | 84 | bw.historySidebar.Destroy() | |
| 75 | 85 | } | |
| 86 | + | if bw.favoritesSidebar != nil { | |
| 87 | + | bw.favoritesSidebar.Destroy() | |
| 88 | + | } | |
| 76 | 89 | if bw.touchpadNavIndicator != nil { | |
| 77 | 90 | bw.touchpadNavIndicator.Destroy() | |
| 78 | 91 | } | |
| @@ -91,7 +104,9 @@ func (bw *browserWindow) clearShellState() { | |||
| 91 | 104 | bw.permissionDialog = nil | |
| 92 | 105 | bw.webrtcIndicator = nil | |
| 93 | 106 | bw.historySidebar = nil | |
| 107 | + | bw.favoritesSidebar = nil | |
| 94 | 108 | bw.historySidebarReloader = nil | |
| 109 | + | bw.activeSidebarKind = nativeSidebarNone | |
| 95 | 110 | } | |
| 96 | 111 | ||
| 97 | 112 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| @@ -106,6 +121,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |||
| 106 | 121 | bw.initSessionManager(ctx, a) | |
| 107 | 122 | bw.initTabPicker(ctx, a) | |
| 108 | 123 | bw.initHistorySidebar(ctx, a) | |
| 124 | + | bw.initFavoritesSidebar(ctx, a) | |
| 109 | 125 | } | |
| 110 | 126 | ||
| 111 | 127 | 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 string | 31 | id string | ||
| 24 | initialURL string | 32 | initialURL string | ||
| @@ -39,8 +47,10 @@ type browserWindow struct { | |||||
| 39 | permissionDialog port.PermissionDialogPresenter | 47 | permissionDialog port.PermissionDialogPresenter | ||
| 40 | webrtcIndicator *component.WebRTCPermissionIndicator | 48 | webrtcIndicator *component.WebRTCPermissionIndicator | ||
| 41 | historySidebar *component.HistorySidebar | 49 | historySidebar *component.HistorySidebar | ||
| 50 | + | favoritesSidebar *component.FavoritesSidebar | |||
| 42 | historySidebarReloader historySidebarReloader | 51 | historySidebarReloader historySidebarReloader | ||
| 43 | sidebarVisible bool | 52 | 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 = nil | 104 | bw.permissionDialog = nil | ||
| 92 | bw.webrtcIndicator = nil | 105 | bw.webrtcIndicator = nil | ||
| 93 | bw.historySidebar = nil | 106 | bw.historySidebar = nil | ||
| 107 | + | bw.favoritesSidebar = nil | |||
| 94 | bw.historySidebarReloader = nil | 108 | 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) { | |||
| 34 | 34 | bw.historySidebar.Hide() | |
| 35 | 35 | bw.mainWindow.SetSidebarVisible(false) | |
| 36 | 36 | bw.sidebarVisible = false | |
| 37 | + | bw.activeSidebarKind = nativeSidebarNone | |
| 37 | 38 | ||
| 38 | 39 | // Apply sidebar width from config, falling back to the default 320px. | |
| 39 | 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 | 46 | // buildHistorySidebarConfig constructs the HistorySidebarConfig for the given | |
| 46 | 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 | 50 | func (a *App) buildHistorySidebarConfig(bw *browserWindow) component.HistorySidebarConfig { | |
| 48 | 51 | var historyUC port.HistorySidebarHistory | |
| 49 | 52 | if a.deps != nil { | |
| @@ -84,7 +87,7 @@ func (bw *browserWindow) toggleHistorySidebar() { | |||
| 84 | 87 | return | |
| 85 | 88 | } | |
| 86 | 89 | ||
| 87 | - | if bw.sidebarVisible { | |
| 90 | + | if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory { | |
| 88 | 91 | bw.hideHistorySidebar() | |
| 89 | 92 | } else { | |
| 90 | 93 | bw.showHistorySidebar() | |
| @@ -97,9 +100,11 @@ func (bw *browserWindow) showHistorySidebar() { | |||
| 97 | 100 | if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil { | |
| 98 | 101 | return | |
| 99 | 102 | } | |
| 103 | + | bw.mountNativeSidebar(nativeSidebarHistory) | |
| 100 | 104 | bw.historySidebar.Show() | |
| 101 | 105 | bw.mainWindow.SetSidebarVisible(true) | |
| 102 | 106 | bw.sidebarVisible = true | |
| 107 | + | bw.activeSidebarKind = nativeSidebarHistory | |
| 103 | 108 | } | |
| 104 | 109 | ||
| 105 | 110 | // hideHistorySidebar hides the sidebar. Callers should also restore focus | |
| @@ -111,6 +116,31 @@ func (bw *browserWindow) hideHistorySidebar() { | |||
| 111 | 116 | bw.historySidebar.Hide() | |
| 112 | 117 | bw.mainWindow.SetSidebarVisible(false) | |
| 113 | 118 | 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 | 146 | // historySidebarWidthConfig extracts the config-backed sidebar width and | |
| @@ -143,7 +173,7 @@ func (a *App) toggleHistorySidebarAction(ctx context.Context) error { | |||
| 143 | 173 | if bw.historySidebar == nil { | |
| 144 | 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 | 177 | a.hideAndRestoreFocusForBrowserWindow(bw) | |
| 148 | 178 | return nil | |
| 149 | 179 | } | |
| @@ -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 = false | 36 | 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 given | 46 | // 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.HistorySidebarHistory | 51 | var historyUC port.HistorySidebarHistory | ||
| 49 | if a.deps != nil { | 52 | if a.deps != nil { | ||
| @@ -84,7 +87,7 @@ func (bw *browserWindow) toggleHistorySidebar() { | |||||
| 84 | return | 87 | 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 | return | 101 | 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 = true | 106 | bw.sidebarVisible = true | ||
| 107 | + | bw.activeSidebarKind = nativeSidebarHistory | |||
| 103 | } | 108 | } | ||
| 104 | 109 | ||||
| 105 | // hideHistorySidebar hides the sidebar. Callers should also restore focus | 110 | // 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 = false | 118 | 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 and | 146 | // 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 nil | 178 | 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 { | |||
| 36 | 36 | ||
| 37 | 37 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 38 | 38 | 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 | |
| 58 | 60 | } | |
| 59 | 61 | ||
| 60 | 62 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| @@ -128,6 +130,14 @@ func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Conte | |||
| 128 | 130 | 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 | 141 | func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) { | |
| 132 | 142 | d.onToggleFloating = fn | |
| 133 | 143 | } | |
| @@ -262,7 +272,16 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 262 | 272 | return d.onToggleHistorySidebar(ctx) | |
| 263 | 273 | }, | |
| 264 | 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 | 286 | input.ActionToggleConfigSystemView: func(ctx context.Context) error { | |
| 268 | 287 | 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 KeyboardActions | 39 | + | actions KeyboardActions |
| 40 | - | wsCoord *coordinator.WorkspaceCoordinator | 40 | + | wsCoord *coordinator.WorkspaceCoordinator |
| 41 | - | navCoord *coordinator.NavigationCoordinator | 41 | + | navCoord *coordinator.NavigationCoordinator |
| 42 | - | zoomUC *usecase.ManageZoomUseCase | 42 | + | zoomUC *usecase.ManageZoomUseCase |
| 43 | - | copyURLUC *usecase.CopyURLUseCase | 43 | + | copyURLUC *usecase.CopyURLUseCase |
| 44 | - | actionHandlers map[input.Action]func(ctx context.Context) error | 44 | + | actionHandlers map[input.Action]func(ctx context.Context) error |
| 45 | - | onQuit func() | 45 | + | onQuit func() |
| 46 | - | onFindOpen func(ctx context.Context) error | 46 | + | onFindOpen func(ctx context.Context) error |
| 47 | - | onFindNext func(ctx context.Context) error | 47 | + | onFindNext func(ctx context.Context) error |
| 48 | - | onFindPrev func(ctx context.Context) error | 48 | + | onFindPrev func(ctx context.Context) error |
| 49 | - | onFindClose func(ctx context.Context) error | 49 | + | onFindClose func(ctx context.Context) error |
| 50 | - | activePaneID func(ctx context.Context) entity.PaneID | 50 | + | activePaneID func(ctx context.Context) entity.PaneID |
| 51 | - | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error | 51 | + | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error |
| 52 | - | onMovePaneToTab 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 | 53 | + | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error |
| 54 | - | onEjectPaneToWindow 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 | 55 | + | onToggleHistorySidebar func(ctx context.Context) error |
| 56 | - | onToggleFloating func(ctx context.Context) error | 56 | + | onToggleFavoritesSidebar func(ctx context.Context) error |
| 57 | - | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) 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 | |||
| 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 = fn | 130 | 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 = fn | 142 | 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 | |||
| 115 | 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 | 158 | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | |
| 119 | 159 | ctx := context.Background() | |
| 120 | 160 | 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 | |||
| 484 | 484 | ||
| 485 | 485 | func globalShortcutActionMap() map[string]Action { | |
| 486 | 486 | 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, | |
| 503 | 505 | } | |
| 504 | 506 | } | |
| 505 | 507 | ||
| @@ -711,6 +713,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |||
| 711 | 713 | ActionToggleFloatingPane, | |
| 712 | 714 | ActionToggleHistorySystemView, | |
| 713 | 715 | ActionToggleFavoritesSystemView, | |
| 716 | + | ActionToggleCurrentPageFavorite, | |
| 714 | 717 | ActionToggleConfigSystemView, | |
| 715 | 718 | ActionCopyURL, | |
| 716 | 719 | 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 | |||
| 231 | 231 | ActionOpenDevTools, | |
| 232 | 232 | ActionToggleFloatingPane, | |
| 233 | 233 | ActionToggleFavoritesSystemView, | |
| 234 | + | ActionToggleCurrentPageFavorite, | |
| 234 | 235 | ActionToggleConfigSystemView, | |
| 235 | 236 | ActionCopyURL, | |
| 236 | 237 | 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 ( | |||
| 190 | 190 | ActionToggleFloatingPane Action = "toggle_floating_pane" | |
| 191 | 191 | ActionToggleHistorySystemView Action = "toggle_history_systemview" | |
| 192 | 192 | ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview" | |
| 193 | + | ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite" | |
| 193 | 194 | ActionToggleConfigSystemView Action = "toggle_config_systemview" | |
| 194 | 195 | ||
| 195 | 196 | // Clipboard | |
| @@ -564,14 +565,16 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||
| 564 | 565 | } | |
| 565 | 566 | ||
| 566 | 567 | 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, | |
| 575 | 578 | ||
| 576 | 579 | // Tab actions | |
| 577 | 580 | "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 | // Clipboard | 196 | // 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 actions | 579 | // 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) { | |||
| 402 | 402 | }{ | |
| 403 | 403 | {name: "toggle_history_systemview", want: ActionToggleHistorySystemView}, | |
| 404 | 404 | {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView}, | |
| 405 | + | {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite}, | |
| 405 | 406 | {name: "toggle_config_systemview", want: ActionToggleConfigSystemView}, | |
| 406 | 407 | } | |
| 407 | 408 | ||
| @@ -421,6 +422,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) { | |||
| 421 | 422 | }{ | |
| 422 | 423 | {name: "toggle-history-systemview", want: ActionToggleHistorySystemView}, | |
| 423 | 424 | {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView}, | |
| 425 | + | {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite}, | |
| 424 | 426 | {name: "toggle-config-systemview", want: ActionToggleConfigSystemView}, | |
| 425 | 427 | } | |
| 426 | 428 | ||
| @@ -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 | ||||