detached 681c467

feat(systemviews): add favorites route

commit
681c467cf1376f4912dbd8bbc7bb1b3aaa1a2e2a
author
brice · · 2026-04-11 18:57 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
0224ed2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +414 -0
M internal/infrastructure/systemviewsbridge/client.go +122 -0
@@ -102,6 +102,128 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
102102 return err
103103 }
104104
105+func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
106+ return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
107+ RequestID string `json:"requestId"`
108+ }{RequestID: nextRequestID()})
109+}
110+
111+func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
112+ return request[[]*entity.Folder](c, ctx, "folder_list", struct {
113+ RequestID string `json:"requestId"`
114+ }{RequestID: nextRequestID()})
115+}
116+
117+func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
118+ return request[[]*entity.Tag](c, ctx, "tag_list", struct {
119+ RequestID string `json:"requestId"`
120+ }{RequestID: nextRequestID()})
121+}
122+
123+func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
124+ _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
125+ RequestID string `json:"requestId"`
126+ FavoriteID int64 `json:"favorite_id"`
127+ ShortcutKey *int `json:"shortcut_key"`
128+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
129+ return err
130+}
131+
132+func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
133+ _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
134+ RequestID string `json:"requestId"`
135+ FavoriteID int64 `json:"favorite_id"`
136+ FolderID *int64 `json:"folder_id"`
137+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
138+ return err
139+}
140+
141+func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) {
142+ return request[*entity.Folder](c, ctx, "folder_create", struct {
143+ RequestID string `json:"requestId"`
144+ Name string `json:"name"`
145+ Icon *string `json:"icon"`
146+ }{RequestID: nextRequestID(), Name: name, Icon: nil})
147+}
148+
149+func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
150+ var iconPtr *string
151+ if icon != "" {
152+ iconPtr = &icon
153+ }
154+ _, err := request[struct{}](c, ctx, "folder_update", struct {
155+ RequestID string `json:"requestId"`
156+ ID int64 `json:"id"`
157+ Name string `json:"name"`
158+ Icon *string `json:"icon"`
159+ }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
160+ return err
161+}
162+
163+func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
164+ _, err := request[struct{}](c, ctx, "folder_delete", struct {
165+ RequestID string `json:"requestId"`
166+ ID int64 `json:"id"`
167+ }{RequestID: nextRequestID(), ID: id})
168+ return err
169+}
170+
171+func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
172+ var colorPtr *string
173+ if color != "" {
174+ colorPtr = &color
175+ }
176+ return request[*entity.Tag](c, ctx, "tag_create", struct {
177+ RequestID string `json:"requestId"`
178+ Name string `json:"name"`
179+ Color *string `json:"color"`
180+ }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
181+}
182+
183+func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
184+ var namePtr *string
185+ if name != "" {
186+ namePtr = &name
187+ }
188+ var colorPtr *string
189+ if color != "" {
190+ colorPtr = &color
191+ }
192+ _, err := request[struct{}](c, ctx, "tag_update", struct {
193+ RequestID string `json:"requestId"`
194+ ID int64 `json:"id"`
195+ Name *string `json:"name"`
196+ Color *string `json:"color"`
197+ }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
198+ return err
199+}
200+
201+func (c *Client) DeleteTag(ctx context.Context, id int64) error {
202+ _, err := request[struct{}](c, ctx, "tag_delete", struct {
203+ RequestID string `json:"requestId"`
204+ ID int64 `json:"id"`
205+ }{RequestID: nextRequestID(), ID: id})
206+ return err
207+}
208+
209+func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
210+ _, err := request[struct{}](c, ctx, "tag_assign", struct {
211+ RequestID string `json:"requestId"`
212+ FavoriteID int64 `json:"favorite_id"`
213+ TagID int64 `json:"tag_id"`
214+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
215+ return err
216+}
217+
218+func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
219+ _, err := request[struct{}](c, ctx, "tag_remove", struct {
220+ RequestID string `json:"requestId"`
221+ FavoriteID int64 `json:"favorite_id"`
222+ TagID int64 `json:"tag_id"`
223+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
224+ return err
225+}
226+
105227 func (c *Client) transport() Transport {
106228 if c == nil {
107229 return nil
@@ -102,6 +102,128 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
102 return err102 return err
103 }103 }
104 104
105+func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
106+ return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
107+ RequestID string `json:"requestId"`
108+ }{RequestID: nextRequestID()})
109+}
110+
111+func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
112+ return request[[]*entity.Folder](c, ctx, "folder_list", struct {
113+ RequestID string `json:"requestId"`
114+ }{RequestID: nextRequestID()})
115+}
116+
117+func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
118+ return request[[]*entity.Tag](c, ctx, "tag_list", struct {
119+ RequestID string `json:"requestId"`
120+ }{RequestID: nextRequestID()})
121+}
122+
123+func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
124+ _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
125+ RequestID string `json:"requestId"`
126+ FavoriteID int64 `json:"favorite_id"`
127+ ShortcutKey *int `json:"shortcut_key"`
128+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
129+ return err
130+}
131+
132+func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
133+ _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
134+ RequestID string `json:"requestId"`
135+ FavoriteID int64 `json:"favorite_id"`
136+ FolderID *int64 `json:"folder_id"`
137+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
138+ return err
139+}
140+
141+func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) {
142+ return request[*entity.Folder](c, ctx, "folder_create", struct {
143+ RequestID string `json:"requestId"`
144+ Name string `json:"name"`
145+ Icon *string `json:"icon"`
146+ }{RequestID: nextRequestID(), Name: name, Icon: nil})
147+}
148+
149+func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
150+ var iconPtr *string
151+ if icon != "" {
152+ iconPtr = &icon
153+ }
154+ _, err := request[struct{}](c, ctx, "folder_update", struct {
155+ RequestID string `json:"requestId"`
156+ ID int64 `json:"id"`
157+ Name string `json:"name"`
158+ Icon *string `json:"icon"`
159+ }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
160+ return err
161+}
162+
163+func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
164+ _, err := request[struct{}](c, ctx, "folder_delete", struct {
165+ RequestID string `json:"requestId"`
166+ ID int64 `json:"id"`
167+ }{RequestID: nextRequestID(), ID: id})
168+ return err
169+}
170+
171+func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
172+ var colorPtr *string
173+ if color != "" {
174+ colorPtr = &color
175+ }
176+ return request[*entity.Tag](c, ctx, "tag_create", struct {
177+ RequestID string `json:"requestId"`
178+ Name string `json:"name"`
179+ Color *string `json:"color"`
180+ }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
181+}
182+
183+func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
184+ var namePtr *string
185+ if name != "" {
186+ namePtr = &name
187+ }
188+ var colorPtr *string
189+ if color != "" {
190+ colorPtr = &color
191+ }
192+ _, err := request[struct{}](c, ctx, "tag_update", struct {
193+ RequestID string `json:"requestId"`
194+ ID int64 `json:"id"`
195+ Name *string `json:"name"`
196+ Color *string `json:"color"`
197+ }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
198+ return err
199+}
200+
201+func (c *Client) DeleteTag(ctx context.Context, id int64) error {
202+ _, err := request[struct{}](c, ctx, "tag_delete", struct {
203+ RequestID string `json:"requestId"`
204+ ID int64 `json:"id"`
205+ }{RequestID: nextRequestID(), ID: id})
206+ return err
207+}
208+
209+func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
210+ _, err := request[struct{}](c, ctx, "tag_assign", struct {
211+ RequestID string `json:"requestId"`
212+ FavoriteID int64 `json:"favorite_id"`
213+ TagID int64 `json:"tag_id"`
214+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
215+ return err
216+}
217+
218+func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
219+ _, err := request[struct{}](c, ctx, "tag_remove", struct {
220+ RequestID string `json:"requestId"`
221+ FavoriteID int64 `json:"favorite_id"`
222+ TagID int64 `json:"tag_id"`
223+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
224+ return err
225+}
226+
105 func (c *Client) transport() Transport {227 func (c *Client) transport() Transport {
106 if c == nil {228 if c == nil {
107 return nil229 return nil
M internal/infrastructure/systemviewsbridge/client_test.go +78 -0
@@ -143,6 +143,84 @@ func TestClientDeleteRangeSendsRange(t *testing.T) {
143143 }
144144 }
145145
146+func TestClientListDecodesFavorites(t *testing.T) {
147+ t.Parallel()
148+
149+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
150+ client := NewClient(native, nil)
151+
152+ favorites, err := client.List(context.Background())
153+ if err != nil {
154+ t.Fatalf("List() error = %v", err)
155+ }
156+ if len(favorites) != 1 {
157+ t.Fatalf("List() len = %d, want 1", len(favorites))
158+ }
159+ if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
160+ t.Fatalf("List() favorite = %+v", favorites[0])
161+ }
162+
163+ var msg port.WebUIMessage
164+ if err := json.Unmarshal(native.last, &msg); err != nil {
165+ t.Fatalf("unmarshal sent envelope: %v", err)
166+ }
167+ if msg.Type != "favorite_list" {
168+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
169+ }
170+}
171+
172+func TestClientListFoldersDecodesFolders(t *testing.T) {
173+ t.Parallel()
174+
175+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)}
176+ client := NewClient(native, nil)
177+
178+ folders, err := client.ListFolders(context.Background())
179+ if err != nil {
180+ t.Fatalf("ListFolders() error = %v", err)
181+ }
182+ if len(folders) != 1 {
183+ t.Fatalf("ListFolders() len = %d, want 1", len(folders))
184+ }
185+ if folders[0].Name != "Read Later" {
186+ t.Fatalf("ListFolders() folder = %+v", folders[0])
187+ }
188+
189+ var msg port.WebUIMessage
190+ if err := json.Unmarshal(native.last, &msg); err != nil {
191+ t.Fatalf("unmarshal sent envelope: %v", err)
192+ }
193+ if msg.Type != "folder_list" {
194+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
195+ }
196+}
197+
198+func TestClientListTagsDecodesTags(t *testing.T) {
199+ t.Parallel()
200+
201+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)}
202+ client := NewClient(native, nil)
203+
204+ tags, err := client.ListTags(context.Background())
205+ if err != nil {
206+ t.Fatalf("ListTags() error = %v", err)
207+ }
208+ if len(tags) != 1 {
209+ t.Fatalf("ListTags() len = %d, want 1", len(tags))
210+ }
211+ if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
212+ t.Fatalf("ListTags() tag = %+v", tags[0])
213+ }
214+
215+ var msg port.WebUIMessage
216+ if err := json.Unmarshal(native.last, &msg); err != nil {
217+ t.Fatalf("unmarshal sent envelope: %v", err)
218+ }
219+ if msg.Type != "tag_list" {
220+ t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
221+ }
222+}
223+
146224 type fakeTransport struct {
147225 available bool
148226 called bool
@@ -143,6 +143,84 @@ func TestClientDeleteRangeSendsRange(t *testing.T) {
143 }143 }
144 }144 }
145 145
146+func TestClientListDecodesFavorites(t *testing.T) {
147+ t.Parallel()
148+
149+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
150+ client := NewClient(native, nil)
151+
152+ favorites, err := client.List(context.Background())
153+ if err != nil {
154+ t.Fatalf("List() error = %v", err)
155+ }
156+ if len(favorites) != 1 {
157+ t.Fatalf("List() len = %d, want 1", len(favorites))
158+ }
159+ if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
160+ t.Fatalf("List() favorite = %+v", favorites[0])
161+ }
162+
163+ var msg port.WebUIMessage
164+ if err := json.Unmarshal(native.last, &msg); err != nil {
165+ t.Fatalf("unmarshal sent envelope: %v", err)
166+ }
167+ if msg.Type != "favorite_list" {
168+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
169+ }
170+}
171+
172+func TestClientListFoldersDecodesFolders(t *testing.T) {
173+ t.Parallel()
174+
175+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)}
176+ client := NewClient(native, nil)
177+
178+ folders, err := client.ListFolders(context.Background())
179+ if err != nil {
180+ t.Fatalf("ListFolders() error = %v", err)
181+ }
182+ if len(folders) != 1 {
183+ t.Fatalf("ListFolders() len = %d, want 1", len(folders))
184+ }
185+ if folders[0].Name != "Read Later" {
186+ t.Fatalf("ListFolders() folder = %+v", folders[0])
187+ }
188+
189+ var msg port.WebUIMessage
190+ if err := json.Unmarshal(native.last, &msg); err != nil {
191+ t.Fatalf("unmarshal sent envelope: %v", err)
192+ }
193+ if msg.Type != "folder_list" {
194+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
195+ }
196+}
197+
198+func TestClientListTagsDecodesTags(t *testing.T) {
199+ t.Parallel()
200+
201+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)}
202+ client := NewClient(native, nil)
203+
204+ tags, err := client.ListTags(context.Background())
205+ if err != nil {
206+ t.Fatalf("ListTags() error = %v", err)
207+ }
208+ if len(tags) != 1 {
209+ t.Fatalf("ListTags() len = %d, want 1", len(tags))
210+ }
211+ if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
212+ t.Fatalf("ListTags() tag = %+v", tags[0])
213+ }
214+
215+ var msg port.WebUIMessage
216+ if err := json.Unmarshal(native.last, &msg); err != nil {
217+ t.Fatalf("unmarshal sent envelope: %v", err)
218+ }
219+ if msg.Type != "tag_list" {
220+ t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
221+ }
222+}
223+
146 type fakeTransport struct {224 type fakeTransport struct {
147 available bool225 available bool
148 called bool226 called bool
M internal/ui/systemviews/app.go +42 -0
@@ -10,6 +10,7 @@ import (
1010
1111 type Dependencies struct {
1212 DOM DOM
13+ Favorites port.SystemviewFavoritesService
1314 History port.SystemviewHistoryService
1415 LocationURI string
1516 }
@@ -18,6 +19,9 @@ type App struct {
1819 deps Dependencies
1920 currentRoute Route
2021 historyEntries []*entity.HistoryEntry
22+ favorites []*entity.Favorite
23+ folders []*entity.Folder
24+ tags []*entity.Tag
2125 renderedHTML string
2226 }
2327
@@ -53,6 +57,9 @@ func (a *App) LoadInitial(ctx context.Context) error {
5357 case RouteHistory:
5458 if a.deps.History == nil {
5559 a.historyEntries = nil
60+ a.favorites = nil
61+ a.folders = nil
62+ a.tags = nil
5663 a.renderedHTML = placeholderHTML(a.currentRoute)
5764 return nil
5865 }
@@ -61,11 +68,46 @@ func (a *App) LoadInitial(ctx context.Context) error {
6168 if err != nil {
6269 return err
6370 }
71+ a.favorites = nil
72+ a.folders = nil
73+ a.tags = nil
6474 a.historyEntries = entries
6575 a.renderedHTML = historyHTML(entries)
6676 return nil
77+ case RouteFavorites:
78+ if a.deps.Favorites == nil {
79+ a.historyEntries = nil
80+ a.favorites = nil
81+ a.folders = nil
82+ a.tags = nil
83+ a.renderedHTML = placeholderHTML(a.currentRoute)
84+ return nil
85+ }
86+
87+ favorites, err := a.deps.Favorites.List(ctx)
88+ if err != nil {
89+ return err
90+ }
91+ folders, err := a.deps.Favorites.ListFolders(ctx)
92+ if err != nil {
93+ return err
94+ }
95+ tags, err := a.deps.Favorites.ListTags(ctx)
96+ if err != nil {
97+ return err
98+ }
99+
100+ a.historyEntries = nil
101+ a.favorites = favorites
102+ a.folders = folders
103+ a.tags = tags
104+ a.renderedHTML = favoritesHTML(favorites, folders, tags)
105+ return nil
67106 default:
68107 a.historyEntries = nil
108+ a.favorites = nil
109+ a.folders = nil
110+ a.tags = nil
69111 a.renderedHTML = placeholderHTML(a.currentRoute)
70112 return nil
71113 }
@@ -10,6 +10,7 @@ import (
10 10
11 type Dependencies struct {11 type Dependencies struct {
12 DOM DOM12 DOM DOM
13+ Favorites port.SystemviewFavoritesService
13 History port.SystemviewHistoryService14 History port.SystemviewHistoryService
14 LocationURI string15 LocationURI string
15 }16 }
@@ -18,6 +19,9 @@ type App struct {
18 deps Dependencies19 deps Dependencies
19 currentRoute Route20 currentRoute Route
20 historyEntries []*entity.HistoryEntry21 historyEntries []*entity.HistoryEntry
22+ favorites []*entity.Favorite
23+ folders []*entity.Folder
24+ tags []*entity.Tag
21 renderedHTML string25 renderedHTML string
22 }26 }
23 27
@@ -53,6 +57,9 @@ func (a *App) LoadInitial(ctx context.Context) error {
53 case RouteHistory:57 case RouteHistory:
54 if a.deps.History == nil {58 if a.deps.History == nil {
55 a.historyEntries = nil59 a.historyEntries = nil
60+ a.favorites = nil
61+ a.folders = nil
62+ a.tags = nil
56 a.renderedHTML = placeholderHTML(a.currentRoute)63 a.renderedHTML = placeholderHTML(a.currentRoute)
57 return nil64 return nil
58 }65 }
@@ -61,11 +68,46 @@ func (a *App) LoadInitial(ctx context.Context) error {
61 if err != nil {68 if err != nil {
62 return err69 return err
63 }70 }
71+ a.favorites = nil
72+ a.folders = nil
73+ a.tags = nil
64 a.historyEntries = entries74 a.historyEntries = entries
65 a.renderedHTML = historyHTML(entries)75 a.renderedHTML = historyHTML(entries)
66 return nil76 return nil
77+ case RouteFavorites:
78+ if a.deps.Favorites == nil {
79+ a.historyEntries = nil
80+ a.favorites = nil
81+ a.folders = nil
82+ a.tags = nil
83+ a.renderedHTML = placeholderHTML(a.currentRoute)
84+ return nil
85+ }
86+
87+ favorites, err := a.deps.Favorites.List(ctx)
88+ if err != nil {
89+ return err
90+ }
91+ folders, err := a.deps.Favorites.ListFolders(ctx)
92+ if err != nil {
93+ return err
94+ }
95+ tags, err := a.deps.Favorites.ListTags(ctx)
96+ if err != nil {
97+ return err
98+ }
99+
100+ a.historyEntries = nil
101+ a.favorites = favorites
102+ a.folders = folders
103+ a.tags = tags
104+ a.renderedHTML = favoritesHTML(favorites, folders, tags)
105+ return nil
67 default:106 default:
68 a.historyEntries = nil107 a.historyEntries = nil
108+ a.favorites = nil
109+ a.folders = nil
110+ a.tags = nil
69 a.renderedHTML = placeholderHTML(a.currentRoute)111 a.renderedHTML = placeholderHTML(a.currentRoute)
70 return nil112 return nil
71 }113 }
M internal/ui/systemviews/app_test.go +76 -0
@@ -74,6 +74,34 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
7474 assert.Equal(t, 0, history.offset)
7575 }
7676
77+func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
78+ t.Parallel()
79+
80+ favorites := &fakeFavoritesService{
81+ favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
82+ folders: []*entity.Folder{{Name: "Read Later"}},
83+ tags: []*entity.Tag{{Name: "Go"}},
84+ }
85+
86+ app := NewApp(Dependencies{
87+ Favorites: favorites,
88+ LocationURI: "dumb://favorites",
89+ })
90+
91+ require.NoError(t, app.LoadInitial(context.Background()))
92+ assert.Equal(t, RouteFavorites, app.CurrentRoute())
93+ assert.Len(t, app.favorites, 1)
94+ assert.Len(t, app.folders, 1)
95+ assert.Len(t, app.tags, 1)
96+ assert.Contains(t, app.renderedHTML, "Favorites")
97+ assert.Contains(t, app.renderedHTML, "Example")
98+ assert.Contains(t, app.renderedHTML, "Read Later")
99+ assert.Contains(t, app.renderedHTML, "Go")
100+ assert.True(t, favorites.calledList)
101+ assert.True(t, favorites.calledFolders)
102+ assert.True(t, favorites.calledTags)
103+}
104+
77105 type fakeDOM struct {
78106 mounted bool
79107 html string
@@ -116,3 +144,51 @@ func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.Domain
116144 }
117145
118146 func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }
147+
148+type fakeFavoritesService struct {
149+ calledList bool
150+ calledFolders bool
151+ calledTags bool
152+ favorites []*entity.Favorite
153+ folders []*entity.Folder
154+ tags []*entity.Tag
155+}
156+
157+func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
158+ s.calledList = true
159+ return s.favorites, nil
160+}
161+
162+func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
163+ s.calledFolders = true
164+ return s.folders, nil
165+}
166+
167+func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
168+ s.calledTags = true
169+ return s.tags, nil
170+}
171+
172+func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
173+
174+func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
175+
176+func (s *fakeFavoritesService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {
177+ return nil, nil
178+}
179+
180+func (s *fakeFavoritesService) UpdateFolder(context.Context, int64, string, string) error { return nil }
181+
182+func (s *fakeFavoritesService) DeleteFolder(context.Context, int64) error { return nil }
183+
184+func (s *fakeFavoritesService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
185+ return nil, nil
186+}
187+
188+func (s *fakeFavoritesService) UpdateTag(context.Context, int64, string, string) error { return nil }
189+
190+func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return nil }
191+
192+func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil }
193+
194+func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil }
@@ -74,6 +74,34 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) {
74 assert.Equal(t, 0, history.offset)74 assert.Equal(t, 0, history.offset)
75 }75 }
76 76
77+func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
78+ t.Parallel()
79+
80+ favorites := &fakeFavoritesService{
81+ favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
82+ folders: []*entity.Folder{{Name: "Read Later"}},
83+ tags: []*entity.Tag{{Name: "Go"}},
84+ }
85+
86+ app := NewApp(Dependencies{
87+ Favorites: favorites,
88+ LocationURI: "dumb://favorites",
89+ })
90+
91+ require.NoError(t, app.LoadInitial(context.Background()))
92+ assert.Equal(t, RouteFavorites, app.CurrentRoute())
93+ assert.Len(t, app.favorites, 1)
94+ assert.Len(t, app.folders, 1)
95+ assert.Len(t, app.tags, 1)
96+ assert.Contains(t, app.renderedHTML, "Favorites")
97+ assert.Contains(t, app.renderedHTML, "Example")
98+ assert.Contains(t, app.renderedHTML, "Read Later")
99+ assert.Contains(t, app.renderedHTML, "Go")
100+ assert.True(t, favorites.calledList)
101+ assert.True(t, favorites.calledFolders)
102+ assert.True(t, favorites.calledTags)
103+}
104+
77 type fakeDOM struct {105 type fakeDOM struct {
78 mounted bool106 mounted bool
79 html string107 html string
@@ -116,3 +144,51 @@ func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.Domain
116 }144 }
117 145
118 func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }146 func (s *fakeHistoryService) DeleteDomain(context.Context, string) error { return nil }
147+
148+type fakeFavoritesService struct {
149+ calledList bool
150+ calledFolders bool
151+ calledTags bool
152+ favorites []*entity.Favorite
153+ folders []*entity.Folder
154+ tags []*entity.Tag
155+}
156+
157+func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
158+ s.calledList = true
159+ return s.favorites, nil
160+}
161+
162+func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
163+ s.calledFolders = true
164+ return s.folders, nil
165+}
166+
167+func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
168+ s.calledTags = true
169+ return s.tags, nil
170+}
171+
172+func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
173+
174+func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
175+
176+func (s *fakeFavoritesService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {
177+ return nil, nil
178+}
179+
180+func (s *fakeFavoritesService) UpdateFolder(context.Context, int64, string, string) error { return nil }
181+
182+func (s *fakeFavoritesService) DeleteFolder(context.Context, int64) error { return nil }
183+
184+func (s *fakeFavoritesService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
185+ return nil, nil
186+}
187+
188+func (s *fakeFavoritesService) UpdateTag(context.Context, int64, string, string) error { return nil }
189+
190+func (s *fakeFavoritesService) DeleteTag(context.Context, int64) error { return nil }
191+
192+func (s *fakeFavoritesService) AssignTag(context.Context, int64, int64) error { return nil }
193+
194+func (s *fakeFavoritesService) RemoveTag(context.Context, int64, int64) error { return nil }
A internal/ui/systemviews/favorites_view.go +96 -0
@@ -0,0 +1,96 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
12+ return fmt.Sprintf(`<!doctype html>
13+<html lang="en">
14+<head>
15+ <meta charset="utf-8">
16+ <meta name="viewport" content="width=device-width, initial-scale=1">
17+ <title>systemviews favorites</title>
18+</head>
19+<body>
20+ <main id="app" data-route="favorites">
21+ <h1>Favorites</h1>
22+ <p>%d favorites · %d folders · %d tags</p>
23+ <section>
24+ <h2>Favorites</h2>
25+ <ul>%s</ul>
26+ </section>
27+ <section>
28+ <h2>Folders</h2>
29+ <ul>%s</ul>
30+ </section>
31+ <section>
32+ <h2>Tags</h2>
33+ <ul>%s</ul>
34+ </section>
35+ </main>
36+</body>
37+</html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags))
38+}
39+
40+func favoriteItemsHTML(favorites []*entity.Favorite) string {
41+ if len(favorites) == 0 {
42+ return "<li>No favorites</li>"
43+ }
44+
45+ var items strings.Builder
46+ for _, favorite := range favorites {
47+ if favorite == nil {
48+ continue
49+ }
50+ label := favorite.Title
51+ if label == "" {
52+ label = favorite.URL
53+ }
54+ items.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label)))
55+ }
56+ if items.Len() == 0 {
57+ return "<li>No favorites</li>"
58+ }
59+ return items.String()
60+}
61+
62+func folderItemsHTML(folders []*entity.Folder) string {
63+ if len(folders) == 0 {
64+ return "<li>No folders</li>"
65+ }
66+
67+ var items strings.Builder
68+ for _, folder := range folders {
69+ if folder == nil {
70+ continue
71+ }
72+ items.WriteString(fmt.Sprintf("<li>%s</li>", html.EscapeString(folder.Name)))
73+ }
74+ if items.Len() == 0 {
75+ return "<li>No folders</li>"
76+ }
77+ return items.String()
78+}
79+
80+func tagItemsHTML(tags []*entity.Tag) string {
81+ if len(tags) == 0 {
82+ return "<li>No tags</li>"
83+ }
84+
85+ var items strings.Builder
86+ for _, tag := range tags {
87+ if tag == nil {
88+ continue
89+ }
90+ items.WriteString(fmt.Sprintf("<li>%s</li>", html.EscapeString(tag.Name)))
91+ }
92+ if items.Len() == 0 {
93+ return "<li>No tags</li>"
94+ }
95+ return items.String()
96+}
@@ -0,0 +1,96 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string {
12+ return fmt.Sprintf(`<!doctype html>
13+<html lang="en">
14+<head>
15+ <meta charset="utf-8">
16+ <meta name="viewport" content="width=device-width, initial-scale=1">
17+ <title>systemviews favorites</title>
18+</head>
19+<body>
20+ <main id="app" data-route="favorites">
21+ <h1>Favorites</h1>
22+ <p>%d favorites · %d folders · %d tags</p>
23+ <section>
24+ <h2>Favorites</h2>
25+ <ul>%s</ul>
26+ </section>
27+ <section>
28+ <h2>Folders</h2>
29+ <ul>%s</ul>
30+ </section>
31+ <section>
32+ <h2>Tags</h2>
33+ <ul>%s</ul>
34+ </section>
35+ </main>
36+</body>
37+</html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags))
38+}
39+
40+func favoriteItemsHTML(favorites []*entity.Favorite) string {
41+ if len(favorites) == 0 {
42+ return "<li>No favorites</li>"
43+ }
44+
45+ var items strings.Builder
46+ for _, favorite := range favorites {
47+ if favorite == nil {
48+ continue
49+ }
50+ label := favorite.Title
51+ if label == "" {
52+ label = favorite.URL
53+ }
54+ items.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label)))
55+ }
56+ if items.Len() == 0 {
57+ return "<li>No favorites</li>"
58+ }
59+ return items.String()
60+}
61+
62+func folderItemsHTML(folders []*entity.Folder) string {
63+ if len(folders) == 0 {
64+ return "<li>No folders</li>"
65+ }
66+
67+ var items strings.Builder
68+ for _, folder := range folders {
69+ if folder == nil {
70+ continue
71+ }
72+ items.WriteString(fmt.Sprintf("<li>%s</li>", html.EscapeString(folder.Name)))
73+ }
74+ if items.Len() == 0 {
75+ return "<li>No folders</li>"
76+ }
77+ return items.String()
78+}
79+
80+func tagItemsHTML(tags []*entity.Tag) string {
81+ if len(tags) == 0 {
82+ return "<li>No tags</li>"
83+ }
84+
85+ var items strings.Builder
86+ for _, tag := range tags {
87+ if tag == nil {
88+ continue
89+ }
90+ items.WriteString(fmt.Sprintf("<li>%s</li>", html.EscapeString(tag.Name)))
91+ }
92+ if items.Len() == 0 {
93+ return "<li>No tags</li>"
94+ }
95+ return items.String()
96+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close