v0.28.0 3cb3770

manage_favorites.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6	"sort"
7	"strings"
8	"sync"
9	"time"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/dumber/internal/domain/repository"
14	domainurl "github.com/bnema/dumber/internal/domain/url"
15	"github.com/bnema/dumber/internal/logging"
16)
17
18// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
19var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
20
21// ManageFavoritesUseCase handles favorite, folder, and tag operations.
22type ManageFavoritesUseCase struct {
23	favoriteRepo repository.FavoriteRepository
24	folderRepo   repository.FolderRepository
25	tagRepo      repository.TagRepository
26
27	cacheMu           sync.Mutex
28	favoritesCache    []*entity.Favorite
29	favoriteURLsCache map[string]struct{}
30	cacheTime         time.Time
31}
32
33const favoritesCacheTTL = 2 * time.Second
34
35// NewManageFavoritesUseCase creates a new favorites management use case.
36func NewManageFavoritesUseCase(
37	favoriteRepo repository.FavoriteRepository,
38	folderRepo repository.FolderRepository,
39	tagRepo repository.TagRepository,
40) *ManageFavoritesUseCase {
41	return &ManageFavoritesUseCase{
42		favoriteRepo: favoriteRepo,
43		folderRepo:   folderRepo,
44		tagRepo:      tagRepo,
45	}
46}
47
48// AddFavoriteInput contains parameters for adding a favorite.
49type AddFavoriteInput struct {
50	URL        string
51	Title      string
52	FaviconURL string
53	FolderID   *entity.FolderID
54	Tags       []entity.TagID
55}
56
57// Add creates a new favorite.
58func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
59	log := logging.FromContext(ctx)
60	log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
61
62	// Check if already favorited
63	existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
64	if err != nil {
65		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
66	}
67	if existing != nil {
68		log.Debug().Str("url", input.URL).Msg("URL already favorited")
69		return existing, nil
70	}
71
72	fav := entity.NewFavorite(input.URL, input.Title)
73	fav.FaviconURL = input.FaviconURL
74	fav.FolderID = input.FolderID
75
76	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
77		return nil, fmt.Errorf("failed to save favorite: %w", err)
78	}
79
80	// Assign tags
81	for _, tagID := range input.Tags {
82		if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
83			log.Warn().
84				Int64("tag_id", int64(tagID)).
85				Int64("favorite_id", int64(fav.ID)).
86				Err(err).
87				Msg("failed to assign tag")
88		}
89	}
90
91	log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
92	uc.invalidateCache()
93	return fav, nil
94}
95
96// Remove deletes a favorite by ID.
97func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
98	log := logging.FromContext(ctx)
99	log.Debug().Int64("id", int64(id)).Msg("removing favorite")
100
101	if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
102		return fmt.Errorf("failed to delete favorite: %w", err)
103	}
104
105	log.Info().Int64("id", int64(id)).Msg("favorite removed")
106	uc.invalidateCache()
107	return nil
108}
109
110// RemoveByURL deletes a favorite by its URL.
111func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
112	log := logging.FromContext(ctx)
113	log.Debug().Str("url", url).Msg("removing favorite by URL")
114
115	fav, err := uc.favoriteRepo.FindByURL(ctx, url)
116	if err != nil {
117		return fmt.Errorf("failed to find favorite: %w", err)
118	}
119	if fav == nil {
120		log.Debug().Str("url", url).Msg("favorite not found")
121		return nil
122	}
123
124	return uc.Remove(ctx, fav.ID)
125}
126
127// Update modifies a favorite's metadata.
128func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
129	log := logging.FromContext(ctx)
130	log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
131
132	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
133		return fmt.Errorf("failed to update favorite: %w", err)
134	}
135
136	log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
137	uc.invalidateCache()
138	return nil
139}
140
141// Move changes a favorite's folder.
142func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
143	log := logging.FromContext(ctx)
144	log.Debug().
145		Int64("favorite_id", int64(id)).
146		Msg("moving favorite to folder")
147
148	if err := uc.favoriteRepo.SetFolder(ctx, id, folderID); err != nil {
149		return fmt.Errorf("failed to move favorite: %w", err)
150	}
151
152	log.Info().Int64("id", int64(id)).Msg("favorite moved")
153	uc.invalidateCache()
154	return nil
155}
156
157// SetShortcut assigns or removes a keyboard shortcut (1-9).
158func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
159	log := logging.FromContext(ctx)
160
161	if key != nil && (*key < 1 || *key > 9) {
162		return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
163	}
164
165	log.Debug().
166		Int64("id", int64(id)).
167		Msg("setting favorite shortcut")
168
169	if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
170		return fmt.Errorf("failed to set shortcut: %w", err)
171	}
172
173	uc.invalidateCache()
174	return nil
175}
176
177// GetByShortcut finds the favorite with the given shortcut key.
178func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
179	log := logging.FromContext(ctx)
180	log.Debug().Int("key", key).Msg("getting favorite by shortcut")
181
182	if key < 1 || key > 9 {
183		return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
184	}
185
186	return uc.favoriteRepo.GetByShortcut(ctx, key)
187}
188
189// GetByURL finds a favorite by its URL.
190func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
191	log := logging.FromContext(ctx)
192	log.Debug().Str("url", url).Msg("getting favorite by URL")
193
194	return uc.favoriteRepo.FindByURL(ctx, url)
195}
196
197// GetAll retrieves all favorites.
198func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
199	log := logging.FromContext(ctx)
200	log.Debug().Msg("getting all favorites")
201
202	uc.cacheMu.Lock()
203	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
204		cached := uc.favoritesCache
205		uc.cacheMu.Unlock()
206		return cached, nil
207	}
208	uc.cacheMu.Unlock()
209
210	favs, err := uc.favoriteRepo.GetAll(ctx)
211	if err != nil {
212		return nil, fmt.Errorf("failed to get favorites: %w", err)
213	}
214
215	uc.cacheMu.Lock()
216	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
217		cached := uc.favoritesCache
218		uc.cacheMu.Unlock()
219		return cached, nil
220	}
221	uc.favoritesCache = favs
222	uc.favoriteURLsCache = buildFavoriteURLSet(favs)
223	uc.cacheTime = time.Now()
224	uc.cacheMu.Unlock()
225
226	log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
227	return favs, nil
228}
229
230// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
231func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
232	favs, err := uc.GetAll(ctx)
233	if err != nil {
234		return nil, err
235	}
236
237	queryLower := strings.ToLower(strings.TrimSpace(query))
238	if queryLower == "" {
239		return favs, nil
240	}
241
242	filtered := make([]*entity.Favorite, 0, len(favs))
243	for _, fav := range favs {
244		if fav == nil {
245			continue
246		}
247		titleLower := strings.ToLower(fav.Title)
248		urlLower := strings.ToLower(fav.URL)
249		if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
250			continue
251		}
252		filtered = append(filtered, fav)
253	}
254
255	sort.SliceStable(filtered, func(i, j int) bool {
256		leftScore := omniboxFavoriteScore(filtered[i], queryLower)
257		rightScore := omniboxFavoriteScore(filtered[j], queryLower)
258		if leftScore != rightScore {
259			return leftScore > rightScore
260		}
261		if filtered[i].Position != filtered[j].Position {
262			return filtered[i].Position < filtered[j].Position
263		}
264		return filtered[i].URL < filtered[j].URL
265	})
266
267	return filtered, nil
268}
269
270// IsFavorite checks if a URL is favorited.
271func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
272	fav, err := uc.favoriteRepo.FindByURL(ctx, url)
273	if err != nil {
274		return false, fmt.Errorf("failed to check favorite: %w", err)
275	}
276	return fav != nil, nil
277}
278
279func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
280	if fav == nil || queryLower == "" {
281		return 0
282	}
283
284	const (
285		favoriteExactMatchScore    = 600
286		favoriteHostPrefixScore    = 520
287		favoriteTitlePrefixScore   = 440
288		favoriteTitleContainsScore = 320
289		favoriteURLContainsScore   = 280
290	)
291
292	urlLower := strings.ToLower(fav.URL)
293	strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
294	if strippedURLLower == "" {
295		strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
296	}
297	titleLower := strings.ToLower(fav.Title)
298
299	switch {
300	case titleLower == queryLower || urlLower == queryLower:
301		return favoriteExactMatchScore
302	case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
303		return favoriteHostPrefixScore
304	case strings.HasPrefix(titleLower, queryLower):
305		return favoriteTitlePrefixScore
306	case strings.Contains(titleLower, queryLower):
307		return favoriteTitleContainsScore
308	case strings.Contains(urlLower, queryLower):
309		return favoriteURLContainsScore
310	default:
311		return 0
312	}
313}
314
315// ToggleResult indicates the result of a toggle operation.
316type ToggleResult struct {
317	Added   bool // true if favorite was added, false if removed
318	URL     string
319	Title   string
320	Message string // User-friendly message
321}
322
323// Toggle adds or removes a URL from favorites based on current state.
324// If the URL is already a favorite, it removes it. Otherwise, it adds it.
325func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
326	log := logging.FromContext(ctx)
327	log.Debug().Str("url", url).Msg("toggling favorite")
328
329	if url == "" {
330		return nil, fmt.Errorf("URL cannot be empty")
331	}
332
333	// Check if already favorited
334	existing, err := uc.favoriteRepo.FindByURL(ctx, url)
335	if err != nil {
336		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
337	}
338
339	if existing != nil {
340		// Remove favorite
341		if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
342			return nil, fmt.Errorf("failed to remove favorite: %w", err)
343		}
344		log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
345		uc.invalidateCache()
346		return &ToggleResult{
347			Added:   false,
348			URL:     url,
349			Title:   existing.Title,
350			Message: "Favorite removed",
351		}, nil
352	}
353
354	// Add favorite
355	fav := entity.NewFavorite(url, title)
356	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
357		return nil, fmt.Errorf("failed to add favorite: %w", err)
358	}
359	log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
360	uc.invalidateCache()
361	return &ToggleResult{
362		Added:   true,
363		URL:     url,
364		Title:   title,
365		Message: "Favorite added",
366	}, nil
367}
368
369// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
370func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
371	log := logging.FromContext(ctx)
372	log.Debug().Msg("getting all favorite URLs")
373
374	uc.cacheMu.Lock()
375	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
376		cached := uc.favoriteURLsCache
377		uc.cacheMu.Unlock()
378		return cached, nil
379	}
380	uc.cacheMu.Unlock()
381
382	favs, err := uc.GetAll(ctx)
383	if err != nil {
384		return nil, fmt.Errorf("failed to get favorites: %w", err)
385	}
386
387	urls := buildFavoriteURLSet(favs)
388
389	log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
390	return urls, nil
391}
392
393func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
394	urls := make(map[string]struct{}, len(favs))
395	for _, fav := range favs {
396		if fav == nil || fav.URL == "" {
397			continue
398		}
399		urls[fav.URL] = struct{}{}
400	}
401	return urls
402}
403
404func (uc *ManageFavoritesUseCase) invalidateCache() {
405	uc.cacheMu.Lock()
406	defer uc.cacheMu.Unlock()
407	uc.favoritesCache = nil
408	uc.favoriteURLsCache = nil
409	uc.cacheTime = time.Time{}
410}
411
412// GetTree builds a complete hierarchical view of folders and favorites.
413func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
414	log := logging.FromContext(ctx)
415	log.Debug().Msg("building favorite tree")
416
417	tree := entity.NewFavoriteTree()
418
419	// Get all folders
420	folders, err := uc.folderRepo.GetAll(ctx)
421	if err != nil {
422		return nil, fmt.Errorf("failed to get folders: %w", err)
423	}
424
425	// Build folder map and organize by parent
426	for _, folder := range folders {
427		tree.FolderMap[folder.ID] = folder
428		if folder.ParentID == nil {
429			tree.RootFolders = append(tree.RootFolders, folder)
430		} else {
431			tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder)
432		}
433	}
434
435	// Get all favorites
436	favorites, err := uc.favoriteRepo.GetAll(ctx)
437	if err != nil {
438		return nil, fmt.Errorf("failed to get favorites: %w", err)
439	}
440
441	// Organize favorites by folder
442	for _, fav := range favorites {
443		if fav.FolderID == nil {
444			tree.RootFavorites = append(tree.RootFavorites, fav)
445		} else {
446			tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav)
447		}
448	}
449
450	log.Debug().
451		Int("folders", len(folders)).
452		Int("favorites", len(favorites)).
453		Msg("favorite tree built")
454
455	return tree, nil
456}
457
458// CreateFolder creates a new folder.
459func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
460	log := logging.FromContext(ctx)
461	log.Debug().Str("name", name).Msg("creating folder")
462
463	folder := entity.NewFolder(name)
464	folder.ParentID = parentID
465
466	if err := uc.folderRepo.Save(ctx, folder); err != nil {
467		return nil, fmt.Errorf("failed to create folder: %w", err)
468	}
469
470	log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created")
471	return folder, nil
472}
473
474// DeleteFolder removes a folder. Favorites in the folder are moved to root.
475func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error {
476	log := logging.FromContext(ctx)
477	log.Debug().Int64("id", int64(id)).Msg("deleting folder")
478
479	// Move favorites from this folder to root (or parent)
480	folder, err := uc.folderRepo.FindByID(ctx, id)
481	if err != nil {
482		return fmt.Errorf("failed to find folder: %w", err)
483	}
484	if folder == nil {
485		log.Debug().Int64("id", int64(id)).Msg("folder not found")
486		return nil
487	}
488
489	// Get favorites in this folder
490	favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id)
491	if err != nil {
492		return fmt.Errorf("failed to get folder favorites: %w", err)
493	}
494
495	// Move favorites to parent folder (or root)
496	for _, fav := range favorites {
497		if setErr := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); setErr != nil {
498			log.Warn().
499				Int64("favorite_id", int64(fav.ID)).
500				Err(setErr).
501				Msg("failed to move favorite from deleted folder")
502		}
503	}
504	uc.invalidateCache()
505
506	// Get child folders
507	children, err := uc.folderRepo.GetChildren(ctx, &id)
508	if err != nil {
509		return fmt.Errorf("failed to get child folders: %w", err)
510	}
511
512	// Recursively delete child folders
513	for _, child := range children {
514		if err := uc.DeleteFolder(ctx, child.ID); err != nil {
515			log.Warn().
516				Int64("child_id", int64(child.ID)).
517				Err(err).
518				Msg("failed to delete child folder")
519		}
520	}
521
522	// Delete the folder itself
523	if err := uc.folderRepo.Delete(ctx, id); err != nil {
524		return fmt.Errorf("failed to delete folder: %w", err)
525	}
526
527	log.Info().Int64("id", int64(id)).Msg("folder deleted")
528	return nil
529}
530
531// GetAllFolders retrieves all folders.
532func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
533	log := logging.FromContext(ctx)
534	log.Debug().Msg("getting all folders")
535
536	return uc.folderRepo.GetAll(ctx)
537}
538
539// AddTag creates a new tag.
540func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
541	log := logging.FromContext(ctx)
542	log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
543
544	// Check if tag already exists
545	existing, err := uc.tagRepo.FindByName(ctx, name)
546	if err != nil {
547		return nil, fmt.Errorf("failed to check existing tag: %w", err)
548	}
549	if existing != nil {
550		log.Debug().Str("name", name).Msg("tag already exists")
551		return existing, nil
552	}
553
554	tag := entity.NewTag(name)
555	if color != "" {
556		tag.Color = color
557	}
558
559	if err := uc.tagRepo.Save(ctx, tag); err != nil {
560		return nil, fmt.Errorf("failed to create tag: %w", err)
561	}
562
563	log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
564	return tag, nil
565}
566
567// DeleteTag removes a tag.
568func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
569	log := logging.FromContext(ctx)
570	log.Debug().Int64("id", int64(id)).Msg("deleting tag")
571
572	if err := uc.tagRepo.Delete(ctx, id); err != nil {
573		return fmt.Errorf("failed to delete tag: %w", err)
574	}
575
576	log.Info().Int64("id", int64(id)).Msg("tag deleted")
577	return nil
578}
579
580// GetAllTags retrieves all tags.
581func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
582	log := logging.FromContext(ctx)
583	log.Debug().Msg("getting all tags")
584
585	return uc.tagRepo.GetAll(ctx)
586}
587
588// TagFavorite assigns a tag to a favorite.
589func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
590	log := logging.FromContext(ctx)
591	log.Debug().
592		Int64("favorite_id", int64(favID)).
593		Int64("tag_id", int64(tagID)).
594		Msg("tagging favorite")
595
596	if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
597		return fmt.Errorf("failed to tag favorite: %w", err)
598	}
599
600	return nil
601}
602
603// UntagFavorite removes a tag from a favorite.
604func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
605	log := logging.FromContext(ctx)
606	log.Debug().
607		Int64("favorite_id", int64(favID)).
608		Int64("tag_id", int64(tagID)).
609		Msg("untagging favorite")
610
611	if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
612		return fmt.Errorf("failed to untag favorite: %w", err)
613	}
614
615	return nil
616}
617
618// GetTagsForFavorite retrieves all tags for a favorite.
619func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
620	log := logging.FromContext(ctx)
621	log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
622
623	return uc.tagRepo.GetForFavorite(ctx, favID)
624}
625
626// UpdateFolder updates a folder's name and icon.
627func (uc *ManageFavoritesUseCase) UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error {
628	log := logging.FromContext(ctx)
629	log.Debug().Int64("id", int64(id)).Str("name", name).Msg("updating folder")
630
631	folder, err := uc.folderRepo.FindByID(ctx, id)
632	if err != nil {
633		return fmt.Errorf("failed to find folder: %w", err)
634	}
635	if folder == nil {
636		return fmt.Errorf("folder %d not found", id)
637	}
638
639	if name != "" {
640		folder.Name = name
641	}
642	folder.Icon = icon
643
644	if err := uc.folderRepo.Save(ctx, folder); err != nil {
645		return fmt.Errorf("failed to update folder: %w", err)
646	}
647
648	log.Info().Int64("id", int64(id)).Msg("folder updated")
649	return nil
650}
651
652// UpdateTag updates a tag's name and color.
653func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
654	log := logging.FromContext(ctx)
655	log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
656
657	tag, err := uc.tagRepo.FindByID(ctx, id)
658	if err != nil {
659		return fmt.Errorf("failed to find tag: %w", err)
660	}
661	if tag == nil {
662		return fmt.Errorf("tag %d not found", id)
663	}
664
665	if name != "" {
666		tag.Name = name
667	}
668	if color != "" {
669		tag.Color = color
670	}
671
672	if err := uc.tagRepo.Save(ctx, tag); err != nil {
673		return fmt.Errorf("failed to update tag: %w", err)
674	}
675
676	log.Info().Int64("id", int64(id)).Msg("tag updated")
677	return nil
678}

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