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	favoriteActionFilterTag      = "favorite.filterTag"
19	favoriteActionFilterUntagged = "favorite.filterUntagged"
20	favoriteActionClearFilters   = "favorite.clearFilters"
21	tagActionCreate              = "tag.create"
22	tagActionUpdate              = "tag.update"
23	tagActionDelete              = "tag.delete"
24	tagActionAssign              = "tag.assign"
25	tagActionRemove              = "tag.remove"
26)
27
28//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
29func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
30	if a.deps.Favorites == nil {
31		return fmt.Errorf("favorites service not configured")
32	}
33	data := event.Data
34	switch event.Action {
35	case favoriteActionCreate:
36		favoriteURL, err := validateFavoriteURL(data["url"])
37		if err != nil {
38			return err
39		}
40		tags, err := optionalTagIDs(data["tags"])
41		if err != nil {
42			return err
43		}
44		favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
45			URL:   favoriteURL,
46			Title: strings.TrimSpace(data["title"]),
47			Tags:  tags,
48		})
49		if err != nil {
50			return err
51		}
52		a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
53	case favoriteActionUpdate:
54		id, err := parsePositiveInt64(data["id"], "favorite id")
55		if err != nil {
56			return err
57		}
58		shortcut, err := optionalShortcut(data["shortcut_key"])
59		if err != nil {
60			return err
61		}
62		favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
63			ID:             entity.FavoriteID(id),
64			Title:          strings.TrimSpace(data["title"]),
65			FaviconURL:     strings.TrimSpace(data["favicon_url"]),
66			ShortcutKey:    shortcut,
67			ShortcutKeySet: true,
68		})
69		if err != nil {
70			return err
71		}
72		a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
73	case favoriteActionDelete:
74		id, err := parsePositiveInt64(data["id"], "favorite id")
75		if err != nil {
76			return err
77		}
78		if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
79			return err
80		}
81		a.favoritesNotice = "Deleted favorite"
82	case favoriteActionFilterTag:
83		tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
84		if err != nil {
85			return err
86		}
87		id := entity.TagID(tagID)
88		a.favoriteTagFilter = &id
89		a.favoriteUntaggedFilter = false
90		a.favoritesNotice = ""
91	case favoriteActionFilterUntagged:
92		a.favoriteTagFilter = nil
93		a.favoriteUntaggedFilter = true
94		a.favoritesNotice = ""
95	case favoriteActionClearFilters:
96		a.favoriteTagFilter = nil
97		a.favoriteUntaggedFilter = false
98		a.favoritesNotice = ""
99	case tagActionCreate:
100		name := strings.TrimSpace(data["name"])
101		if name == "" {
102			return fmt.Errorf("tag name is required")
103		}
104		tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
105		if err != nil {
106			return err
107		}
108		a.favoritesNotice = "Created tag " + tag.Name
109	case tagActionUpdate:
110		id, err := parsePositiveInt64(data["id"], "tag id")
111		if err != nil {
112			return err
113		}
114		name := strings.TrimSpace(data["name"])
115		if name == "" {
116			return fmt.Errorf("tag name is required")
117		}
118		if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
119			return err
120		}
121		a.favoritesNotice = "Saved tag " + name
122	case tagActionDelete:
123		id, err := parsePositiveInt64(data["id"], "tag id")
124		if err != nil {
125			return err
126		}
127		if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
128			return err
129		}
130		a.favoriteTagFilter = nil
131		a.favoritesNotice = "Deleted tag"
132	case tagActionAssign, tagActionRemove:
133		favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
134		if err != nil {
135			return err
136		}
137		tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
138		if err != nil {
139			return err
140		}
141		if event.Action == tagActionAssign {
142			if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
143				return err
144			}
145			a.favoritesNotice = "Assigned tag"
146		} else {
147			if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
148				return err
149			}
150			a.favoritesNotice = "Removed tag"
151		}
152	default:
153		return fmt.Errorf("unknown favorite action: %q", event.Action)
154	}
155	return nil
156}
157
158func validateFavoriteURL(raw string) (string, error) {
159	favoriteURL := strings.TrimSpace(raw)
160	if favoriteURL == "" {
161		return "", fmt.Errorf("favorite URL is required")
162	}
163	parsed, err := url.Parse(favoriteURL)
164	if err != nil || parsed.Scheme == "" {
165		return "", fmt.Errorf("favorite URL must be absolute")
166	}
167	switch strings.ToLower(parsed.Scheme) {
168	case "http", "https":
169		if parsed.Host == "" {
170			return "", fmt.Errorf("favorite URL host is required")
171		}
172	case "dumb":
173		if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
174			return favoriteURL, nil
175		}
176		return "", fmt.Errorf("favorite URL must target a known systemview route")
177	default:
178		return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
179	}
180	return favoriteURL, nil
181}
182
183func firstActionValue(data map[string]string, keys ...string) string {
184	for _, key := range keys {
185		if value, ok := data[key]; ok {
186			return value
187		}
188	}
189	return ""
190}
191
192func parsePositiveInt64(raw, label string) (int64, error) {
193	id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
194	if err != nil {
195		return 0, fmt.Errorf("invalid %s: %w", label, err)
196	}
197	if id <= 0 {
198		return 0, fmt.Errorf("%s must be a positive integer", label)
199	}
200	return id, nil
201}
202
203func optionalTagIDs(raw string) ([]entity.TagID, error) {
204	raw = strings.TrimSpace(raw)
205	if raw == "" {
206		return nil, nil
207	}
208	parts := strings.Split(raw, ",")
209	ids := make([]entity.TagID, 0, len(parts))
210	seen := make(map[entity.TagID]struct{}, len(parts))
211	for _, part := range parts {
212		id, err := parsePositiveInt64(part, "tag id")
213		if err != nil {
214			return nil, err
215		}
216		tagID := entity.TagID(id)
217		if _, ok := seen[tagID]; ok {
218			continue
219		}
220		seen[tagID] = struct{}{}
221		ids = append(ids, tagID)
222	}
223	return ids, nil
224}
225
226// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
227func optionalShortcut(raw string) (*int, error) {
228	raw = strings.TrimSpace(raw)
229	if raw == "" {
230		return nil, nil
231	}
232	value, err := strconv.Atoi(raw)
233	if err != nil || value < 1 || value > 9 {
234		return nil, fmt.Errorf("shortcut key must be 1-9")
235	}
236	return &value, nil
237}

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