v0.29.0 21b3150

favorite_actions.go RawHistoryFolder

1package systemviews
2
3import (
4	"context"
5	"fmt"
6	"net/url"
7	"strconv"
8	"strings"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	"github.com/bnema/dumber/internal/domain/entity"
12)
13
14const (
15	favoriteActionCreate       = "favorite.create"
16	favoriteActionUpdate       = "favorite.update"
17	favoriteActionDelete       = "favorite.delete"
18	favoriteActionFilterFolder = "favorite.filterFolder"
19	favoriteActionFilterTag    = "favorite.filterTag"
20	favoriteActionClearFilters = "favorite.clearFilters"
21	folderActionCreate         = "folder.create"
22	folderActionUpdate         = "folder.update"
23	folderActionDelete         = "folder.delete"
24	tagActionCreate            = "tag.create"
25	tagActionUpdate            = "tag.update"
26	tagActionDelete            = "tag.delete"
27	tagActionAssign            = "tag.assign"
28	tagActionRemove            = "tag.remove"
29)
30
31//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
32func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
33	if a.deps.Favorites == nil {
34		return fmt.Errorf("favorites service not configured")
35	}
36	data := event.Data
37	switch event.Action {
38	case favoriteActionCreate:
39		folderID, err := optionalFolderID(data["folder_id"])
40		if err != nil {
41			return err
42		}
43		favoriteURL, err := validateFavoriteURL(data["url"])
44		if err != nil {
45			return err
46		}
47		tags, err := optionalTagIDs(data["tags"])
48		if err != nil {
49			return err
50		}
51		favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52			URL:      favoriteURL,
53			Title:    strings.TrimSpace(data["title"]),
54			FolderID: folderID,
55			Tags:     tags,
56		})
57		if err != nil {
58			return err
59		}
60		a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
61	case favoriteActionUpdate:
62		id, err := parsePositiveInt64(data["id"], "favorite id")
63		if err != nil {
64			return err
65		}
66		folderID, err := optionalFolderID(data["folder_id"])
67		if err != nil {
68			return err
69		}
70		shortcut, err := optionalShortcut(data["shortcut_key"])
71		if err != nil {
72			return err
73		}
74		favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75			ID:          entity.FavoriteID(id),
76			Title:       strings.TrimSpace(data["title"]),
77			FaviconURL:  strings.TrimSpace(data["favicon_url"]),
78			FolderID:    folderID,
79			ShortcutKey: shortcut,
80		})
81		if err != nil {
82			return err
83		}
84		a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
85	case favoriteActionDelete:
86		id, err := parsePositiveInt64(data["id"], "favorite id")
87		if err != nil {
88			return err
89		}
90		if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
91			return err
92		}
93		a.favoritesNotice = "Deleted favorite"
94	case favoriteActionFilterFolder:
95		folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
96		if err != nil {
97			return err
98		}
99		a.favoriteFolderFilter = folderID
100		a.favoriteTagFilter = nil
101		a.favoritesNotice = ""
102	case favoriteActionFilterTag:
103		tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
104		if err != nil {
105			return err
106		}
107		id := entity.TagID(tagID)
108		a.favoriteTagFilter = &id
109		a.favoriteFolderFilter = nil
110		a.favoritesNotice = ""
111	case favoriteActionClearFilters:
112		a.favoriteFolderFilter = nil
113		a.favoriteTagFilter = nil
114		a.favoritesNotice = ""
115	case folderActionCreate:
116		name := strings.TrimSpace(data["name"])
117		if name == "" {
118			return fmt.Errorf("folder name is required")
119		}
120		folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
121		if err != nil {
122			return err
123		}
124		a.favoritesNotice = "Created folder " + folderDisplayName(folder)
125	case folderActionUpdate:
126		id, err := parsePositiveInt64(data["id"], "folder id")
127		if err != nil {
128			return err
129		}
130		name := strings.TrimSpace(data["name"])
131		if name == "" {
132			return fmt.Errorf("folder name is required")
133		}
134		if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil {
135			return err
136		}
137		a.favoritesNotice = "Saved folder " + name
138	case folderActionDelete:
139		id, err := parsePositiveInt64(data["id"], "folder id")
140		if err != nil {
141			return err
142		}
143		if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil {
144			return err
145		}
146		a.favoriteFolderFilter = nil
147		a.favoritesNotice = "Deleted folder"
148	case tagActionCreate:
149		name := strings.TrimSpace(data["name"])
150		if name == "" {
151			return fmt.Errorf("tag name is required")
152		}
153		tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
154		if err != nil {
155			return err
156		}
157		a.favoritesNotice = "Created tag " + tag.Name
158	case tagActionUpdate:
159		id, err := parsePositiveInt64(data["id"], "tag id")
160		if err != nil {
161			return err
162		}
163		name := strings.TrimSpace(data["name"])
164		if name == "" {
165			return fmt.Errorf("tag name is required")
166		}
167		if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
168			return err
169		}
170		a.favoritesNotice = "Saved tag " + name
171	case tagActionDelete:
172		id, err := parsePositiveInt64(data["id"], "tag id")
173		if err != nil {
174			return err
175		}
176		if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
177			return err
178		}
179		a.favoriteTagFilter = nil
180		a.favoritesNotice = "Deleted tag"
181	case tagActionAssign, tagActionRemove:
182		favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
183		if err != nil {
184			return err
185		}
186		tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
187		if err != nil {
188			return err
189		}
190		if event.Action == tagActionAssign {
191			if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
192				return err
193			}
194			a.favoritesNotice = "Assigned tag"
195		} else {
196			if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
197				return err
198			}
199			a.favoritesNotice = "Removed tag"
200		}
201	default:
202		return fmt.Errorf("unknown favorite action: %q", event.Action)
203	}
204	return nil
205}
206
207func validateFavoriteURL(raw string) (string, error) {
208	favoriteURL := strings.TrimSpace(raw)
209	if favoriteURL == "" {
210		return "", fmt.Errorf("favorite URL is required")
211	}
212	parsed, err := url.Parse(favoriteURL)
213	if err != nil || parsed.Scheme == "" {
214		return "", fmt.Errorf("favorite URL must be absolute")
215	}
216	switch strings.ToLower(parsed.Scheme) {
217	case "http", "https":
218		if parsed.Host == "" {
219			return "", fmt.Errorf("favorite URL host is required")
220		}
221	case "dumb":
222		if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223			return favoriteURL, nil
224		}
225		return "", fmt.Errorf("favorite URL must target a known systemview route")
226	default:
227		return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
228	}
229	return favoriteURL, nil
230}
231
232func firstActionValue(data map[string]string, keys ...string) string {
233	for _, key := range keys {
234		if value, ok := data[key]; ok {
235			return value
236		}
237	}
238	return ""
239}
240
241func parsePositiveInt64(raw, label string) (int64, error) {
242	id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
243	if err != nil {
244		return 0, fmt.Errorf("invalid %s: %w", label, err)
245	}
246	if id <= 0 {
247		return 0, fmt.Errorf("%s must be a positive integer", label)
248	}
249	return id, nil
250}
251
252func optionalTagIDs(raw string) ([]entity.TagID, error) {
253	raw = strings.TrimSpace(raw)
254	if raw == "" {
255		return nil, nil
256	}
257	parts := strings.Split(raw, ",")
258	ids := make([]entity.TagID, 0, len(parts))
259	seen := make(map[entity.TagID]struct{}, len(parts))
260	for _, part := range parts {
261		id, err := parsePositiveInt64(part, "tag id")
262		if err != nil {
263			return nil, err
264		}
265		tagID := entity.TagID(id)
266		if _, ok := seen[tagID]; ok {
267			continue
268		}
269		seen[tagID] = struct{}{}
270		ids = append(ids, tagID)
271	}
272	return ids, nil
273}
274
275// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
276// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
277func optionalFolderID(raw string) (*entity.FolderID, error) {
278	raw = strings.TrimSpace(raw)
279	if raw == "" || raw == "root" {
280		return nil, nil
281	}
282	id, err := parsePositiveInt64(raw, "folder id")
283	if err != nil {
284		return nil, err
285	}
286	folderID := entity.FolderID(id)
287	return &folderID, nil
288}
289
290// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
291// favorites in the root folder; other inputs delegate to optionalFolderID.
292func filterFolderID(raw string) (*entity.FolderID, error) {
293	if strings.TrimSpace(raw) == "root" {
294		root := entity.FolderID(0)
295		return &root, nil
296	}
297	return optionalFolderID(raw)
298}
299
300// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301func optionalShortcut(raw string) (*int, error) {
302	raw = strings.TrimSpace(raw)
303	if raw == "" {
304		return nil, nil
305	}
306	value, err := strconv.Atoi(raw)
307	if err != nil || value < 1 || value > 9 {
308		return nil, fmt.Errorf("shortcut key must be 1-9")
309	}
310	return &value, nil
311}

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