detached 13b335c

feat(ui): implement GTK application core with dependency injection and tab bar component

commit
13b335c80d9b60b13988a57d8069c993534b72bf
author
bnema · · 2025-12-09 23:34 UTC
parent
27707ff
signature
unsigned
12 files · +2763 -0
A internal/application/usecase/manage_favorites.go +410 -0
@@ -0,0 +1,410 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ManageFavoritesUseCase handles favorite, folder, and tag operations.
13+type ManageFavoritesUseCase struct {
14+ favoriteRepo repository.FavoriteRepository
15+ folderRepo repository.FolderRepository
16+ tagRepo repository.TagRepository
17+}
18+
19+// NewManageFavoritesUseCase creates a new favorites management use case.
20+func NewManageFavoritesUseCase(
21+ favoriteRepo repository.FavoriteRepository,
22+ folderRepo repository.FolderRepository,
23+ tagRepo repository.TagRepository,
24+) *ManageFavoritesUseCase {
25+ return &ManageFavoritesUseCase{
26+ favoriteRepo: favoriteRepo,
27+ folderRepo: folderRepo,
28+ tagRepo: tagRepo,
29+ }
30+}
31+
32+// AddFavoriteInput contains parameters for adding a favorite.
33+type AddFavoriteInput struct {
34+ URL string
35+ Title string
36+ FaviconURL string
37+ FolderID *entity.FolderID
38+ Tags []entity.TagID
39+}
40+
41+// Add creates a new favorite.
42+func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
43+ log := logging.FromContext(ctx)
44+ log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
45+
46+ // Check if already favorited
47+ existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
48+ if err != nil {
49+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
50+ }
51+ if existing != nil {
52+ log.Debug().Str("url", input.URL).Msg("URL already favorited")
53+ return existing, nil
54+ }
55+
56+ fav := entity.NewFavorite(input.URL, input.Title)
57+ fav.FaviconURL = input.FaviconURL
58+ fav.FolderID = input.FolderID
59+
60+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
61+ return nil, fmt.Errorf("failed to save favorite: %w", err)
62+ }
63+
64+ // Assign tags
65+ for _, tagID := range input.Tags {
66+ if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
67+ log.Warn().
68+ Int64("tag_id", int64(tagID)).
69+ Int64("favorite_id", int64(fav.ID)).
70+ Err(err).
71+ Msg("failed to assign tag")
72+ }
73+ }
74+
75+ log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
76+ return fav, nil
77+}
78+
79+// Remove deletes a favorite by ID.
80+func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
81+ log := logging.FromContext(ctx)
82+ log.Debug().Int64("id", int64(id)).Msg("removing favorite")
83+
84+ if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
85+ return fmt.Errorf("failed to delete favorite: %w", err)
86+ }
87+
88+ log.Info().Int64("id", int64(id)).Msg("favorite removed")
89+ return nil
90+}
91+
92+// RemoveByURL deletes a favorite by its URL.
93+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
94+ log := logging.FromContext(ctx)
95+ log.Debug().Str("url", url).Msg("removing favorite by URL")
96+
97+ fav, err := uc.favoriteRepo.FindByURL(ctx, url)
98+ if err != nil {
99+ return fmt.Errorf("failed to find favorite: %w", err)
100+ }
101+ if fav == nil {
102+ log.Debug().Str("url", url).Msg("favorite not found")
103+ return nil
104+ }
105+
106+ return uc.Remove(ctx, fav.ID)
107+}
108+
109+// Update modifies a favorite's metadata.
110+func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
111+ log := logging.FromContext(ctx)
112+ log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
113+
114+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
115+ return fmt.Errorf("failed to update favorite: %w", err)
116+ }
117+
118+ log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
119+ return nil
120+}
121+
122+// Move changes a favorite's folder.
123+func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
124+ log := logging.FromContext(ctx)
125+ log.Debug().
126+ Int64("favorite_id", int64(id)).
127+ Msg("moving favorite to folder")
128+
129+ if err := uc.favoriteRepo.SetFolder(ctx, id, folderID); err != nil {
130+ return fmt.Errorf("failed to move favorite: %w", err)
131+ }
132+
133+ log.Info().Int64("id", int64(id)).Msg("favorite moved")
134+ return nil
135+}
136+
137+// SetShortcut assigns or removes a keyboard shortcut (1-9).
138+func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
139+ log := logging.FromContext(ctx)
140+
141+ if key != nil && (*key < 1 || *key > 9) {
142+ return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
143+ }
144+
145+ log.Debug().
146+ Int64("id", int64(id)).
147+ Msg("setting favorite shortcut")
148+
149+ if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
150+ return fmt.Errorf("failed to set shortcut: %w", err)
151+ }
152+
153+ return nil
154+}
155+
156+// GetByShortcut finds the favorite with the given shortcut key.
157+func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
158+ log := logging.FromContext(ctx)
159+ log.Debug().Int("key", key).Msg("getting favorite by shortcut")
160+
161+ if key < 1 || key > 9 {
162+ return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
163+ }
164+
165+ return uc.favoriteRepo.GetByShortcut(ctx, key)
166+}
167+
168+// GetByURL finds a favorite by its URL.
169+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
170+ log := logging.FromContext(ctx)
171+ log.Debug().Str("url", url).Msg("getting favorite by URL")
172+
173+ return uc.favoriteRepo.FindByURL(ctx, url)
174+}
175+
176+// GetAll retrieves all favorites.
177+func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
178+ log := logging.FromContext(ctx)
179+ log.Debug().Msg("getting all favorites")
180+
181+ favs, err := uc.favoriteRepo.GetAll(ctx)
182+ if err != nil {
183+ return nil, fmt.Errorf("failed to get favorites: %w", err)
184+ }
185+
186+ log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187+ return favs, nil
188+}
189+
190+// IsFavorite checks if a URL is favorited.
191+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192+ fav, err := uc.favoriteRepo.FindByURL(ctx, url)
193+ if err != nil {
194+ return false, fmt.Errorf("failed to check favorite: %w", err)
195+ }
196+ return fav != nil, nil
197+}
198+
199+// GetTree builds a complete hierarchical view of folders and favorites.
200+func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201+ log := logging.FromContext(ctx)
202+ log.Debug().Msg("building favorite tree")
203+
204+ tree := entity.NewFavoriteTree()
205+
206+ // Get all folders
207+ folders, err := uc.folderRepo.GetAll(ctx)
208+ if err != nil {
209+ return nil, fmt.Errorf("failed to get folders: %w", err)
210+ }
211+
212+ // Build folder map and organize by parent
213+ for _, folder := range folders {
214+ tree.FolderMap[folder.ID] = folder
215+ if folder.ParentID == nil {
216+ tree.RootFolders = append(tree.RootFolders, folder)
217+ } else {
218+ tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder)
219+ }
220+ }
221+
222+ // Get all favorites
223+ favorites, err := uc.favoriteRepo.GetAll(ctx)
224+ if err != nil {
225+ return nil, fmt.Errorf("failed to get favorites: %w", err)
226+ }
227+
228+ // Organize favorites by folder
229+ for _, fav := range favorites {
230+ if fav.FolderID == nil {
231+ tree.RootFavorites = append(tree.RootFavorites, fav)
232+ } else {
233+ tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav)
234+ }
235+ }
236+
237+ log.Debug().
238+ Int("folders", len(folders)).
239+ Int("favorites", len(favorites)).
240+ Msg("favorite tree built")
241+
242+ return tree, nil
243+}
244+
245+// CreateFolder creates a new folder.
246+func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
247+ log := logging.FromContext(ctx)
248+ log.Debug().Str("name", name).Msg("creating folder")
249+
250+ folder := entity.NewFolder(name)
251+ folder.ParentID = parentID
252+
253+ if err := uc.folderRepo.Save(ctx, folder); err != nil {
254+ return nil, fmt.Errorf("failed to create folder: %w", err)
255+ }
256+
257+ log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created")
258+ return folder, nil
259+}
260+
261+// DeleteFolder removes a folder. Favorites in the folder are moved to root.
262+func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error {
263+ log := logging.FromContext(ctx)
264+ log.Debug().Int64("id", int64(id)).Msg("deleting folder")
265+
266+ // Move favorites from this folder to root (or parent)
267+ folder, err := uc.folderRepo.FindByID(ctx, id)
268+ if err != nil {
269+ return fmt.Errorf("failed to find folder: %w", err)
270+ }
271+ if folder == nil {
272+ log.Debug().Int64("id", int64(id)).Msg("folder not found")
273+ return nil
274+ }
275+
276+ // Get favorites in this folder
277+ favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id)
278+ if err != nil {
279+ return fmt.Errorf("failed to get folder favorites: %w", err)
280+ }
281+
282+ // Move favorites to parent folder (or root)
283+ for _, fav := range favorites {
284+ if err := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); err != nil {
285+ log.Warn().
286+ Int64("favorite_id", int64(fav.ID)).
287+ Err(err).
288+ Msg("failed to move favorite from deleted folder")
289+ }
290+ }
291+
292+ // Get child folders
293+ children, err := uc.folderRepo.GetChildren(ctx, &id)
294+ if err != nil {
295+ return fmt.Errorf("failed to get child folders: %w", err)
296+ }
297+
298+ // Recursively delete child folders
299+ for _, child := range children {
300+ if err := uc.DeleteFolder(ctx, child.ID); err != nil {
301+ log.Warn().
302+ Int64("child_id", int64(child.ID)).
303+ Err(err).
304+ Msg("failed to delete child folder")
305+ }
306+ }
307+
308+ // Delete the folder itself
309+ if err := uc.folderRepo.Delete(ctx, id); err != nil {
310+ return fmt.Errorf("failed to delete folder: %w", err)
311+ }
312+
313+ log.Info().Int64("id", int64(id)).Msg("folder deleted")
314+ return nil
315+}
316+
317+// GetAllFolders retrieves all folders.
318+func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
319+ log := logging.FromContext(ctx)
320+ log.Debug().Msg("getting all folders")
321+
322+ return uc.folderRepo.GetAll(ctx)
323+}
324+
325+// AddTag creates a new tag.
326+func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
327+ log := logging.FromContext(ctx)
328+ log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
329+
330+ // Check if tag already exists
331+ existing, err := uc.tagRepo.FindByName(ctx, name)
332+ if err != nil {
333+ return nil, fmt.Errorf("failed to check existing tag: %w", err)
334+ }
335+ if existing != nil {
336+ log.Debug().Str("name", name).Msg("tag already exists")
337+ return existing, nil
338+ }
339+
340+ tag := entity.NewTag(name)
341+ if color != "" {
342+ tag.Color = color
343+ }
344+
345+ if err := uc.tagRepo.Save(ctx, tag); err != nil {
346+ return nil, fmt.Errorf("failed to create tag: %w", err)
347+ }
348+
349+ log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
350+ return tag, nil
351+}
352+
353+// DeleteTag removes a tag.
354+func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
355+ log := logging.FromContext(ctx)
356+ log.Debug().Int64("id", int64(id)).Msg("deleting tag")
357+
358+ if err := uc.tagRepo.Delete(ctx, id); err != nil {
359+ return fmt.Errorf("failed to delete tag: %w", err)
360+ }
361+
362+ log.Info().Int64("id", int64(id)).Msg("tag deleted")
363+ return nil
364+}
365+
366+// GetAllTags retrieves all tags.
367+func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
368+ log := logging.FromContext(ctx)
369+ log.Debug().Msg("getting all tags")
370+
371+ return uc.tagRepo.GetAll(ctx)
372+}
373+
374+// TagFavorite assigns a tag to a favorite.
375+func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
376+ log := logging.FromContext(ctx)
377+ log.Debug().
378+ Int64("favorite_id", int64(favID)).
379+ Int64("tag_id", int64(tagID)).
380+ Msg("tagging favorite")
381+
382+ if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
383+ return fmt.Errorf("failed to tag favorite: %w", err)
384+ }
385+
386+ return nil
387+}
388+
389+// UntagFavorite removes a tag from a favorite.
390+func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
391+ log := logging.FromContext(ctx)
392+ log.Debug().
393+ Int64("favorite_id", int64(favID)).
394+ Int64("tag_id", int64(tagID)).
395+ Msg("untagging favorite")
396+
397+ if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
398+ return fmt.Errorf("failed to untag favorite: %w", err)
399+ }
400+
401+ return nil
402+}
403+
404+// GetTagsForFavorite retrieves all tags for a favorite.
405+func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
406+ log := logging.FromContext(ctx)
407+ log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
408+
409+ return uc.tagRepo.GetForFavorite(ctx, favID)
410+}
@@ -0,0 +1,410 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ManageFavoritesUseCase handles favorite, folder, and tag operations.
13+type ManageFavoritesUseCase struct {
14+ favoriteRepo repository.FavoriteRepository
15+ folderRepo repository.FolderRepository
16+ tagRepo repository.TagRepository
17+}
18+
19+// NewManageFavoritesUseCase creates a new favorites management use case.
20+func NewManageFavoritesUseCase(
21+ favoriteRepo repository.FavoriteRepository,
22+ folderRepo repository.FolderRepository,
23+ tagRepo repository.TagRepository,
24+) *ManageFavoritesUseCase {
25+ return &ManageFavoritesUseCase{
26+ favoriteRepo: favoriteRepo,
27+ folderRepo: folderRepo,
28+ tagRepo: tagRepo,
29+ }
30+}
31+
32+// AddFavoriteInput contains parameters for adding a favorite.
33+type AddFavoriteInput struct {
34+ URL string
35+ Title string
36+ FaviconURL string
37+ FolderID *entity.FolderID
38+ Tags []entity.TagID
39+}
40+
41+// Add creates a new favorite.
42+func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
43+ log := logging.FromContext(ctx)
44+ log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
45+
46+ // Check if already favorited
47+ existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
48+ if err != nil {
49+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
50+ }
51+ if existing != nil {
52+ log.Debug().Str("url", input.URL).Msg("URL already favorited")
53+ return existing, nil
54+ }
55+
56+ fav := entity.NewFavorite(input.URL, input.Title)
57+ fav.FaviconURL = input.FaviconURL
58+ fav.FolderID = input.FolderID
59+
60+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
61+ return nil, fmt.Errorf("failed to save favorite: %w", err)
62+ }
63+
64+ // Assign tags
65+ for _, tagID := range input.Tags {
66+ if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
67+ log.Warn().
68+ Int64("tag_id", int64(tagID)).
69+ Int64("favorite_id", int64(fav.ID)).
70+ Err(err).
71+ Msg("failed to assign tag")
72+ }
73+ }
74+
75+ log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
76+ return fav, nil
77+}
78+
79+// Remove deletes a favorite by ID.
80+func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
81+ log := logging.FromContext(ctx)
82+ log.Debug().Int64("id", int64(id)).Msg("removing favorite")
83+
84+ if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
85+ return fmt.Errorf("failed to delete favorite: %w", err)
86+ }
87+
88+ log.Info().Int64("id", int64(id)).Msg("favorite removed")
89+ return nil
90+}
91+
92+// RemoveByURL deletes a favorite by its URL.
93+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
94+ log := logging.FromContext(ctx)
95+ log.Debug().Str("url", url).Msg("removing favorite by URL")
96+
97+ fav, err := uc.favoriteRepo.FindByURL(ctx, url)
98+ if err != nil {
99+ return fmt.Errorf("failed to find favorite: %w", err)
100+ }
101+ if fav == nil {
102+ log.Debug().Str("url", url).Msg("favorite not found")
103+ return nil
104+ }
105+
106+ return uc.Remove(ctx, fav.ID)
107+}
108+
109+// Update modifies a favorite's metadata.
110+func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
111+ log := logging.FromContext(ctx)
112+ log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
113+
114+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
115+ return fmt.Errorf("failed to update favorite: %w", err)
116+ }
117+
118+ log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
119+ return nil
120+}
121+
122+// Move changes a favorite's folder.
123+func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
124+ log := logging.FromContext(ctx)
125+ log.Debug().
126+ Int64("favorite_id", int64(id)).
127+ Msg("moving favorite to folder")
128+
129+ if err := uc.favoriteRepo.SetFolder(ctx, id, folderID); err != nil {
130+ return fmt.Errorf("failed to move favorite: %w", err)
131+ }
132+
133+ log.Info().Int64("id", int64(id)).Msg("favorite moved")
134+ return nil
135+}
136+
137+// SetShortcut assigns or removes a keyboard shortcut (1-9).
138+func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
139+ log := logging.FromContext(ctx)
140+
141+ if key != nil && (*key < 1 || *key > 9) {
142+ return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
143+ }
144+
145+ log.Debug().
146+ Int64("id", int64(id)).
147+ Msg("setting favorite shortcut")
148+
149+ if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
150+ return fmt.Errorf("failed to set shortcut: %w", err)
151+ }
152+
153+ return nil
154+}
155+
156+// GetByShortcut finds the favorite with the given shortcut key.
157+func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
158+ log := logging.FromContext(ctx)
159+ log.Debug().Int("key", key).Msg("getting favorite by shortcut")
160+
161+ if key < 1 || key > 9 {
162+ return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
163+ }
164+
165+ return uc.favoriteRepo.GetByShortcut(ctx, key)
166+}
167+
168+// GetByURL finds a favorite by its URL.
169+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
170+ log := logging.FromContext(ctx)
171+ log.Debug().Str("url", url).Msg("getting favorite by URL")
172+
173+ return uc.favoriteRepo.FindByURL(ctx, url)
174+}
175+
176+// GetAll retrieves all favorites.
177+func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
178+ log := logging.FromContext(ctx)
179+ log.Debug().Msg("getting all favorites")
180+
181+ favs, err := uc.favoriteRepo.GetAll(ctx)
182+ if err != nil {
183+ return nil, fmt.Errorf("failed to get favorites: %w", err)
184+ }
185+
186+ log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187+ return favs, nil
188+}
189+
190+// IsFavorite checks if a URL is favorited.
191+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192+ fav, err := uc.favoriteRepo.FindByURL(ctx, url)
193+ if err != nil {
194+ return false, fmt.Errorf("failed to check favorite: %w", err)
195+ }
196+ return fav != nil, nil
197+}
198+
199+// GetTree builds a complete hierarchical view of folders and favorites.
200+func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201+ log := logging.FromContext(ctx)
202+ log.Debug().Msg("building favorite tree")
203+
204+ tree := entity.NewFavoriteTree()
205+
206+ // Get all folders
207+ folders, err := uc.folderRepo.GetAll(ctx)
208+ if err != nil {
209+ return nil, fmt.Errorf("failed to get folders: %w", err)
210+ }
211+
212+ // Build folder map and organize by parent
213+ for _, folder := range folders {
214+ tree.FolderMap[folder.ID] = folder
215+ if folder.ParentID == nil {
216+ tree.RootFolders = append(tree.RootFolders, folder)
217+ } else {
218+ tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder)
219+ }
220+ }
221+
222+ // Get all favorites
223+ favorites, err := uc.favoriteRepo.GetAll(ctx)
224+ if err != nil {
225+ return nil, fmt.Errorf("failed to get favorites: %w", err)
226+ }
227+
228+ // Organize favorites by folder
229+ for _, fav := range favorites {
230+ if fav.FolderID == nil {
231+ tree.RootFavorites = append(tree.RootFavorites, fav)
232+ } else {
233+ tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav)
234+ }
235+ }
236+
237+ log.Debug().
238+ Int("folders", len(folders)).
239+ Int("favorites", len(favorites)).
240+ Msg("favorite tree built")
241+
242+ return tree, nil
243+}
244+
245+// CreateFolder creates a new folder.
246+func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
247+ log := logging.FromContext(ctx)
248+ log.Debug().Str("name", name).Msg("creating folder")
249+
250+ folder := entity.NewFolder(name)
251+ folder.ParentID = parentID
252+
253+ if err := uc.folderRepo.Save(ctx, folder); err != nil {
254+ return nil, fmt.Errorf("failed to create folder: %w", err)
255+ }
256+
257+ log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created")
258+ return folder, nil
259+}
260+
261+// DeleteFolder removes a folder. Favorites in the folder are moved to root.
262+func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error {
263+ log := logging.FromContext(ctx)
264+ log.Debug().Int64("id", int64(id)).Msg("deleting folder")
265+
266+ // Move favorites from this folder to root (or parent)
267+ folder, err := uc.folderRepo.FindByID(ctx, id)
268+ if err != nil {
269+ return fmt.Errorf("failed to find folder: %w", err)
270+ }
271+ if folder == nil {
272+ log.Debug().Int64("id", int64(id)).Msg("folder not found")
273+ return nil
274+ }
275+
276+ // Get favorites in this folder
277+ favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id)
278+ if err != nil {
279+ return fmt.Errorf("failed to get folder favorites: %w", err)
280+ }
281+
282+ // Move favorites to parent folder (or root)
283+ for _, fav := range favorites {
284+ if err := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); err != nil {
285+ log.Warn().
286+ Int64("favorite_id", int64(fav.ID)).
287+ Err(err).
288+ Msg("failed to move favorite from deleted folder")
289+ }
290+ }
291+
292+ // Get child folders
293+ children, err := uc.folderRepo.GetChildren(ctx, &id)
294+ if err != nil {
295+ return fmt.Errorf("failed to get child folders: %w", err)
296+ }
297+
298+ // Recursively delete child folders
299+ for _, child := range children {
300+ if err := uc.DeleteFolder(ctx, child.ID); err != nil {
301+ log.Warn().
302+ Int64("child_id", int64(child.ID)).
303+ Err(err).
304+ Msg("failed to delete child folder")
305+ }
306+ }
307+
308+ // Delete the folder itself
309+ if err := uc.folderRepo.Delete(ctx, id); err != nil {
310+ return fmt.Errorf("failed to delete folder: %w", err)
311+ }
312+
313+ log.Info().Int64("id", int64(id)).Msg("folder deleted")
314+ return nil
315+}
316+
317+// GetAllFolders retrieves all folders.
318+func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
319+ log := logging.FromContext(ctx)
320+ log.Debug().Msg("getting all folders")
321+
322+ return uc.folderRepo.GetAll(ctx)
323+}
324+
325+// AddTag creates a new tag.
326+func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
327+ log := logging.FromContext(ctx)
328+ log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
329+
330+ // Check if tag already exists
331+ existing, err := uc.tagRepo.FindByName(ctx, name)
332+ if err != nil {
333+ return nil, fmt.Errorf("failed to check existing tag: %w", err)
334+ }
335+ if existing != nil {
336+ log.Debug().Str("name", name).Msg("tag already exists")
337+ return existing, nil
338+ }
339+
340+ tag := entity.NewTag(name)
341+ if color != "" {
342+ tag.Color = color
343+ }
344+
345+ if err := uc.tagRepo.Save(ctx, tag); err != nil {
346+ return nil, fmt.Errorf("failed to create tag: %w", err)
347+ }
348+
349+ log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
350+ return tag, nil
351+}
352+
353+// DeleteTag removes a tag.
354+func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
355+ log := logging.FromContext(ctx)
356+ log.Debug().Int64("id", int64(id)).Msg("deleting tag")
357+
358+ if err := uc.tagRepo.Delete(ctx, id); err != nil {
359+ return fmt.Errorf("failed to delete tag: %w", err)
360+ }
361+
362+ log.Info().Int64("id", int64(id)).Msg("tag deleted")
363+ return nil
364+}
365+
366+// GetAllTags retrieves all tags.
367+func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
368+ log := logging.FromContext(ctx)
369+ log.Debug().Msg("getting all tags")
370+
371+ return uc.tagRepo.GetAll(ctx)
372+}
373+
374+// TagFavorite assigns a tag to a favorite.
375+func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
376+ log := logging.FromContext(ctx)
377+ log.Debug().
378+ Int64("favorite_id", int64(favID)).
379+ Int64("tag_id", int64(tagID)).
380+ Msg("tagging favorite")
381+
382+ if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
383+ return fmt.Errorf("failed to tag favorite: %w", err)
384+ }
385+
386+ return nil
387+}
388+
389+// UntagFavorite removes a tag from a favorite.
390+func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
391+ log := logging.FromContext(ctx)
392+ log.Debug().
393+ Int64("favorite_id", int64(favID)).
394+ Int64("tag_id", int64(tagID)).
395+ Msg("untagging favorite")
396+
397+ if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
398+ return fmt.Errorf("failed to untag favorite: %w", err)
399+ }
400+
401+ return nil
402+}
403+
404+// GetTagsForFavorite retrieves all tags for a favorite.
405+func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
406+ log := logging.FromContext(ctx)
407+ log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
408+
409+ return uc.tagRepo.GetForFavorite(ctx, favID)
410+}
A internal/application/usecase/manage_panes.go +588 -0
@@ -0,0 +1,588 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// SplitDirection indicates the direction of a pane split.
12+type SplitDirection string
13+
14+const (
15+ SplitLeft SplitDirection = "left"
16+ SplitRight SplitDirection = "right"
17+ SplitUp SplitDirection = "up"
18+ SplitDown SplitDirection = "down"
19+)
20+
21+// NavigateDirection indicates the direction for focus navigation.
22+type NavigateDirection string
23+
24+const (
25+ NavLeft NavigateDirection = "left"
26+ NavRight NavigateDirection = "right"
27+ NavUp NavigateDirection = "up"
28+ NavDown NavigateDirection = "down"
29+)
30+
31+// ManagePanesUseCase handles pane tree operations.
32+type ManagePanesUseCase struct {
33+ idGenerator IDGenerator
34+}
35+
36+// NewManagePanesUseCase creates a new pane management use case.
37+func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
38+ return &ManagePanesUseCase{
39+ idGenerator: idGenerator,
40+ }
41+}
42+
43+// SplitPaneInput contains parameters for splitting a pane.
44+type SplitPaneInput struct {
45+ Workspace *entity.Workspace
46+ TargetPane *entity.PaneNode
47+ Direction SplitDirection
48+ NewPane *entity.Pane // Optional: existing pane to insert (for popups)
49+ InitialURL string // URL for new pane (default: about:blank)
50+}
51+
52+// SplitPaneOutput contains the result of a split operation.
53+type SplitPaneOutput struct {
54+ NewPaneNode *entity.PaneNode
55+ ParentNode *entity.PaneNode // New parent container
56+ SplitRatio float64 // Always 0.5 for new splits
57+}
58+
59+// Split creates a new pane adjacent to the target pane.
60+func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (*SplitPaneOutput, error) {
61+ log := logging.FromContext(ctx)
62+ log.Debug().
63+ Str("direction", string(input.Direction)).
64+ Str("target_id", input.TargetPane.ID).
65+ Msg("splitting pane")
66+
67+ if input.Workspace == nil {
68+ return nil, fmt.Errorf("workspace is required")
69+ }
70+ if input.TargetPane == nil {
71+ return nil, fmt.Errorf("target pane is required")
72+ }
73+
74+ // If target is stacked, split around the entire stack
75+ targetNode := input.TargetPane
76+ if targetNode.IsStacked {
77+ // Find the stack container
78+ log.Debug().Msg("target is stacked, splitting around stack")
79+ }
80+
81+ // Create new pane
82+ var newPane *entity.Pane
83+ if input.NewPane != nil {
84+ newPane = input.NewPane
85+ } else {
86+ paneID := entity.PaneID(uc.idGenerator())
87+ newPane = entity.NewPane(paneID)
88+ if input.InitialURL != "" {
89+ newPane.URI = input.InitialURL
90+ }
91+ }
92+
93+ // Create new pane node
94+ newPaneNode := &entity.PaneNode{
95+ ID: string(newPane.ID),
96+ Pane: newPane,
97+ }
98+
99+ // Determine split orientation
100+ var splitDir entity.SplitDirection
101+ switch input.Direction {
102+ case SplitLeft, SplitRight:
103+ splitDir = entity.SplitHorizontal
104+ case SplitUp, SplitDown:
105+ splitDir = entity.SplitVertical
106+ }
107+
108+ // Create parent container node
109+ parentID := uc.idGenerator()
110+ parentNode := &entity.PaneNode{
111+ ID: parentID,
112+ SplitDir: splitDir,
113+ SplitRatio: 0.5,
114+ Children: make([]*entity.PaneNode, 2),
115+ }
116+
117+ // Arrange children based on direction
118+ switch input.Direction {
119+ case SplitLeft, SplitUp:
120+ // New pane goes first (left/top)
121+ parentNode.Children[0] = newPaneNode
122+ parentNode.Children[1] = targetNode
123+ case SplitRight, SplitDown:
124+ // Existing pane stays first, new pane goes second (right/bottom)
125+ parentNode.Children[0] = targetNode
126+ parentNode.Children[1] = newPaneNode
127+ }
128+
129+ // Update parent references
130+ newPaneNode.Parent = parentNode
131+ oldParent := targetNode.Parent
132+ targetNode.Parent = parentNode
133+
134+ // Insert parent into tree
135+ if oldParent == nil {
136+ // Target was root
137+ input.Workspace.Root = parentNode
138+ } else {
139+ // Replace target with parent in old parent's children
140+ for i, child := range oldParent.Children {
141+ if child == targetNode {
142+ oldParent.Children[i] = parentNode
143+ break
144+ }
145+ }
146+ parentNode.Parent = oldParent
147+ }
148+
149+ log.Info().
150+ Str("new_pane_id", string(newPane.ID)).
151+ Str("parent_id", parentID).
152+ Str("direction", string(input.Direction)).
153+ Msg("pane split completed")
154+
155+ return &SplitPaneOutput{
156+ NewPaneNode: newPaneNode,
157+ ParentNode: parentNode,
158+ SplitRatio: 0.5,
159+ }, nil
160+}
161+
162+// Close removes a pane and promotes its sibling.
163+// Returns the sibling node that was promoted, or nil if this was the last pane.
164+func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
165+ log := logging.FromContext(ctx)
166+ log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
167+
168+ if ws == nil {
169+ return nil, fmt.Errorf("workspace is required")
170+ }
171+ if paneNode == nil {
172+ return nil, fmt.Errorf("pane node is required")
173+ }
174+ if !paneNode.IsLeaf() {
175+ return nil, fmt.Errorf("cannot close non-leaf node")
176+ }
177+
178+ parent := paneNode.Parent
179+
180+ // If no parent, this is the root (last pane)
181+ if parent == nil {
182+ log.Info().Msg("closing last pane in workspace")
183+ ws.Root = nil
184+ ws.ActivePaneID = ""
185+ return nil, nil
186+ }
187+
188+ // Find sibling
189+ var sibling *entity.PaneNode
190+ for _, child := range parent.Children {
191+ if child != paneNode {
192+ sibling = child
193+ break
194+ }
195+ }
196+
197+ if sibling == nil {
198+ return nil, fmt.Errorf("no sibling found for pane")
199+ }
200+
201+ grandparent := parent.Parent
202+
203+ // Promote sibling to parent's position
204+ if grandparent == nil {
205+ // Parent was root, sibling becomes new root
206+ ws.Root = sibling
207+ sibling.Parent = nil
208+ } else {
209+ // Replace parent with sibling in grandparent
210+ for i, child := range grandparent.Children {
211+ if child == parent {
212+ grandparent.Children[i] = sibling
213+ break
214+ }
215+ }
216+ sibling.Parent = grandparent
217+ }
218+
219+ // Update active pane if needed
220+ if ws.ActivePaneID == paneNode.Pane.ID {
221+ // Find a new active pane from the sibling subtree
222+ var newActive *entity.Pane
223+ sibling.Walk(func(node *entity.PaneNode) bool {
224+ if node.IsLeaf() && node.Pane != nil {
225+ newActive = node.Pane
226+ return false // Stop walking
227+ }
228+ return true
229+ })
230+ if newActive != nil {
231+ ws.ActivePaneID = newActive.ID
232+ } else {
233+ ws.ActivePaneID = ""
234+ }
235+ }
236+
237+ log.Info().
238+ Str("closed_pane_id", paneNode.ID).
239+ Str("promoted_sibling_id", sibling.ID).
240+ Msg("pane closed, sibling promoted")
241+
242+ return sibling, nil
243+}
244+
245+// Focus sets the active pane in the workspace.
246+func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
247+ log := logging.FromContext(ctx)
248+ log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
249+
250+ if ws == nil {
251+ return fmt.Errorf("workspace is required")
252+ }
253+
254+ node := ws.FindPane(paneID)
255+ if node == nil {
256+ return fmt.Errorf("pane not found: %s", paneID)
257+ }
258+
259+ oldActive := ws.ActivePaneID
260+ ws.ActivePaneID = paneID
261+
262+ log.Info().
263+ Str("from", string(oldActive)).
264+ Str("to", string(paneID)).
265+ Msg("focus changed")
266+
267+ return nil
268+}
269+
270+// NavigateFocus moves focus to an adjacent pane.
271+// Returns the newly focused pane, or nil if navigation not possible.
272+func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Workspace, direction NavigateDirection) (*entity.PaneNode, error) {
273+ log := logging.FromContext(ctx)
274+ log.Debug().Str("direction", string(direction)).Msg("navigating focus")
275+
276+ if ws == nil {
277+ return nil, fmt.Errorf("workspace is required")
278+ }
279+
280+ activeNode := ws.ActivePane()
281+ if activeNode == nil {
282+ return nil, fmt.Errorf("no active pane")
283+ }
284+
285+ // Try structural navigation first
286+ targetNode := uc.findAdjacentPane(activeNode, direction)
287+
288+ if targetNode == nil {
289+ log.Debug().Msg("no adjacent pane found")
290+ return nil, nil
291+ }
292+
293+ ws.ActivePaneID = targetNode.Pane.ID
294+
295+ log.Info().
296+ Str("from", activeNode.ID).
297+ Str("to", targetNode.ID).
298+ Str("direction", string(direction)).
299+ Msg("focus navigated")
300+
301+ return targetNode, nil
302+}
303+
304+// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
305+func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
306+ if node == nil {
307+ return nil
308+ }
309+
310+ // Determine if we need to go horizontal or vertical
311+ isHorizontal := direction == NavLeft || direction == NavRight
312+ isForward := direction == NavRight || direction == NavDown
313+
314+ // Walk up the tree to find an ancestor with the right split type
315+ current := node
316+ for current.Parent != nil {
317+ parent := current.Parent
318+
319+ // Check if this parent's split orientation matches our direction
320+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
321+
322+ if parentIsHorizontal == isHorizontal {
323+ // Find which child we came from
324+ childIndex := -1
325+ for i, child := range parent.Children {
326+ if child == current {
327+ childIndex = i
328+ break
329+ }
330+ }
331+
332+ // Check if we can move in the desired direction
333+ targetIndex := childIndex
334+ if isForward {
335+ targetIndex = childIndex + 1
336+ } else {
337+ targetIndex = childIndex - 1
338+ }
339+
340+ if targetIndex >= 0 && targetIndex < len(parent.Children) {
341+ // Found a valid sibling, descend into it to find a leaf
342+ return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
343+ }
344+ }
345+
346+ current = parent
347+ }
348+
349+ return nil
350+}
351+
352+// findLeafInDirection descends into a node to find a leaf pane.
353+// If fromEnd is true, prefers the last child; otherwise prefers the first.
354+func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
355+ if node == nil {
356+ return nil
357+ }
358+
359+ // If this is a leaf, return it
360+ if node.IsLeaf() {
361+ return node
362+ }
363+
364+ // If stacked, return active pane
365+ if node.IsStacked && len(node.Children) > 0 {
366+ activeIdx := node.ActiveStackIndex
367+ if activeIdx >= 0 && activeIdx < len(node.Children) {
368+ return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
369+ }
370+ return uc.findLeafInDirection(node.Children[0], fromEnd)
371+ }
372+
373+ // Descend into children
374+ if len(node.Children) > 0 {
375+ if fromEnd {
376+ return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
377+ }
378+ return uc.findLeafInDirection(node.Children[0], fromEnd)
379+ }
380+
381+ return nil
382+}
383+
384+// CreateStack converts a pane into a stacked container.
385+func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error {
386+ log := logging.FromContext(ctx)
387+ log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
388+
389+ if ws == nil {
390+ return fmt.Errorf("workspace is required")
391+ }
392+ if paneNode == nil {
393+ return fmt.Errorf("pane node is required")
394+ }
395+ if !paneNode.IsLeaf() {
396+ return fmt.Errorf("can only create stack from leaf pane")
397+ }
398+ if paneNode.IsStacked {
399+ return fmt.Errorf("pane is already stacked")
400+ }
401+
402+ // Convert to stacked container
403+ paneNode.IsStacked = true
404+ paneNode.ActiveStackIndex = 0
405+
406+ // The pane becomes a child of itself conceptually
407+ // Create a child node for the actual pane
408+ childNode := &entity.PaneNode{
409+ ID: paneNode.ID + "_child",
410+ Pane: paneNode.Pane,
411+ Parent: paneNode,
412+ }
413+ paneNode.Pane = nil
414+ paneNode.Children = []*entity.PaneNode{childNode}
415+
416+ log.Info().Str("stack_id", paneNode.ID).Msg("stack created")
417+ return nil
418+}
419+
420+// AddToStack adds a pane to an existing stack.
421+func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error {
422+ log := logging.FromContext(ctx)
423+ log.Debug().
424+ Str("stack_id", stackNode.ID).
425+ Str("pane_id", string(pane.ID)).
426+ Msg("adding pane to stack")
427+
428+ if ws == nil {
429+ return fmt.Errorf("workspace is required")
430+ }
431+ if stackNode == nil {
432+ return fmt.Errorf("stack node is required")
433+ }
434+ if !stackNode.IsStacked {
435+ return fmt.Errorf("node is not a stack")
436+ }
437+ if pane == nil {
438+ return fmt.Errorf("pane is required")
439+ }
440+
441+ // Create node for the new pane
442+ newNode := &entity.PaneNode{
443+ ID: string(pane.ID),
444+ Pane: pane,
445+ Parent: stackNode,
446+ }
447+
448+ // Add to stack
449+ stackNode.Children = append(stackNode.Children, newNode)
450+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
451+
452+ log.Info().
453+ Str("stack_id", stackNode.ID).
454+ Str("pane_id", string(pane.ID)).
455+ Int("stack_size", len(stackNode.Children)).
456+ Msg("pane added to stack")
457+
458+ return nil
459+}
460+
461+// NavigateStack cycles through stacked panes.
462+// direction: NavUp for previous, NavDown for next.
463+func (uc *ManagePanesUseCase) NavigateStack(ctx context.Context, stackNode *entity.PaneNode, direction NavigateDirection) (*entity.Pane, error) {
464+ log := logging.FromContext(ctx)
465+ log.Debug().
466+ Str("stack_id", stackNode.ID).
467+ Str("direction", string(direction)).
468+ Msg("navigating stack")
469+
470+ if stackNode == nil {
471+ return nil, fmt.Errorf("stack node is required")
472+ }
473+ if !stackNode.IsStacked {
474+ return nil, fmt.Errorf("node is not a stack")
475+ }
476+ if len(stackNode.Children) == 0 {
477+ return nil, fmt.Errorf("stack is empty")
478+ }
479+
480+ count := len(stackNode.Children)
481+ current := stackNode.ActiveStackIndex
482+
483+ var newIndex int
484+ switch direction {
485+ case NavUp:
486+ newIndex = (current - 1 + count) % count
487+ case NavDown:
488+ newIndex = (current + 1) % count
489+ default:
490+ return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
491+ }
492+
493+ stackNode.ActiveStackIndex = newIndex
494+
495+ activeChild := stackNode.Children[newIndex]
496+ var activePaneNode *entity.PaneNode
497+ if activeChild.IsLeaf() {
498+ activePaneNode = activeChild
499+ } else {
500+ // Find first leaf in the child
501+ activeChild.Walk(func(n *entity.PaneNode) bool {
502+ if n.IsLeaf() {
503+ activePaneNode = n
504+ return false
505+ }
506+ return true
507+ })
508+ }
509+
510+ if activePaneNode == nil || activePaneNode.Pane == nil {
511+ return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
512+ }
513+
514+ log.Info().
515+ Str("stack_id", stackNode.ID).
516+ Int("from_index", current).
517+ Int("to_index", newIndex).
518+ Str("pane_id", string(activePaneNode.Pane.ID)).
519+ Msg("stack navigated")
520+
521+ return activePaneNode.Pane, nil
522+}
523+
524+// RemoveFromStack removes a pane from a stack.
525+// If only one pane remains, the stack is dissolved.
526+func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
527+ log := logging.FromContext(ctx)
528+ log.Debug().
529+ Str("stack_id", stackNode.ID).
530+ Str("pane_id", string(paneID)).
531+ Msg("removing pane from stack")
532+
533+ if stackNode == nil {
534+ return fmt.Errorf("stack node is required")
535+ }
536+ if !stackNode.IsStacked {
537+ return fmt.Errorf("node is not a stack")
538+ }
539+
540+ // Find and remove the pane
541+ var removed bool
542+ newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
543+ for _, child := range stackNode.Children {
544+ if child.Pane != nil && child.Pane.ID == paneID {
545+ removed = true
546+ continue
547+ }
548+ newChildren = append(newChildren, child)
549+ }
550+
551+ if !removed {
552+ return fmt.Errorf("pane not found in stack: %s", paneID)
553+ }
554+
555+ stackNode.Children = newChildren
556+
557+ // Adjust active index
558+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
559+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
560+ }
561+ if stackNode.ActiveStackIndex < 0 {
562+ stackNode.ActiveStackIndex = 0
563+ }
564+
565+ log.Info().
566+ Str("stack_id", stackNode.ID).
567+ Str("pane_id", string(paneID)).
568+ Int("remaining", len(stackNode.Children)).
569+ Msg("pane removed from stack")
570+
571+ return nil
572+}
573+
574+// GetAllPanes returns all leaf panes in a workspace.
575+func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
576+ if ws == nil || ws.Root == nil {
577+ return nil
578+ }
579+ return ws.AllPanes()
580+}
581+
582+// CountPanes returns the number of panes in a workspace.
583+func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
584+ if ws == nil {
585+ return 0
586+ }
587+ return ws.PaneCount()
588+}
@@ -0,0 +1,588 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// SplitDirection indicates the direction of a pane split.
12+type SplitDirection string
13+
14+const (
15+ SplitLeft SplitDirection = "left"
16+ SplitRight SplitDirection = "right"
17+ SplitUp SplitDirection = "up"
18+ SplitDown SplitDirection = "down"
19+)
20+
21+// NavigateDirection indicates the direction for focus navigation.
22+type NavigateDirection string
23+
24+const (
25+ NavLeft NavigateDirection = "left"
26+ NavRight NavigateDirection = "right"
27+ NavUp NavigateDirection = "up"
28+ NavDown NavigateDirection = "down"
29+)
30+
31+// ManagePanesUseCase handles pane tree operations.
32+type ManagePanesUseCase struct {
33+ idGenerator IDGenerator
34+}
35+
36+// NewManagePanesUseCase creates a new pane management use case.
37+func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
38+ return &ManagePanesUseCase{
39+ idGenerator: idGenerator,
40+ }
41+}
42+
43+// SplitPaneInput contains parameters for splitting a pane.
44+type SplitPaneInput struct {
45+ Workspace *entity.Workspace
46+ TargetPane *entity.PaneNode
47+ Direction SplitDirection
48+ NewPane *entity.Pane // Optional: existing pane to insert (for popups)
49+ InitialURL string // URL for new pane (default: about:blank)
50+}
51+
52+// SplitPaneOutput contains the result of a split operation.
53+type SplitPaneOutput struct {
54+ NewPaneNode *entity.PaneNode
55+ ParentNode *entity.PaneNode // New parent container
56+ SplitRatio float64 // Always 0.5 for new splits
57+}
58+
59+// Split creates a new pane adjacent to the target pane.
60+func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (*SplitPaneOutput, error) {
61+ log := logging.FromContext(ctx)
62+ log.Debug().
63+ Str("direction", string(input.Direction)).
64+ Str("target_id", input.TargetPane.ID).
65+ Msg("splitting pane")
66+
67+ if input.Workspace == nil {
68+ return nil, fmt.Errorf("workspace is required")
69+ }
70+ if input.TargetPane == nil {
71+ return nil, fmt.Errorf("target pane is required")
72+ }
73+
74+ // If target is stacked, split around the entire stack
75+ targetNode := input.TargetPane
76+ if targetNode.IsStacked {
77+ // Find the stack container
78+ log.Debug().Msg("target is stacked, splitting around stack")
79+ }
80+
81+ // Create new pane
82+ var newPane *entity.Pane
83+ if input.NewPane != nil {
84+ newPane = input.NewPane
85+ } else {
86+ paneID := entity.PaneID(uc.idGenerator())
87+ newPane = entity.NewPane(paneID)
88+ if input.InitialURL != "" {
89+ newPane.URI = input.InitialURL
90+ }
91+ }
92+
93+ // Create new pane node
94+ newPaneNode := &entity.PaneNode{
95+ ID: string(newPane.ID),
96+ Pane: newPane,
97+ }
98+
99+ // Determine split orientation
100+ var splitDir entity.SplitDirection
101+ switch input.Direction {
102+ case SplitLeft, SplitRight:
103+ splitDir = entity.SplitHorizontal
104+ case SplitUp, SplitDown:
105+ splitDir = entity.SplitVertical
106+ }
107+
108+ // Create parent container node
109+ parentID := uc.idGenerator()
110+ parentNode := &entity.PaneNode{
111+ ID: parentID,
112+ SplitDir: splitDir,
113+ SplitRatio: 0.5,
114+ Children: make([]*entity.PaneNode, 2),
115+ }
116+
117+ // Arrange children based on direction
118+ switch input.Direction {
119+ case SplitLeft, SplitUp:
120+ // New pane goes first (left/top)
121+ parentNode.Children[0] = newPaneNode
122+ parentNode.Children[1] = targetNode
123+ case SplitRight, SplitDown:
124+ // Existing pane stays first, new pane goes second (right/bottom)
125+ parentNode.Children[0] = targetNode
126+ parentNode.Children[1] = newPaneNode
127+ }
128+
129+ // Update parent references
130+ newPaneNode.Parent = parentNode
131+ oldParent := targetNode.Parent
132+ targetNode.Parent = parentNode
133+
134+ // Insert parent into tree
135+ if oldParent == nil {
136+ // Target was root
137+ input.Workspace.Root = parentNode
138+ } else {
139+ // Replace target with parent in old parent's children
140+ for i, child := range oldParent.Children {
141+ if child == targetNode {
142+ oldParent.Children[i] = parentNode
143+ break
144+ }
145+ }
146+ parentNode.Parent = oldParent
147+ }
148+
149+ log.Info().
150+ Str("new_pane_id", string(newPane.ID)).
151+ Str("parent_id", parentID).
152+ Str("direction", string(input.Direction)).
153+ Msg("pane split completed")
154+
155+ return &SplitPaneOutput{
156+ NewPaneNode: newPaneNode,
157+ ParentNode: parentNode,
158+ SplitRatio: 0.5,
159+ }, nil
160+}
161+
162+// Close removes a pane and promotes its sibling.
163+// Returns the sibling node that was promoted, or nil if this was the last pane.
164+func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
165+ log := logging.FromContext(ctx)
166+ log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
167+
168+ if ws == nil {
169+ return nil, fmt.Errorf("workspace is required")
170+ }
171+ if paneNode == nil {
172+ return nil, fmt.Errorf("pane node is required")
173+ }
174+ if !paneNode.IsLeaf() {
175+ return nil, fmt.Errorf("cannot close non-leaf node")
176+ }
177+
178+ parent := paneNode.Parent
179+
180+ // If no parent, this is the root (last pane)
181+ if parent == nil {
182+ log.Info().Msg("closing last pane in workspace")
183+ ws.Root = nil
184+ ws.ActivePaneID = ""
185+ return nil, nil
186+ }
187+
188+ // Find sibling
189+ var sibling *entity.PaneNode
190+ for _, child := range parent.Children {
191+ if child != paneNode {
192+ sibling = child
193+ break
194+ }
195+ }
196+
197+ if sibling == nil {
198+ return nil, fmt.Errorf("no sibling found for pane")
199+ }
200+
201+ grandparent := parent.Parent
202+
203+ // Promote sibling to parent's position
204+ if grandparent == nil {
205+ // Parent was root, sibling becomes new root
206+ ws.Root = sibling
207+ sibling.Parent = nil
208+ } else {
209+ // Replace parent with sibling in grandparent
210+ for i, child := range grandparent.Children {
211+ if child == parent {
212+ grandparent.Children[i] = sibling
213+ break
214+ }
215+ }
216+ sibling.Parent = grandparent
217+ }
218+
219+ // Update active pane if needed
220+ if ws.ActivePaneID == paneNode.Pane.ID {
221+ // Find a new active pane from the sibling subtree
222+ var newActive *entity.Pane
223+ sibling.Walk(func(node *entity.PaneNode) bool {
224+ if node.IsLeaf() && node.Pane != nil {
225+ newActive = node.Pane
226+ return false // Stop walking
227+ }
228+ return true
229+ })
230+ if newActive != nil {
231+ ws.ActivePaneID = newActive.ID
232+ } else {
233+ ws.ActivePaneID = ""
234+ }
235+ }
236+
237+ log.Info().
238+ Str("closed_pane_id", paneNode.ID).
239+ Str("promoted_sibling_id", sibling.ID).
240+ Msg("pane closed, sibling promoted")
241+
242+ return sibling, nil
243+}
244+
245+// Focus sets the active pane in the workspace.
246+func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
247+ log := logging.FromContext(ctx)
248+ log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
249+
250+ if ws == nil {
251+ return fmt.Errorf("workspace is required")
252+ }
253+
254+ node := ws.FindPane(paneID)
255+ if node == nil {
256+ return fmt.Errorf("pane not found: %s", paneID)
257+ }
258+
259+ oldActive := ws.ActivePaneID
260+ ws.ActivePaneID = paneID
261+
262+ log.Info().
263+ Str("from", string(oldActive)).
264+ Str("to", string(paneID)).
265+ Msg("focus changed")
266+
267+ return nil
268+}
269+
270+// NavigateFocus moves focus to an adjacent pane.
271+// Returns the newly focused pane, or nil if navigation not possible.
272+func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Workspace, direction NavigateDirection) (*entity.PaneNode, error) {
273+ log := logging.FromContext(ctx)
274+ log.Debug().Str("direction", string(direction)).Msg("navigating focus")
275+
276+ if ws == nil {
277+ return nil, fmt.Errorf("workspace is required")
278+ }
279+
280+ activeNode := ws.ActivePane()
281+ if activeNode == nil {
282+ return nil, fmt.Errorf("no active pane")
283+ }
284+
285+ // Try structural navigation first
286+ targetNode := uc.findAdjacentPane(activeNode, direction)
287+
288+ if targetNode == nil {
289+ log.Debug().Msg("no adjacent pane found")
290+ return nil, nil
291+ }
292+
293+ ws.ActivePaneID = targetNode.Pane.ID
294+
295+ log.Info().
296+ Str("from", activeNode.ID).
297+ Str("to", targetNode.ID).
298+ Str("direction", string(direction)).
299+ Msg("focus navigated")
300+
301+ return targetNode, nil
302+}
303+
304+// findAdjacentPane finds the adjacent pane in the given direction using tree structure.
305+func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
306+ if node == nil {
307+ return nil
308+ }
309+
310+ // Determine if we need to go horizontal or vertical
311+ isHorizontal := direction == NavLeft || direction == NavRight
312+ isForward := direction == NavRight || direction == NavDown
313+
314+ // Walk up the tree to find an ancestor with the right split type
315+ current := node
316+ for current.Parent != nil {
317+ parent := current.Parent
318+
319+ // Check if this parent's split orientation matches our direction
320+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
321+
322+ if parentIsHorizontal == isHorizontal {
323+ // Find which child we came from
324+ childIndex := -1
325+ for i, child := range parent.Children {
326+ if child == current {
327+ childIndex = i
328+ break
329+ }
330+ }
331+
332+ // Check if we can move in the desired direction
333+ targetIndex := childIndex
334+ if isForward {
335+ targetIndex = childIndex + 1
336+ } else {
337+ targetIndex = childIndex - 1
338+ }
339+
340+ if targetIndex >= 0 && targetIndex < len(parent.Children) {
341+ // Found a valid sibling, descend into it to find a leaf
342+ return uc.findLeafInDirection(parent.Children[targetIndex], !isForward)
343+ }
344+ }
345+
346+ current = parent
347+ }
348+
349+ return nil
350+}
351+
352+// findLeafInDirection descends into a node to find a leaf pane.
353+// If fromEnd is true, prefers the last child; otherwise prefers the first.
354+func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd bool) *entity.PaneNode {
355+ if node == nil {
356+ return nil
357+ }
358+
359+ // If this is a leaf, return it
360+ if node.IsLeaf() {
361+ return node
362+ }
363+
364+ // If stacked, return active pane
365+ if node.IsStacked && len(node.Children) > 0 {
366+ activeIdx := node.ActiveStackIndex
367+ if activeIdx >= 0 && activeIdx < len(node.Children) {
368+ return uc.findLeafInDirection(node.Children[activeIdx], fromEnd)
369+ }
370+ return uc.findLeafInDirection(node.Children[0], fromEnd)
371+ }
372+
373+ // Descend into children
374+ if len(node.Children) > 0 {
375+ if fromEnd {
376+ return uc.findLeafInDirection(node.Children[len(node.Children)-1], fromEnd)
377+ }
378+ return uc.findLeafInDirection(node.Children[0], fromEnd)
379+ }
380+
381+ return nil
382+}
383+
384+// CreateStack converts a pane into a stacked container.
385+func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error {
386+ log := logging.FromContext(ctx)
387+ log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
388+
389+ if ws == nil {
390+ return fmt.Errorf("workspace is required")
391+ }
392+ if paneNode == nil {
393+ return fmt.Errorf("pane node is required")
394+ }
395+ if !paneNode.IsLeaf() {
396+ return fmt.Errorf("can only create stack from leaf pane")
397+ }
398+ if paneNode.IsStacked {
399+ return fmt.Errorf("pane is already stacked")
400+ }
401+
402+ // Convert to stacked container
403+ paneNode.IsStacked = true
404+ paneNode.ActiveStackIndex = 0
405+
406+ // The pane becomes a child of itself conceptually
407+ // Create a child node for the actual pane
408+ childNode := &entity.PaneNode{
409+ ID: paneNode.ID + "_child",
410+ Pane: paneNode.Pane,
411+ Parent: paneNode,
412+ }
413+ paneNode.Pane = nil
414+ paneNode.Children = []*entity.PaneNode{childNode}
415+
416+ log.Info().Str("stack_id", paneNode.ID).Msg("stack created")
417+ return nil
418+}
419+
420+// AddToStack adds a pane to an existing stack.
421+func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error {
422+ log := logging.FromContext(ctx)
423+ log.Debug().
424+ Str("stack_id", stackNode.ID).
425+ Str("pane_id", string(pane.ID)).
426+ Msg("adding pane to stack")
427+
428+ if ws == nil {
429+ return fmt.Errorf("workspace is required")
430+ }
431+ if stackNode == nil {
432+ return fmt.Errorf("stack node is required")
433+ }
434+ if !stackNode.IsStacked {
435+ return fmt.Errorf("node is not a stack")
436+ }
437+ if pane == nil {
438+ return fmt.Errorf("pane is required")
439+ }
440+
441+ // Create node for the new pane
442+ newNode := &entity.PaneNode{
443+ ID: string(pane.ID),
444+ Pane: pane,
445+ Parent: stackNode,
446+ }
447+
448+ // Add to stack
449+ stackNode.Children = append(stackNode.Children, newNode)
450+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
451+
452+ log.Info().
453+ Str("stack_id", stackNode.ID).
454+ Str("pane_id", string(pane.ID)).
455+ Int("stack_size", len(stackNode.Children)).
456+ Msg("pane added to stack")
457+
458+ return nil
459+}
460+
461+// NavigateStack cycles through stacked panes.
462+// direction: NavUp for previous, NavDown for next.
463+func (uc *ManagePanesUseCase) NavigateStack(ctx context.Context, stackNode *entity.PaneNode, direction NavigateDirection) (*entity.Pane, error) {
464+ log := logging.FromContext(ctx)
465+ log.Debug().
466+ Str("stack_id", stackNode.ID).
467+ Str("direction", string(direction)).
468+ Msg("navigating stack")
469+
470+ if stackNode == nil {
471+ return nil, fmt.Errorf("stack node is required")
472+ }
473+ if !stackNode.IsStacked {
474+ return nil, fmt.Errorf("node is not a stack")
475+ }
476+ if len(stackNode.Children) == 0 {
477+ return nil, fmt.Errorf("stack is empty")
478+ }
479+
480+ count := len(stackNode.Children)
481+ current := stackNode.ActiveStackIndex
482+
483+ var newIndex int
484+ switch direction {
485+ case NavUp:
486+ newIndex = (current - 1 + count) % count
487+ case NavDown:
488+ newIndex = (current + 1) % count
489+ default:
490+ return nil, fmt.Errorf("invalid stack navigation direction: %s (use up/down)", direction)
491+ }
492+
493+ stackNode.ActiveStackIndex = newIndex
494+
495+ activeChild := stackNode.Children[newIndex]
496+ var activePaneNode *entity.PaneNode
497+ if activeChild.IsLeaf() {
498+ activePaneNode = activeChild
499+ } else {
500+ // Find first leaf in the child
501+ activeChild.Walk(func(n *entity.PaneNode) bool {
502+ if n.IsLeaf() {
503+ activePaneNode = n
504+ return false
505+ }
506+ return true
507+ })
508+ }
509+
510+ if activePaneNode == nil || activePaneNode.Pane == nil {
511+ return nil, fmt.Errorf("no pane found in stack at index %d", newIndex)
512+ }
513+
514+ log.Info().
515+ Str("stack_id", stackNode.ID).
516+ Int("from_index", current).
517+ Int("to_index", newIndex).
518+ Str("pane_id", string(activePaneNode.Pane.ID)).
519+ Msg("stack navigated")
520+
521+ return activePaneNode.Pane, nil
522+}
523+
524+// RemoveFromStack removes a pane from a stack.
525+// If only one pane remains, the stack is dissolved.
526+func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
527+ log := logging.FromContext(ctx)
528+ log.Debug().
529+ Str("stack_id", stackNode.ID).
530+ Str("pane_id", string(paneID)).
531+ Msg("removing pane from stack")
532+
533+ if stackNode == nil {
534+ return fmt.Errorf("stack node is required")
535+ }
536+ if !stackNode.IsStacked {
537+ return fmt.Errorf("node is not a stack")
538+ }
539+
540+ // Find and remove the pane
541+ var removed bool
542+ newChildren := make([]*entity.PaneNode, 0, len(stackNode.Children))
543+ for _, child := range stackNode.Children {
544+ if child.Pane != nil && child.Pane.ID == paneID {
545+ removed = true
546+ continue
547+ }
548+ newChildren = append(newChildren, child)
549+ }
550+
551+ if !removed {
552+ return fmt.Errorf("pane not found in stack: %s", paneID)
553+ }
554+
555+ stackNode.Children = newChildren
556+
557+ // Adjust active index
558+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
559+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
560+ }
561+ if stackNode.ActiveStackIndex < 0 {
562+ stackNode.ActiveStackIndex = 0
563+ }
564+
565+ log.Info().
566+ Str("stack_id", stackNode.ID).
567+ Str("pane_id", string(paneID)).
568+ Int("remaining", len(stackNode.Children)).
569+ Msg("pane removed from stack")
570+
571+ return nil
572+}
573+
574+// GetAllPanes returns all leaf panes in a workspace.
575+func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
576+ if ws == nil || ws.Root == nil {
577+ return nil
578+ }
579+ return ws.AllPanes()
580+}
581+
582+// CountPanes returns the number of panes in a workspace.
583+func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
584+ if ws == nil {
585+ return 0
586+ }
587+ return ws.PaneCount()
588+}
A internal/application/usecase/manage_tabs.go +285 -0
@@ -0,0 +1,285 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// IDGenerator is a function type for generating unique IDs.
12+type IDGenerator func() string
13+
14+// ManageTabsUseCase handles tab lifecycle operations.
15+type ManageTabsUseCase struct {
16+ idGenerator IDGenerator
17+}
18+
19+// NewManageTabsUseCase creates a new tab management use case.
20+func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {
21+ return &ManageTabsUseCase{
22+ idGenerator: idGenerator,
23+ }
24+}
25+
26+// CreateTabInput contains parameters for creating a new tab.
27+type CreateTabInput struct {
28+ TabList *entity.TabList
29+ Name string // Optional custom name
30+ InitialURL string // URL to load (default: about:blank)
31+ IsPinned bool
32+}
33+
34+// CreateTabOutput contains the result of tab creation.
35+type CreateTabOutput struct {
36+ Tab *entity.Tab
37+}
38+
39+// Create creates a new tab with a workspace and initial pane.
40+func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (*CreateTabOutput, error) {
41+ log := logging.FromContext(ctx)
42+ log.Debug().
43+ Str("name", input.Name).
44+ Str("initial_url", input.InitialURL).
45+ Bool("is_pinned", input.IsPinned).
46+ Msg("creating new tab")
47+
48+ if input.TabList == nil {
49+ return nil, fmt.Errorf("tab list is required")
50+ }
51+
52+ // Generate IDs
53+ tabID := entity.TabID(uc.idGenerator())
54+ workspaceID := entity.WorkspaceID(uc.idGenerator())
55+ paneID := entity.PaneID(uc.idGenerator())
56+
57+ // Create initial pane
58+ pane := entity.NewPane(paneID)
59+ if input.InitialURL != "" {
60+ pane.URI = input.InitialURL
61+ }
62+
63+ // Create tab with workspace
64+ tab := entity.NewTab(tabID, workspaceID, pane)
65+ tab.Name = input.Name
66+ tab.IsPinned = input.IsPinned
67+
68+ // Add to list (handles position and active tab)
69+ input.TabList.Add(tab)
70+
71+ log.Info().
72+ Str("tab_id", string(tabID)).
73+ Str("workspace_id", string(workspaceID)).
74+ Str("pane_id", string(paneID)).
75+ Int("position", tab.Position).
76+ Msg("tab created")
77+
78+ return &CreateTabOutput{Tab: tab}, nil
79+}
80+
81+// Close removes a tab from the list.
82+// Returns true if this was the last tab (caller should handle app exit).
83+func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) {
84+ log := logging.FromContext(ctx)
85+ ctx = logging.WithTabID(ctx, string(tabID))
86+ log = logging.FromContext(ctx)
87+
88+ log.Debug().Msg("closing tab")
89+
90+ if tabs == nil {
91+ return false, fmt.Errorf("tab list is required")
92+ }
93+
94+ tab := tabs.Find(tabID)
95+ if tab == nil {
96+ log.Debug().Msg("tab not found")
97+ return false, nil
98+ }
99+
100+ // Check if this is the last tab
101+ if tabs.Count() == 1 {
102+ log.Info().Msg("closing last tab")
103+ tabs.Remove(tabID)
104+ return true, nil
105+ }
106+
107+ // Remove tab (TabList handles active tab switching)
108+ if !tabs.Remove(tabID) {
109+ return false, fmt.Errorf("failed to remove tab")
110+ }
111+
112+ log.Info().
113+ Str("new_active", string(tabs.ActiveTabID)).
114+ Int("remaining", tabs.Count()).
115+ Msg("tab closed")
116+
117+ return false, nil
118+}
119+
120+// Switch changes the active tab.
121+func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error {
122+ log := logging.FromContext(ctx)
123+ log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab")
124+
125+ if tabs == nil {
126+ return fmt.Errorf("tab list is required")
127+ }
128+
129+ tab := tabs.Find(tabID)
130+ if tab == nil {
131+ return fmt.Errorf("tab not found: %s", tabID)
132+ }
133+
134+ oldActive := tabs.ActiveTabID
135+ tabs.ActiveTabID = tabID
136+
137+ log.Info().
138+ Str("from", string(oldActive)).
139+ Str("to", string(tabID)).
140+ Msg("tab switched")
141+
142+ return nil
143+}
144+
145+// Move repositions a tab within the tab bar.
146+func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error {
147+ log := logging.FromContext(ctx)
148+ log.Debug().
149+ Str("tab_id", string(tabID)).
150+ Int("new_position", newPosition).
151+ Msg("moving tab")
152+
153+ if tabs == nil {
154+ return fmt.Errorf("tab list is required")
155+ }
156+
157+ if !tabs.Move(tabID, newPosition) {
158+ return fmt.Errorf("failed to move tab to position %d", newPosition)
159+ }
160+
161+ log.Info().
162+ Str("tab_id", string(tabID)).
163+ Int("position", newPosition).
164+ Msg("tab moved")
165+
166+ return nil
167+}
168+
169+// Rename changes a tab's custom name.
170+func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error {
171+ log := logging.FromContext(ctx)
172+ log.Debug().
173+ Str("tab_id", string(tabID)).
174+ Str("name", name).
175+ Msg("renaming tab")
176+
177+ if tabs == nil {
178+ return fmt.Errorf("tab list is required")
179+ }
180+
181+ tab := tabs.Find(tabID)
182+ if tab == nil {
183+ return fmt.Errorf("tab not found: %s", tabID)
184+ }
185+
186+ tab.Name = name
187+
188+ log.Info().
189+ Str("tab_id", string(tabID)).
190+ Str("name", name).
191+ Msg("tab renamed")
192+
193+ return nil
194+}
195+
196+// GetNext returns the next tab ID in the given direction.
197+// direction: 1 for next, -1 for previous.
198+func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID {
199+ if tabs == nil || tabs.Count() == 0 {
200+ return ""
201+ }
202+
203+ // Find current active tab position
204+ activeTab := tabs.ActiveTab()
205+ if activeTab == nil {
206+ // Return first tab if no active
207+ if len(tabs.Tabs) > 0 {
208+ return tabs.Tabs[0].ID
209+ }
210+ return ""
211+ }
212+
213+ currentPos := activeTab.Position
214+ newPos := currentPos + direction
215+
216+ // Wrap around
217+ if newPos < 0 {
218+ newPos = tabs.Count() - 1
219+ } else if newPos >= tabs.Count() {
220+ newPos = 0
221+ }
222+
223+ if newPos >= 0 && newPos < len(tabs.Tabs) {
224+ return tabs.Tabs[newPos].ID
225+ }
226+
227+ return tabs.ActiveTabID
228+}
229+
230+// SwitchNext switches to the next tab (wraps around).
231+func (uc *ManageTabsUseCase) SwitchNext(ctx context.Context, tabs *entity.TabList) error {
232+ nextID := uc.GetNext(tabs, 1)
233+ if nextID == "" || nextID == tabs.ActiveTabID {
234+ return nil
235+ }
236+ return uc.Switch(ctx, tabs, nextID)
237+}
238+
239+// SwitchPrevious switches to the previous tab (wraps around).
240+func (uc *ManageTabsUseCase) SwitchPrevious(ctx context.Context, tabs *entity.TabList) error {
241+ prevID := uc.GetNext(tabs, -1)
242+ if prevID == "" || prevID == tabs.ActiveTabID {
243+ return nil
244+ }
245+ return uc.Switch(ctx, tabs, prevID)
246+}
247+
248+// SwitchByIndex switches to tab at the given index (0-based).
249+func (uc *ManageTabsUseCase) SwitchByIndex(ctx context.Context, tabs *entity.TabList, index int) error {
250+ log := logging.FromContext(ctx)
251+
252+ if tabs == nil || index < 0 || index >= tabs.Count() {
253+ log.Debug().Int("index", index).Msg("invalid tab index")
254+ return nil
255+ }
256+
257+ return uc.Switch(ctx, tabs, tabs.Tabs[index].ID)
258+}
259+
260+// Pin toggles the pinned state of a tab.
261+func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error {
262+ log := logging.FromContext(ctx)
263+ log.Debug().
264+ Str("tab_id", string(tabID)).
265+ Bool("pinned", pinned).
266+ Msg("toggling tab pin state")
267+
268+ if tabs == nil {
269+ return fmt.Errorf("tab list is required")
270+ }
271+
272+ tab := tabs.Find(tabID)
273+ if tab == nil {
274+ return fmt.Errorf("tab not found: %s", tabID)
275+ }
276+
277+ tab.IsPinned = pinned
278+
279+ log.Info().
280+ Str("tab_id", string(tabID)).
281+ Bool("pinned", pinned).
282+ Msg("tab pin state changed")
283+
284+ return nil
285+}
@@ -0,0 +1,285 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// IDGenerator is a function type for generating unique IDs.
12+type IDGenerator func() string
13+
14+// ManageTabsUseCase handles tab lifecycle operations.
15+type ManageTabsUseCase struct {
16+ idGenerator IDGenerator
17+}
18+
19+// NewManageTabsUseCase creates a new tab management use case.
20+func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {
21+ return &ManageTabsUseCase{
22+ idGenerator: idGenerator,
23+ }
24+}
25+
26+// CreateTabInput contains parameters for creating a new tab.
27+type CreateTabInput struct {
28+ TabList *entity.TabList
29+ Name string // Optional custom name
30+ InitialURL string // URL to load (default: about:blank)
31+ IsPinned bool
32+}
33+
34+// CreateTabOutput contains the result of tab creation.
35+type CreateTabOutput struct {
36+ Tab *entity.Tab
37+}
38+
39+// Create creates a new tab with a workspace and initial pane.
40+func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (*CreateTabOutput, error) {
41+ log := logging.FromContext(ctx)
42+ log.Debug().
43+ Str("name", input.Name).
44+ Str("initial_url", input.InitialURL).
45+ Bool("is_pinned", input.IsPinned).
46+ Msg("creating new tab")
47+
48+ if input.TabList == nil {
49+ return nil, fmt.Errorf("tab list is required")
50+ }
51+
52+ // Generate IDs
53+ tabID := entity.TabID(uc.idGenerator())
54+ workspaceID := entity.WorkspaceID(uc.idGenerator())
55+ paneID := entity.PaneID(uc.idGenerator())
56+
57+ // Create initial pane
58+ pane := entity.NewPane(paneID)
59+ if input.InitialURL != "" {
60+ pane.URI = input.InitialURL
61+ }
62+
63+ // Create tab with workspace
64+ tab := entity.NewTab(tabID, workspaceID, pane)
65+ tab.Name = input.Name
66+ tab.IsPinned = input.IsPinned
67+
68+ // Add to list (handles position and active tab)
69+ input.TabList.Add(tab)
70+
71+ log.Info().
72+ Str("tab_id", string(tabID)).
73+ Str("workspace_id", string(workspaceID)).
74+ Str("pane_id", string(paneID)).
75+ Int("position", tab.Position).
76+ Msg("tab created")
77+
78+ return &CreateTabOutput{Tab: tab}, nil
79+}
80+
81+// Close removes a tab from the list.
82+// Returns true if this was the last tab (caller should handle app exit).
83+func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) {
84+ log := logging.FromContext(ctx)
85+ ctx = logging.WithTabID(ctx, string(tabID))
86+ log = logging.FromContext(ctx)
87+
88+ log.Debug().Msg("closing tab")
89+
90+ if tabs == nil {
91+ return false, fmt.Errorf("tab list is required")
92+ }
93+
94+ tab := tabs.Find(tabID)
95+ if tab == nil {
96+ log.Debug().Msg("tab not found")
97+ return false, nil
98+ }
99+
100+ // Check if this is the last tab
101+ if tabs.Count() == 1 {
102+ log.Info().Msg("closing last tab")
103+ tabs.Remove(tabID)
104+ return true, nil
105+ }
106+
107+ // Remove tab (TabList handles active tab switching)
108+ if !tabs.Remove(tabID) {
109+ return false, fmt.Errorf("failed to remove tab")
110+ }
111+
112+ log.Info().
113+ Str("new_active", string(tabs.ActiveTabID)).
114+ Int("remaining", tabs.Count()).
115+ Msg("tab closed")
116+
117+ return false, nil
118+}
119+
120+// Switch changes the active tab.
121+func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error {
122+ log := logging.FromContext(ctx)
123+ log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab")
124+
125+ if tabs == nil {
126+ return fmt.Errorf("tab list is required")
127+ }
128+
129+ tab := tabs.Find(tabID)
130+ if tab == nil {
131+ return fmt.Errorf("tab not found: %s", tabID)
132+ }
133+
134+ oldActive := tabs.ActiveTabID
135+ tabs.ActiveTabID = tabID
136+
137+ log.Info().
138+ Str("from", string(oldActive)).
139+ Str("to", string(tabID)).
140+ Msg("tab switched")
141+
142+ return nil
143+}
144+
145+// Move repositions a tab within the tab bar.
146+func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error {
147+ log := logging.FromContext(ctx)
148+ log.Debug().
149+ Str("tab_id", string(tabID)).
150+ Int("new_position", newPosition).
151+ Msg("moving tab")
152+
153+ if tabs == nil {
154+ return fmt.Errorf("tab list is required")
155+ }
156+
157+ if !tabs.Move(tabID, newPosition) {
158+ return fmt.Errorf("failed to move tab to position %d", newPosition)
159+ }
160+
161+ log.Info().
162+ Str("tab_id", string(tabID)).
163+ Int("position", newPosition).
164+ Msg("tab moved")
165+
166+ return nil
167+}
168+
169+// Rename changes a tab's custom name.
170+func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error {
171+ log := logging.FromContext(ctx)
172+ log.Debug().
173+ Str("tab_id", string(tabID)).
174+ Str("name", name).
175+ Msg("renaming tab")
176+
177+ if tabs == nil {
178+ return fmt.Errorf("tab list is required")
179+ }
180+
181+ tab := tabs.Find(tabID)
182+ if tab == nil {
183+ return fmt.Errorf("tab not found: %s", tabID)
184+ }
185+
186+ tab.Name = name
187+
188+ log.Info().
189+ Str("tab_id", string(tabID)).
190+ Str("name", name).
191+ Msg("tab renamed")
192+
193+ return nil
194+}
195+
196+// GetNext returns the next tab ID in the given direction.
197+// direction: 1 for next, -1 for previous.
198+func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID {
199+ if tabs == nil || tabs.Count() == 0 {
200+ return ""
201+ }
202+
203+ // Find current active tab position
204+ activeTab := tabs.ActiveTab()
205+ if activeTab == nil {
206+ // Return first tab if no active
207+ if len(tabs.Tabs) > 0 {
208+ return tabs.Tabs[0].ID
209+ }
210+ return ""
211+ }
212+
213+ currentPos := activeTab.Position
214+ newPos := currentPos + direction
215+
216+ // Wrap around
217+ if newPos < 0 {
218+ newPos = tabs.Count() - 1
219+ } else if newPos >= tabs.Count() {
220+ newPos = 0
221+ }
222+
223+ if newPos >= 0 && newPos < len(tabs.Tabs) {
224+ return tabs.Tabs[newPos].ID
225+ }
226+
227+ return tabs.ActiveTabID
228+}
229+
230+// SwitchNext switches to the next tab (wraps around).
231+func (uc *ManageTabsUseCase) SwitchNext(ctx context.Context, tabs *entity.TabList) error {
232+ nextID := uc.GetNext(tabs, 1)
233+ if nextID == "" || nextID == tabs.ActiveTabID {
234+ return nil
235+ }
236+ return uc.Switch(ctx, tabs, nextID)
237+}
238+
239+// SwitchPrevious switches to the previous tab (wraps around).
240+func (uc *ManageTabsUseCase) SwitchPrevious(ctx context.Context, tabs *entity.TabList) error {
241+ prevID := uc.GetNext(tabs, -1)
242+ if prevID == "" || prevID == tabs.ActiveTabID {
243+ return nil
244+ }
245+ return uc.Switch(ctx, tabs, prevID)
246+}
247+
248+// SwitchByIndex switches to tab at the given index (0-based).
249+func (uc *ManageTabsUseCase) SwitchByIndex(ctx context.Context, tabs *entity.TabList, index int) error {
250+ log := logging.FromContext(ctx)
251+
252+ if tabs == nil || index < 0 || index >= tabs.Count() {
253+ log.Debug().Int("index", index).Msg("invalid tab index")
254+ return nil
255+ }
256+
257+ return uc.Switch(ctx, tabs, tabs.Tabs[index].ID)
258+}
259+
260+// Pin toggles the pinned state of a tab.
261+func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error {
262+ log := logging.FromContext(ctx)
263+ log.Debug().
264+ Str("tab_id", string(tabID)).
265+ Bool("pinned", pinned).
266+ Msg("toggling tab pin state")
267+
268+ if tabs == nil {
269+ return fmt.Errorf("tab list is required")
270+ }
271+
272+ tab := tabs.Find(tabID)
273+ if tab == nil {
274+ return fmt.Errorf("tab not found: %s", tabID)
275+ }
276+
277+ tab.IsPinned = pinned
278+
279+ log.Info().
280+ Str("tab_id", string(tabID)).
281+ Bool("pinned", pinned).
282+ Msg("tab pin state changed")
283+
284+ return nil
285+}
A internal/application/usecase/manage_zoom.go +157 -0
@@ -0,0 +1,157 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// ManageZoomUseCase handles per-domain zoom level operations.
16+type ManageZoomUseCase struct {
17+ zoomRepo repository.ZoomRepository
18+}
19+
20+// NewManageZoomUseCase creates a new zoom management use case.
21+func NewManageZoomUseCase(zoomRepo repository.ZoomRepository) *ManageZoomUseCase {
22+ return &ManageZoomUseCase{
23+ zoomRepo: zoomRepo,
24+ }
25+}
26+
27+// GetZoom retrieves the zoom level for a domain.
28+// Returns the default zoom level if none is set.
29+func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
30+ log := logging.FromContext(ctx)
31+ log.Debug().Str("domain", domain).Msg("getting zoom level")
32+
33+ zoom, err := uc.zoomRepo.Get(ctx, domain)
34+ if err != nil {
35+ return nil, fmt.Errorf("failed to get zoom level: %w", err)
36+ }
37+
38+ if zoom == nil {
39+ zoom = entity.NewZoomLevel(domain, entity.ZoomDefault)
40+ log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
41+ }
42+
43+ return zoom, nil
44+}
45+
46+// SetZoom saves a zoom level for a domain.
47+func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor float64) error {
48+ log := logging.FromContext(ctx)
49+ log.Debug().Str("domain", domain).Float64("factor", factor).Msg("setting zoom level")
50+
51+ zoom := entity.NewZoomLevel(domain, factor)
52+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
53+ return fmt.Errorf("failed to set zoom level: %w", err)
54+ }
55+
56+ log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
57+ return nil
58+}
59+
60+// ResetZoom removes the custom zoom level for a domain.
61+func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error {
62+ log := logging.FromContext(ctx)
63+ log.Debug().Str("domain", domain).Msg("resetting zoom level")
64+
65+ if err := uc.zoomRepo.Delete(ctx, domain); err != nil {
66+ return fmt.Errorf("failed to reset zoom level: %w", err)
67+ }
68+
69+ log.Info().Str("domain", domain).Msg("zoom level reset to default")
70+ return nil
71+}
72+
73+// ZoomIn increases the zoom level by one step (0.1).
74+func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current float64) (*entity.ZoomLevel, error) {
75+ log := logging.FromContext(ctx)
76+
77+ zoom := entity.NewZoomLevel(domain, current)
78+ zoom.ZoomIn()
79+
80+ log.Debug().
81+ Str("domain", domain).
82+ Float64("from", current).
83+ Float64("to", zoom.ZoomFactor).
84+ Msg("zooming in")
85+
86+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
87+ return nil, fmt.Errorf("failed to save zoom level: %w", err)
88+ }
89+
90+ return zoom, nil
91+}
92+
93+// ZoomOut decreases the zoom level by one step (0.1).
94+func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current float64) (*entity.ZoomLevel, error) {
95+ log := logging.FromContext(ctx)
96+
97+ zoom := entity.NewZoomLevel(domain, current)
98+ zoom.ZoomOut()
99+
100+ log.Debug().
101+ Str("domain", domain).
102+ Float64("from", current).
103+ Float64("to", zoom.ZoomFactor).
104+ Msg("zooming out")
105+
106+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
107+ return nil, fmt.Errorf("failed to save zoom level: %w", err)
108+ }
109+
110+ return zoom, nil
111+}
112+
113+// ApplyToWebView loads the saved zoom level and applies it to a webview.
114+func (uc *ManageZoomUseCase) ApplyToWebView(ctx context.Context, webview port.WebView, domain string) error {
115+ log := logging.FromContext(ctx)
116+
117+ zoom, err := uc.GetZoom(ctx, domain)
118+ if err != nil {
119+ return err
120+ }
121+
122+ log.Debug().
123+ Str("domain", domain).
124+ Float64("factor", zoom.ZoomFactor).
125+ Msg("applying zoom to webview")
126+
127+ if err := webview.SetZoomLevel(ctx, zoom.ZoomFactor); err != nil {
128+ return fmt.Errorf("failed to set zoom level: %w", err)
129+ }
130+ return nil
131+}
132+
133+// GetAll retrieves all saved zoom levels.
134+func (uc *ManageZoomUseCase) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
135+ log := logging.FromContext(ctx)
136+ log.Debug().Msg("getting all zoom levels")
137+
138+ levels, err := uc.zoomRepo.GetAll(ctx)
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to get zoom levels: %w", err)
141+ }
142+
143+ log.Debug().Int("count", len(levels)).Msg("retrieved zoom levels")
144+ return levels, nil
145+}
146+
147+// ExtractDomain extracts the host from a URL string.
148+func ExtractDomain(rawURL string) (string, error) {
149+ u, err := url.Parse(rawURL)
150+ if err != nil {
151+ return "", fmt.Errorf("invalid URL: %w", err)
152+ }
153+ if u.Host == "" {
154+ return "", fmt.Errorf("URL has no host: %s", rawURL)
155+ }
156+ return u.Host, nil
157+}
@@ -0,0 +1,157 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/repository"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// ManageZoomUseCase handles per-domain zoom level operations.
16+type ManageZoomUseCase struct {
17+ zoomRepo repository.ZoomRepository
18+}
19+
20+// NewManageZoomUseCase creates a new zoom management use case.
21+func NewManageZoomUseCase(zoomRepo repository.ZoomRepository) *ManageZoomUseCase {
22+ return &ManageZoomUseCase{
23+ zoomRepo: zoomRepo,
24+ }
25+}
26+
27+// GetZoom retrieves the zoom level for a domain.
28+// Returns the default zoom level if none is set.
29+func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
30+ log := logging.FromContext(ctx)
31+ log.Debug().Str("domain", domain).Msg("getting zoom level")
32+
33+ zoom, err := uc.zoomRepo.Get(ctx, domain)
34+ if err != nil {
35+ return nil, fmt.Errorf("failed to get zoom level: %w", err)
36+ }
37+
38+ if zoom == nil {
39+ zoom = entity.NewZoomLevel(domain, entity.ZoomDefault)
40+ log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
41+ }
42+
43+ return zoom, nil
44+}
45+
46+// SetZoom saves a zoom level for a domain.
47+func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor float64) error {
48+ log := logging.FromContext(ctx)
49+ log.Debug().Str("domain", domain).Float64("factor", factor).Msg("setting zoom level")
50+
51+ zoom := entity.NewZoomLevel(domain, factor)
52+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
53+ return fmt.Errorf("failed to set zoom level: %w", err)
54+ }
55+
56+ log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
57+ return nil
58+}
59+
60+// ResetZoom removes the custom zoom level for a domain.
61+func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error {
62+ log := logging.FromContext(ctx)
63+ log.Debug().Str("domain", domain).Msg("resetting zoom level")
64+
65+ if err := uc.zoomRepo.Delete(ctx, domain); err != nil {
66+ return fmt.Errorf("failed to reset zoom level: %w", err)
67+ }
68+
69+ log.Info().Str("domain", domain).Msg("zoom level reset to default")
70+ return nil
71+}
72+
73+// ZoomIn increases the zoom level by one step (0.1).
74+func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current float64) (*entity.ZoomLevel, error) {
75+ log := logging.FromContext(ctx)
76+
77+ zoom := entity.NewZoomLevel(domain, current)
78+ zoom.ZoomIn()
79+
80+ log.Debug().
81+ Str("domain", domain).
82+ Float64("from", current).
83+ Float64("to", zoom.ZoomFactor).
84+ Msg("zooming in")
85+
86+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
87+ return nil, fmt.Errorf("failed to save zoom level: %w", err)
88+ }
89+
90+ return zoom, nil
91+}
92+
93+// ZoomOut decreases the zoom level by one step (0.1).
94+func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current float64) (*entity.ZoomLevel, error) {
95+ log := logging.FromContext(ctx)
96+
97+ zoom := entity.NewZoomLevel(domain, current)
98+ zoom.ZoomOut()
99+
100+ log.Debug().
101+ Str("domain", domain).
102+ Float64("from", current).
103+ Float64("to", zoom.ZoomFactor).
104+ Msg("zooming out")
105+
106+ if err := uc.zoomRepo.Set(ctx, zoom); err != nil {
107+ return nil, fmt.Errorf("failed to save zoom level: %w", err)
108+ }
109+
110+ return zoom, nil
111+}
112+
113+// ApplyToWebView loads the saved zoom level and applies it to a webview.
114+func (uc *ManageZoomUseCase) ApplyToWebView(ctx context.Context, webview port.WebView, domain string) error {
115+ log := logging.FromContext(ctx)
116+
117+ zoom, err := uc.GetZoom(ctx, domain)
118+ if err != nil {
119+ return err
120+ }
121+
122+ log.Debug().
123+ Str("domain", domain).
124+ Float64("factor", zoom.ZoomFactor).
125+ Msg("applying zoom to webview")
126+
127+ if err := webview.SetZoomLevel(ctx, zoom.ZoomFactor); err != nil {
128+ return fmt.Errorf("failed to set zoom level: %w", err)
129+ }
130+ return nil
131+}
132+
133+// GetAll retrieves all saved zoom levels.
134+func (uc *ManageZoomUseCase) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
135+ log := logging.FromContext(ctx)
136+ log.Debug().Msg("getting all zoom levels")
137+
138+ levels, err := uc.zoomRepo.GetAll(ctx)
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to get zoom levels: %w", err)
141+ }
142+
143+ log.Debug().Int("count", len(levels)).Msg("retrieved zoom levels")
144+ return levels, nil
145+}
146+
147+// ExtractDomain extracts the host from a URL string.
148+func ExtractDomain(rawURL string) (string, error) {
149+ u, err := url.Parse(rawURL)
150+ if err != nil {
151+ return "", fmt.Errorf("invalid URL: %w", err)
152+ }
153+ if u.Host == "" {
154+ return "", fmt.Errorf("URL has no host: %s", rawURL)
155+ }
156+ return u.Host, nil
157+}
A internal/application/usecase/navigate.go +186 -0
@@ -0,0 +1,186 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// NavigateUseCase handles URL navigation with history recording and zoom application.
14+type NavigateUseCase struct {
15+ historyRepo repository.HistoryRepository
16+ zoomRepo repository.ZoomRepository
17+}
18+
19+// NewNavigateUseCase creates a new navigation use case.
20+func NewNavigateUseCase(
21+ historyRepo repository.HistoryRepository,
22+ zoomRepo repository.ZoomRepository,
23+) *NavigateUseCase {
24+ return &NavigateUseCase{
25+ historyRepo: historyRepo,
26+ zoomRepo: zoomRepo,
27+ }
28+}
29+
30+// NavigateInput contains parameters for navigation.
31+type NavigateInput struct {
32+ URL string
33+ PaneID string
34+ WebView port.WebView
35+}
36+
37+// NavigateOutput contains the result of navigation.
38+type NavigateOutput struct {
39+ AppliedZoom float64
40+}
41+
42+// Execute navigates to a URL, applies zoom, and records history.
43+func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
44+ log := logging.FromContext(ctx)
45+ log.Debug().
46+ Str("url", input.URL).
47+ Str("pane_id", input.PaneID).
48+ Msg("navigating to URL")
49+
50+ // Extract domain for zoom lookup
51+ domain, err := ExtractDomain(input.URL)
52+ if err != nil {
53+ log.Debug().Str("url", input.URL).Msg("could not extract domain, using default zoom")
54+ domain = ""
55+ }
56+
57+ // Load and apply zoom level before navigation (for DOM zoom mode)
58+ var zoomFactor float64 = entity.ZoomDefault
59+ if domain != "" {
60+ zoom, err := uc.zoomRepo.Get(ctx, domain)
61+ if err != nil {
62+ log.Warn().Err(err).Str("domain", domain).Msg("failed to get zoom level")
63+ } else if zoom != nil {
64+ zoomFactor = zoom.ZoomFactor
65+ }
66+ }
67+
68+ // Apply zoom before loading (important for DOM zoom)
69+ if err := input.WebView.SetZoomLevel(ctx, zoomFactor); err != nil {
70+ log.Warn().Err(err).Float64("zoom", zoomFactor).Msg("failed to set zoom level")
71+ }
72+
73+ // Navigate to URL
74+ if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
75+ return nil, fmt.Errorf("failed to load URL: %w", err)
76+ }
77+
78+ // Record in history asynchronously
79+ go uc.recordHistory(ctx, input.URL)
80+
81+ log.Info().
82+ Str("url", input.URL).
83+ Float64("zoom", zoomFactor).
84+ Msg("navigation initiated")
85+
86+ return &NavigateOutput{
87+ AppliedZoom: zoomFactor,
88+ }, nil
89+}
90+
91+// recordHistory saves or updates the history entry.
92+func (uc *NavigateUseCase) recordHistory(ctx context.Context, url string) {
93+ log := logging.FromContext(ctx)
94+
95+ // Check if entry exists
96+ existing, err := uc.historyRepo.FindByURL(ctx, url)
97+ if err != nil {
98+ log.Warn().Err(err).Str("url", url).Msg("failed to check history")
99+ return
100+ }
101+
102+ if existing != nil {
103+ // Increment visit count
104+ if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
105+ log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
106+ }
107+ } else {
108+ // Create new entry
109+ entry := entity.NewHistoryEntry(url, "")
110+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
111+ log.Warn().Err(err).Str("url", url).Msg("failed to save history")
112+ }
113+ }
114+}
115+
116+// UpdateHistoryTitle updates the title of a history entry after page load.
117+func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
118+ log := logging.FromContext(ctx)
119+ log.Debug().Str("url", url).Str("title", title).Msg("updating history title")
120+
121+ entry, err := uc.historyRepo.FindByURL(ctx, url)
122+ if err != nil {
123+ return fmt.Errorf("failed to find history entry: %w", err)
124+ }
125+
126+ if entry == nil {
127+ // Entry doesn't exist, create it with title
128+ entry = entity.NewHistoryEntry(url, title)
129+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
130+ return fmt.Errorf("failed to save history: %w", err)
131+ }
132+ return nil
133+ }
134+
135+ // Update existing entry's title
136+ entry.Title = title
137+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
138+ return fmt.Errorf("failed to update history title: %w", err)
139+ }
140+
141+ return nil
142+}
143+
144+// Reload reloads the current page.
145+func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
146+ log := logging.FromContext(ctx)
147+ log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
148+
149+ if bypassCache {
150+ return webview.ReloadBypassCache(ctx)
151+ }
152+ return webview.Reload(ctx)
153+}
154+
155+// GoBack navigates back in history.
156+func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
157+ log := logging.FromContext(ctx)
158+
159+ if !webview.CanGoBack() {
160+ log.Debug().Msg("cannot go back - no history")
161+ return nil
162+ }
163+
164+ log.Debug().Msg("navigating back")
165+ return webview.GoBack(ctx)
166+}
167+
168+// GoForward navigates forward in history.
169+func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
170+ log := logging.FromContext(ctx)
171+
172+ if !webview.CanGoForward() {
173+ log.Debug().Msg("cannot go forward - no history")
174+ return nil
175+ }
176+
177+ log.Debug().Msg("navigating forward")
178+ return webview.GoForward(ctx)
179+}
180+
181+// Stop stops the current page load.
182+func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
183+ log := logging.FromContext(ctx)
184+ log.Debug().Msg("stopping page load")
185+ return webview.Stop(ctx)
186+}
@@ -0,0 +1,186 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// NavigateUseCase handles URL navigation with history recording and zoom application.
14+type NavigateUseCase struct {
15+ historyRepo repository.HistoryRepository
16+ zoomRepo repository.ZoomRepository
17+}
18+
19+// NewNavigateUseCase creates a new navigation use case.
20+func NewNavigateUseCase(
21+ historyRepo repository.HistoryRepository,
22+ zoomRepo repository.ZoomRepository,
23+) *NavigateUseCase {
24+ return &NavigateUseCase{
25+ historyRepo: historyRepo,
26+ zoomRepo: zoomRepo,
27+ }
28+}
29+
30+// NavigateInput contains parameters for navigation.
31+type NavigateInput struct {
32+ URL string
33+ PaneID string
34+ WebView port.WebView
35+}
36+
37+// NavigateOutput contains the result of navigation.
38+type NavigateOutput struct {
39+ AppliedZoom float64
40+}
41+
42+// Execute navigates to a URL, applies zoom, and records history.
43+func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
44+ log := logging.FromContext(ctx)
45+ log.Debug().
46+ Str("url", input.URL).
47+ Str("pane_id", input.PaneID).
48+ Msg("navigating to URL")
49+
50+ // Extract domain for zoom lookup
51+ domain, err := ExtractDomain(input.URL)
52+ if err != nil {
53+ log.Debug().Str("url", input.URL).Msg("could not extract domain, using default zoom")
54+ domain = ""
55+ }
56+
57+ // Load and apply zoom level before navigation (for DOM zoom mode)
58+ var zoomFactor float64 = entity.ZoomDefault
59+ if domain != "" {
60+ zoom, err := uc.zoomRepo.Get(ctx, domain)
61+ if err != nil {
62+ log.Warn().Err(err).Str("domain", domain).Msg("failed to get zoom level")
63+ } else if zoom != nil {
64+ zoomFactor = zoom.ZoomFactor
65+ }
66+ }
67+
68+ // Apply zoom before loading (important for DOM zoom)
69+ if err := input.WebView.SetZoomLevel(ctx, zoomFactor); err != nil {
70+ log.Warn().Err(err).Float64("zoom", zoomFactor).Msg("failed to set zoom level")
71+ }
72+
73+ // Navigate to URL
74+ if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
75+ return nil, fmt.Errorf("failed to load URL: %w", err)
76+ }
77+
78+ // Record in history asynchronously
79+ go uc.recordHistory(ctx, input.URL)
80+
81+ log.Info().
82+ Str("url", input.URL).
83+ Float64("zoom", zoomFactor).
84+ Msg("navigation initiated")
85+
86+ return &NavigateOutput{
87+ AppliedZoom: zoomFactor,
88+ }, nil
89+}
90+
91+// recordHistory saves or updates the history entry.
92+func (uc *NavigateUseCase) recordHistory(ctx context.Context, url string) {
93+ log := logging.FromContext(ctx)
94+
95+ // Check if entry exists
96+ existing, err := uc.historyRepo.FindByURL(ctx, url)
97+ if err != nil {
98+ log.Warn().Err(err).Str("url", url).Msg("failed to check history")
99+ return
100+ }
101+
102+ if existing != nil {
103+ // Increment visit count
104+ if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
105+ log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
106+ }
107+ } else {
108+ // Create new entry
109+ entry := entity.NewHistoryEntry(url, "")
110+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
111+ log.Warn().Err(err).Str("url", url).Msg("failed to save history")
112+ }
113+ }
114+}
115+
116+// UpdateHistoryTitle updates the title of a history entry after page load.
117+func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
118+ log := logging.FromContext(ctx)
119+ log.Debug().Str("url", url).Str("title", title).Msg("updating history title")
120+
121+ entry, err := uc.historyRepo.FindByURL(ctx, url)
122+ if err != nil {
123+ return fmt.Errorf("failed to find history entry: %w", err)
124+ }
125+
126+ if entry == nil {
127+ // Entry doesn't exist, create it with title
128+ entry = entity.NewHistoryEntry(url, title)
129+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
130+ return fmt.Errorf("failed to save history: %w", err)
131+ }
132+ return nil
133+ }
134+
135+ // Update existing entry's title
136+ entry.Title = title
137+ if err := uc.historyRepo.Save(ctx, entry); err != nil {
138+ return fmt.Errorf("failed to update history title: %w", err)
139+ }
140+
141+ return nil
142+}
143+
144+// Reload reloads the current page.
145+func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
146+ log := logging.FromContext(ctx)
147+ log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
148+
149+ if bypassCache {
150+ return webview.ReloadBypassCache(ctx)
151+ }
152+ return webview.Reload(ctx)
153+}
154+
155+// GoBack navigates back in history.
156+func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
157+ log := logging.FromContext(ctx)
158+
159+ if !webview.CanGoBack() {
160+ log.Debug().Msg("cannot go back - no history")
161+ return nil
162+ }
163+
164+ log.Debug().Msg("navigating back")
165+ return webview.GoBack(ctx)
166+}
167+
168+// GoForward navigates forward in history.
169+func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
170+ log := logging.FromContext(ctx)
171+
172+ if !webview.CanGoForward() {
173+ log.Debug().Msg("cannot go forward - no history")
174+ return nil
175+ }
176+
177+ log.Debug().Msg("navigating forward")
178+ return webview.GoForward(ctx)
179+}
180+
181+// Stop stops the current page load.
182+func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
183+ log := logging.FromContext(ctx)
184+ log.Debug().Msg("stopping page load")
185+ return webview.Stop(ctx)
186+}
A internal/application/usecase/search_history.go +141 -0
@@ -0,0 +1,141 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// SearchHistoryUseCase handles history search and retrieval operations.
14+type SearchHistoryUseCase struct {
15+ historyRepo repository.HistoryRepository
16+}
17+
18+// NewSearchHistoryUseCase creates a new history search use case.
19+func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHistoryUseCase {
20+ return &SearchHistoryUseCase{
21+ historyRepo: historyRepo,
22+ }
23+}
24+
25+// SearchInput contains search parameters.
26+type SearchInput struct {
27+ Query string
28+ Limit int
29+}
30+
31+// SearchOutput contains search results.
32+type SearchOutput struct {
33+ Matches []entity.HistoryMatch
34+}
35+
36+// Search performs a fuzzy search on history entries.
37+func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
38+ log := logging.FromContext(ctx)
39+ log.Debug().
40+ Str("query", input.Query).
41+ Int("limit", input.Limit).
42+ Msg("searching history")
43+
44+ if input.Query == "" {
45+ return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
46+ }
47+
48+ limit := input.Limit
49+ if limit <= 0 {
50+ limit = 20 // Default limit
51+ }
52+
53+ matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to search history: %w", err)
56+ }
57+
58+ log.Debug().
59+ Str("query", input.Query).
60+ Int("results", len(matches)).
61+ Msg("history search completed")
62+
63+ return &SearchOutput{Matches: matches}, nil
64+}
65+
66+// GetRecent retrieves recent history entries with pagination.
67+func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
68+ log := logging.FromContext(ctx)
69+ log.Debug().
70+ Int("limit", limit).
71+ Int("offset", offset).
72+ Msg("getting recent history")
73+
74+ if limit <= 0 {
75+ limit = 50 // Default limit
76+ }
77+
78+ entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
79+ if err != nil {
80+ return nil, fmt.Errorf("failed to get recent history: %w", err)
81+ }
82+
83+ log.Debug().Int("count", len(entries)).Msg("retrieved recent history")
84+ return entries, nil
85+}
86+
87+// FindByURL retrieves a history entry by its URL.
88+func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
89+ log := logging.FromContext(ctx)
90+ log.Debug().Str("url", url).Msg("finding history entry by URL")
91+
92+ entry, err := uc.historyRepo.FindByURL(ctx, url)
93+ if err != nil {
94+ return nil, fmt.Errorf("failed to find history entry: %w", err)
95+ }
96+
97+ if entry == nil {
98+ log.Debug().Str("url", url).Msg("history entry not found")
99+ }
100+
101+ return entry, nil
102+}
103+
104+// ClearOlderThan deletes history entries older than the specified time.
105+func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
106+ log := logging.FromContext(ctx)
107+ log.Debug().Time("before", before).Msg("clearing old history")
108+
109+ if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
110+ return fmt.Errorf("failed to clear history: %w", err)
111+ }
112+
113+ log.Info().Time("before", before).Msg("old history cleared")
114+ return nil
115+}
116+
117+// ClearAll deletes all history entries.
118+func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
119+ log := logging.FromContext(ctx)
120+ log.Debug().Msg("clearing all history")
121+
122+ if err := uc.historyRepo.DeleteAll(ctx); err != nil {
123+ return fmt.Errorf("failed to clear all history: %w", err)
124+ }
125+
126+ log.Info().Msg("all history cleared")
127+ return nil
128+}
129+
130+// Delete removes a single history entry by ID.
131+func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
132+ log := logging.FromContext(ctx)
133+ log.Debug().Int64("id", id).Msg("deleting history entry")
134+
135+ if err := uc.historyRepo.Delete(ctx, id); err != nil {
136+ return fmt.Errorf("failed to delete history entry: %w", err)
137+ }
138+
139+ log.Debug().Int64("id", id).Msg("history entry deleted")
140+ return nil
141+}
@@ -0,0 +1,141 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// SearchHistoryUseCase handles history search and retrieval operations.
14+type SearchHistoryUseCase struct {
15+ historyRepo repository.HistoryRepository
16+}
17+
18+// NewSearchHistoryUseCase creates a new history search use case.
19+func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHistoryUseCase {
20+ return &SearchHistoryUseCase{
21+ historyRepo: historyRepo,
22+ }
23+}
24+
25+// SearchInput contains search parameters.
26+type SearchInput struct {
27+ Query string
28+ Limit int
29+}
30+
31+// SearchOutput contains search results.
32+type SearchOutput struct {
33+ Matches []entity.HistoryMatch
34+}
35+
36+// Search performs a fuzzy search on history entries.
37+func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
38+ log := logging.FromContext(ctx)
39+ log.Debug().
40+ Str("query", input.Query).
41+ Int("limit", input.Limit).
42+ Msg("searching history")
43+
44+ if input.Query == "" {
45+ return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
46+ }
47+
48+ limit := input.Limit
49+ if limit <= 0 {
50+ limit = 20 // Default limit
51+ }
52+
53+ matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to search history: %w", err)
56+ }
57+
58+ log.Debug().
59+ Str("query", input.Query).
60+ Int("results", len(matches)).
61+ Msg("history search completed")
62+
63+ return &SearchOutput{Matches: matches}, nil
64+}
65+
66+// GetRecent retrieves recent history entries with pagination.
67+func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
68+ log := logging.FromContext(ctx)
69+ log.Debug().
70+ Int("limit", limit).
71+ Int("offset", offset).
72+ Msg("getting recent history")
73+
74+ if limit <= 0 {
75+ limit = 50 // Default limit
76+ }
77+
78+ entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
79+ if err != nil {
80+ return nil, fmt.Errorf("failed to get recent history: %w", err)
81+ }
82+
83+ log.Debug().Int("count", len(entries)).Msg("retrieved recent history")
84+ return entries, nil
85+}
86+
87+// FindByURL retrieves a history entry by its URL.
88+func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
89+ log := logging.FromContext(ctx)
90+ log.Debug().Str("url", url).Msg("finding history entry by URL")
91+
92+ entry, err := uc.historyRepo.FindByURL(ctx, url)
93+ if err != nil {
94+ return nil, fmt.Errorf("failed to find history entry: %w", err)
95+ }
96+
97+ if entry == nil {
98+ log.Debug().Str("url", url).Msg("history entry not found")
99+ }
100+
101+ return entry, nil
102+}
103+
104+// ClearOlderThan deletes history entries older than the specified time.
105+func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
106+ log := logging.FromContext(ctx)
107+ log.Debug().Time("before", before).Msg("clearing old history")
108+
109+ if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
110+ return fmt.Errorf("failed to clear history: %w", err)
111+ }
112+
113+ log.Info().Time("before", before).Msg("old history cleared")
114+ return nil
115+}
116+
117+// ClearAll deletes all history entries.
118+func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
119+ log := logging.FromContext(ctx)
120+ log.Debug().Msg("clearing all history")
121+
122+ if err := uc.historyRepo.DeleteAll(ctx); err != nil {
123+ return fmt.Errorf("failed to clear all history: %w", err)
124+ }
125+
126+ log.Info().Msg("all history cleared")
127+ return nil
128+}
129+
130+// Delete removes a single history entry by ID.
131+func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
132+ log := logging.FromContext(ctx)
133+ log.Debug().Int64("id", id).Msg("deleting history entry")
134+
135+ if err := uc.historyRepo.Delete(ctx, id); err != nil {
136+ return fmt.Errorf("failed to delete history entry: %w", err)
137+ }
138+
139+ log.Debug().Int64("id", id).Msg("history entry deleted")
140+ return nil
141+}
A internal/ui/app.go +192 -0
@@ -0,0 +1,192 @@
1+package ui
2+
3+import (
4+ "context"
5+ "os"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/window"
12+ "github.com/jwijenbergh/puregotk/v4/gio"
13+ "github.com/jwijenbergh/puregotk/v4/gtk"
14+ "github.com/rs/zerolog"
15+)
16+
17+const (
18+ // AppID is the application identifier for GTK.
19+ AppID = "com.github.bnema.dumber"
20+)
21+
22+// App wraps the GTK Application and manages the browser lifecycle.
23+type App struct {
24+ deps *Dependencies
25+ gtkApp *gtk.Application
26+ mainWindow *window.MainWindow
27+
28+ // State
29+ tabs *entity.TabList
30+ tabsUC *usecase.ManageTabsUseCase
31+
32+ // ID generator for tabs/panes
33+ idCounter uint64
34+ idMu sync.Mutex
35+
36+ // Lifecycle
37+ ctx context.Context
38+ cancel context.CancelFunc
39+ logger *zerolog.Logger
40+}
41+
42+// New creates a new App with the given dependencies.
43+func New(deps *Dependencies) (*App, error) {
44+ if err := deps.Validate(); err != nil {
45+ return nil, err
46+ }
47+
48+ ctx, cancel := context.WithCancel(deps.Ctx)
49+ logger := logging.FromContext(ctx)
50+
51+ app := &App{
52+ deps: deps,
53+ tabs: entity.NewTabList(),
54+ tabsUC: deps.TabsUC,
55+ ctx: ctx,
56+ cancel: cancel,
57+ logger: logger,
58+ }
59+
60+ return app, nil
61+}
62+
63+// Run starts the GTK application and blocks until it exits.
64+// Returns the exit code.
65+func (a *App) Run(args []string) int {
66+ a.logger.Debug().Msg("creating GTK application")
67+
68+ a.gtkApp = gtk.NewApplication(AppID, gio.GApplicationFlagsNoneValue)
69+ if a.gtkApp == nil {
70+ a.logger.Error().Msg("failed to create GTK application")
71+ return 1
72+ }
73+ defer a.gtkApp.Unref()
74+
75+ // Connect activate signal
76+ activateCb := func(_ gio.Application) {
77+ a.onActivate()
78+ }
79+ a.gtkApp.ConnectActivate(&activateCb)
80+
81+ // Connect shutdown signal
82+ shutdownCb := func(_ gio.Application) {
83+ a.onShutdown()
84+ }
85+ a.gtkApp.ConnectShutdown(&shutdownCb)
86+
87+ a.logger.Info().Msg("starting GTK main loop")
88+ code := a.gtkApp.Run(len(args), args)
89+
90+ return code
91+}
92+
93+// onActivate is called when the GTK application is activated.
94+func (a *App) onActivate() {
95+ a.logger.Debug().Msg("GTK application activated")
96+
97+ // Create the main window
98+ var err error
99+ a.mainWindow, err = window.New(a.gtkApp, a.deps.Config, a.logger)
100+ if err != nil {
101+ a.logger.Error().Err(err).Msg("failed to create main window")
102+ return
103+ }
104+
105+ // Create an initial tab
106+ a.createInitialTab()
107+
108+ // Show the window
109+ a.mainWindow.Show()
110+
111+ a.logger.Info().Msg("main window displayed")
112+}
113+
114+// onShutdown is called when the GTK application is shutting down.
115+func (a *App) onShutdown() {
116+ a.logger.Debug().Msg("GTK application shutting down")
117+
118+ // Cancel context to signal all goroutines
119+ a.cancel()
120+
121+ // Cleanup resources
122+ if a.deps.Pool != nil {
123+ a.deps.Pool.Close()
124+ }
125+
126+ a.logger.Info().Msg("application shutdown complete")
127+}
128+
129+// createInitialTab creates the first tab when the application starts.
130+func (a *App) createInitialTab() {
131+ if a.tabsUC == nil {
132+ // Create use case if not injected
133+ a.tabsUC = usecase.NewManageTabsUseCase(a.generateID)
134+ }
135+
136+ output, err := a.tabsUC.Create(a.ctx, usecase.CreateTabInput{
137+ TabList: a.tabs,
138+ Name: "",
139+ InitialURL: "about:blank",
140+ })
141+ if err != nil {
142+ a.logger.Error().Err(err).Msg("failed to create initial tab")
143+ return
144+ }
145+
146+ // Update tab bar
147+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
148+ a.mainWindow.TabBar().AddTab(output.Tab)
149+ a.mainWindow.TabBar().SetActive(output.Tab.ID)
150+ }
151+
152+ a.logger.Debug().
153+ Str("tab_id", string(output.Tab.ID)).
154+ Msg("initial tab created")
155+}
156+
157+// generateID generates a unique ID for tabs and panes.
158+func (a *App) generateID() string {
159+ a.idMu.Lock()
160+ defer a.idMu.Unlock()
161+ a.idCounter++
162+ return string(rune('a'+a.idCounter%26)) + string(rune('0'+a.idCounter/26))
163+}
164+
165+// Tabs returns the tab list.
166+func (a *App) Tabs() *entity.TabList {
167+ return a.tabs
168+}
169+
170+// MainWindow returns the main window.
171+func (a *App) MainWindow() *window.MainWindow {
172+ return a.mainWindow
173+}
174+
175+// Quit requests the application to quit.
176+func (a *App) Quit() {
177+ if a.gtkApp != nil {
178+ a.gtkApp.Quit()
179+ }
180+}
181+
182+// RunWithArgs is a convenience function that creates and runs an App.
183+func RunWithArgs(deps *Dependencies) int {
184+ app, err := New(deps)
185+ if err != nil {
186+ if deps.Logger != nil {
187+ deps.Logger.Error().Err(err).Msg("failed to create application")
188+ }
189+ return 1
190+ }
191+ return app.Run(os.Args)
192+}
@@ -0,0 +1,192 @@
1+package ui
2+
3+import (
4+ "context"
5+ "os"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/window"
12+ "github.com/jwijenbergh/puregotk/v4/gio"
13+ "github.com/jwijenbergh/puregotk/v4/gtk"
14+ "github.com/rs/zerolog"
15+)
16+
17+const (
18+ // AppID is the application identifier for GTK.
19+ AppID = "com.github.bnema.dumber"
20+)
21+
22+// App wraps the GTK Application and manages the browser lifecycle.
23+type App struct {
24+ deps *Dependencies
25+ gtkApp *gtk.Application
26+ mainWindow *window.MainWindow
27+
28+ // State
29+ tabs *entity.TabList
30+ tabsUC *usecase.ManageTabsUseCase
31+
32+ // ID generator for tabs/panes
33+ idCounter uint64
34+ idMu sync.Mutex
35+
36+ // Lifecycle
37+ ctx context.Context
38+ cancel context.CancelFunc
39+ logger *zerolog.Logger
40+}
41+
42+// New creates a new App with the given dependencies.
43+func New(deps *Dependencies) (*App, error) {
44+ if err := deps.Validate(); err != nil {
45+ return nil, err
46+ }
47+
48+ ctx, cancel := context.WithCancel(deps.Ctx)
49+ logger := logging.FromContext(ctx)
50+
51+ app := &App{
52+ deps: deps,
53+ tabs: entity.NewTabList(),
54+ tabsUC: deps.TabsUC,
55+ ctx: ctx,
56+ cancel: cancel,
57+ logger: logger,
58+ }
59+
60+ return app, nil
61+}
62+
63+// Run starts the GTK application and blocks until it exits.
64+// Returns the exit code.
65+func (a *App) Run(args []string) int {
66+ a.logger.Debug().Msg("creating GTK application")
67+
68+ a.gtkApp = gtk.NewApplication(AppID, gio.GApplicationFlagsNoneValue)
69+ if a.gtkApp == nil {
70+ a.logger.Error().Msg("failed to create GTK application")
71+ return 1
72+ }
73+ defer a.gtkApp.Unref()
74+
75+ // Connect activate signal
76+ activateCb := func(_ gio.Application) {
77+ a.onActivate()
78+ }
79+ a.gtkApp.ConnectActivate(&activateCb)
80+
81+ // Connect shutdown signal
82+ shutdownCb := func(_ gio.Application) {
83+ a.onShutdown()
84+ }
85+ a.gtkApp.ConnectShutdown(&shutdownCb)
86+
87+ a.logger.Info().Msg("starting GTK main loop")
88+ code := a.gtkApp.Run(len(args), args)
89+
90+ return code
91+}
92+
93+// onActivate is called when the GTK application is activated.
94+func (a *App) onActivate() {
95+ a.logger.Debug().Msg("GTK application activated")
96+
97+ // Create the main window
98+ var err error
99+ a.mainWindow, err = window.New(a.gtkApp, a.deps.Config, a.logger)
100+ if err != nil {
101+ a.logger.Error().Err(err).Msg("failed to create main window")
102+ return
103+ }
104+
105+ // Create an initial tab
106+ a.createInitialTab()
107+
108+ // Show the window
109+ a.mainWindow.Show()
110+
111+ a.logger.Info().Msg("main window displayed")
112+}
113+
114+// onShutdown is called when the GTK application is shutting down.
115+func (a *App) onShutdown() {
116+ a.logger.Debug().Msg("GTK application shutting down")
117+
118+ // Cancel context to signal all goroutines
119+ a.cancel()
120+
121+ // Cleanup resources
122+ if a.deps.Pool != nil {
123+ a.deps.Pool.Close()
124+ }
125+
126+ a.logger.Info().Msg("application shutdown complete")
127+}
128+
129+// createInitialTab creates the first tab when the application starts.
130+func (a *App) createInitialTab() {
131+ if a.tabsUC == nil {
132+ // Create use case if not injected
133+ a.tabsUC = usecase.NewManageTabsUseCase(a.generateID)
134+ }
135+
136+ output, err := a.tabsUC.Create(a.ctx, usecase.CreateTabInput{
137+ TabList: a.tabs,
138+ Name: "",
139+ InitialURL: "about:blank",
140+ })
141+ if err != nil {
142+ a.logger.Error().Err(err).Msg("failed to create initial tab")
143+ return
144+ }
145+
146+ // Update tab bar
147+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
148+ a.mainWindow.TabBar().AddTab(output.Tab)
149+ a.mainWindow.TabBar().SetActive(output.Tab.ID)
150+ }
151+
152+ a.logger.Debug().
153+ Str("tab_id", string(output.Tab.ID)).
154+ Msg("initial tab created")
155+}
156+
157+// generateID generates a unique ID for tabs and panes.
158+func (a *App) generateID() string {
159+ a.idMu.Lock()
160+ defer a.idMu.Unlock()
161+ a.idCounter++
162+ return string(rune('a'+a.idCounter%26)) + string(rune('0'+a.idCounter/26))
163+}
164+
165+// Tabs returns the tab list.
166+func (a *App) Tabs() *entity.TabList {
167+ return a.tabs
168+}
169+
170+// MainWindow returns the main window.
171+func (a *App) MainWindow() *window.MainWindow {
172+ return a.mainWindow
173+}
174+
175+// Quit requests the application to quit.
176+func (a *App) Quit() {
177+ if a.gtkApp != nil {
178+ a.gtkApp.Quit()
179+ }
180+}
181+
182+// RunWithArgs is a convenience function that creates and runs an App.
183+func RunWithArgs(deps *Dependencies) int {
184+ app, err := New(deps)
185+ if err != nil {
186+ if deps.Logger != nil {
187+ deps.Logger.Error().Err(err).Msg("failed to create application")
188+ }
189+ return 1
190+ }
191+ return app.Run(os.Args)
192+}
A internal/ui/component/tab_bar.go +178 -0
@@ -0,0 +1,178 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+)
9+
10+// TabBar represents the horizontal tab bar widget.
11+type TabBar struct {
12+ box *gtk.Box
13+ buttons map[entity.TabID]*TabButton
14+
15+ // Active tab tracking
16+ activeTabID entity.TabID
17+
18+ // Callbacks
19+ onSwitch func(tabID entity.TabID)
20+ onClose func(tabID entity.TabID)
21+ onCreate func()
22+
23+ mu sync.RWMutex
24+}
25+
26+// NewTabBar creates a new tab bar widget.
27+func NewTabBar() *TabBar {
28+ tb := &TabBar{
29+ buttons: make(map[entity.TabID]*TabButton),
30+ }
31+
32+ // Create horizontal box container
33+ tb.box = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
34+ if tb.box == nil {
35+ return nil
36+ }
37+
38+ // Configure the box
39+ tb.box.SetHexpand(true)
40+ tb.box.SetVexpand(false)
41+ tb.box.AddCssClass("tab-bar")
42+
43+ return tb
44+}
45+
46+// Widget returns the underlying GTK widget for embedding.
47+func (tb *TabBar) Widget() *gtk.Widget {
48+ return &tb.box.Widget
49+}
50+
51+// Box returns the underlying GTK box.
52+func (tb *TabBar) Box() *gtk.Box {
53+ return tb.box
54+}
55+
56+// AddTab adds a new tab button to the bar.
57+func (tb *TabBar) AddTab(tab *entity.Tab) {
58+ tb.mu.Lock()
59+ defer tb.mu.Unlock()
60+
61+ // Check if already exists
62+ if _, exists := tb.buttons[tab.ID]; exists {
63+ return
64+ }
65+
66+ // Create the button
67+ button := NewTabButton(tab)
68+ if button == nil {
69+ return
70+ }
71+
72+ // Set click handler
73+ button.SetOnClick(func(tabID entity.TabID) {
74+ if tb.onSwitch != nil {
75+ tb.onSwitch(tabID)
76+ }
77+ })
78+
79+ // Add to container
80+ tb.box.Append(button.Widget())
81+ tb.buttons[tab.ID] = button
82+}
83+
84+// RemoveTab removes a tab button from the bar.
85+func (tb *TabBar) RemoveTab(tabID entity.TabID) {
86+ tb.mu.Lock()
87+ defer tb.mu.Unlock()
88+
89+ button, exists := tb.buttons[tabID]
90+ if !exists {
91+ return
92+ }
93+
94+ // Remove from container
95+ tb.box.Remove(button.Widget())
96+ button.Destroy()
97+ delete(tb.buttons, tabID)
98+}
99+
100+// SetActive updates which tab is shown as active.
101+func (tb *TabBar) SetActive(tabID entity.TabID) {
102+ tb.mu.Lock()
103+ defer tb.mu.Unlock()
104+
105+ // Deactivate previous
106+ if tb.activeTabID != "" && tb.activeTabID != tabID {
107+ if prevButton, exists := tb.buttons[tb.activeTabID]; exists {
108+ prevButton.SetActive(false)
109+ }
110+ }
111+
112+ // Activate new
113+ if button, exists := tb.buttons[tabID]; exists {
114+ button.SetActive(true)
115+ }
116+
117+ tb.activeTabID = tabID
118+}
119+
120+// UpdateTitle updates the title of a specific tab button.
121+func (tb *TabBar) UpdateTitle(tabID entity.TabID, title string) {
122+ tb.mu.RLock()
123+ defer tb.mu.RUnlock()
124+
125+ if button, exists := tb.buttons[tabID]; exists {
126+ button.SetTitle(title)
127+ }
128+}
129+
130+// SetOnSwitch sets the callback for tab switch events.
131+func (tb *TabBar) SetOnSwitch(fn func(tabID entity.TabID)) {
132+ tb.onSwitch = fn
133+}
134+
135+// SetOnClose sets the callback for tab close events.
136+func (tb *TabBar) SetOnClose(fn func(tabID entity.TabID)) {
137+ tb.onClose = fn
138+}
139+
140+// SetOnCreate sets the callback for new tab creation.
141+func (tb *TabBar) SetOnCreate(fn func()) {
142+ tb.onCreate = fn
143+}
144+
145+// Count returns the number of tabs in the bar.
146+func (tb *TabBar) Count() int {
147+ tb.mu.RLock()
148+ defer tb.mu.RUnlock()
149+ return len(tb.buttons)
150+}
151+
152+// ActiveTabID returns the currently active tab ID.
153+func (tb *TabBar) ActiveTabID() entity.TabID {
154+ tb.mu.RLock()
155+ defer tb.mu.RUnlock()
156+ return tb.activeTabID
157+}
158+
159+// SetVisible shows or hides the tab bar.
160+func (tb *TabBar) SetVisible(visible bool) {
161+ tb.box.SetVisible(visible)
162+}
163+
164+// Destroy cleans up all tab bar resources.
165+func (tb *TabBar) Destroy() {
166+ tb.mu.Lock()
167+ defer tb.mu.Unlock()
168+
169+ for _, button := range tb.buttons {
170+ button.Destroy()
171+ }
172+ tb.buttons = nil
173+
174+ if tb.box != nil {
175+ tb.box.Unref()
176+ tb.box = nil
177+ }
178+}
@@ -0,0 +1,178 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+)
9+
10+// TabBar represents the horizontal tab bar widget.
11+type TabBar struct {
12+ box *gtk.Box
13+ buttons map[entity.TabID]*TabButton
14+
15+ // Active tab tracking
16+ activeTabID entity.TabID
17+
18+ // Callbacks
19+ onSwitch func(tabID entity.TabID)
20+ onClose func(tabID entity.TabID)
21+ onCreate func()
22+
23+ mu sync.RWMutex
24+}
25+
26+// NewTabBar creates a new tab bar widget.
27+func NewTabBar() *TabBar {
28+ tb := &TabBar{
29+ buttons: make(map[entity.TabID]*TabButton),
30+ }
31+
32+ // Create horizontal box container
33+ tb.box = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
34+ if tb.box == nil {
35+ return nil
36+ }
37+
38+ // Configure the box
39+ tb.box.SetHexpand(true)
40+ tb.box.SetVexpand(false)
41+ tb.box.AddCssClass("tab-bar")
42+
43+ return tb
44+}
45+
46+// Widget returns the underlying GTK widget for embedding.
47+func (tb *TabBar) Widget() *gtk.Widget {
48+ return &tb.box.Widget
49+}
50+
51+// Box returns the underlying GTK box.
52+func (tb *TabBar) Box() *gtk.Box {
53+ return tb.box
54+}
55+
56+// AddTab adds a new tab button to the bar.
57+func (tb *TabBar) AddTab(tab *entity.Tab) {
58+ tb.mu.Lock()
59+ defer tb.mu.Unlock()
60+
61+ // Check if already exists
62+ if _, exists := tb.buttons[tab.ID]; exists {
63+ return
64+ }
65+
66+ // Create the button
67+ button := NewTabButton(tab)
68+ if button == nil {
69+ return
70+ }
71+
72+ // Set click handler
73+ button.SetOnClick(func(tabID entity.TabID) {
74+ if tb.onSwitch != nil {
75+ tb.onSwitch(tabID)
76+ }
77+ })
78+
79+ // Add to container
80+ tb.box.Append(button.Widget())
81+ tb.buttons[tab.ID] = button
82+}
83+
84+// RemoveTab removes a tab button from the bar.
85+func (tb *TabBar) RemoveTab(tabID entity.TabID) {
86+ tb.mu.Lock()
87+ defer tb.mu.Unlock()
88+
89+ button, exists := tb.buttons[tabID]
90+ if !exists {
91+ return
92+ }
93+
94+ // Remove from container
95+ tb.box.Remove(button.Widget())
96+ button.Destroy()
97+ delete(tb.buttons, tabID)
98+}
99+
100+// SetActive updates which tab is shown as active.
101+func (tb *TabBar) SetActive(tabID entity.TabID) {
102+ tb.mu.Lock()
103+ defer tb.mu.Unlock()
104+
105+ // Deactivate previous
106+ if tb.activeTabID != "" && tb.activeTabID != tabID {
107+ if prevButton, exists := tb.buttons[tb.activeTabID]; exists {
108+ prevButton.SetActive(false)
109+ }
110+ }
111+
112+ // Activate new
113+ if button, exists := tb.buttons[tabID]; exists {
114+ button.SetActive(true)
115+ }
116+
117+ tb.activeTabID = tabID
118+}
119+
120+// UpdateTitle updates the title of a specific tab button.
121+func (tb *TabBar) UpdateTitle(tabID entity.TabID, title string) {
122+ tb.mu.RLock()
123+ defer tb.mu.RUnlock()
124+
125+ if button, exists := tb.buttons[tabID]; exists {
126+ button.SetTitle(title)
127+ }
128+}
129+
130+// SetOnSwitch sets the callback for tab switch events.
131+func (tb *TabBar) SetOnSwitch(fn func(tabID entity.TabID)) {
132+ tb.onSwitch = fn
133+}
134+
135+// SetOnClose sets the callback for tab close events.
136+func (tb *TabBar) SetOnClose(fn func(tabID entity.TabID)) {
137+ tb.onClose = fn
138+}
139+
140+// SetOnCreate sets the callback for new tab creation.
141+func (tb *TabBar) SetOnCreate(fn func()) {
142+ tb.onCreate = fn
143+}
144+
145+// Count returns the number of tabs in the bar.
146+func (tb *TabBar) Count() int {
147+ tb.mu.RLock()
148+ defer tb.mu.RUnlock()
149+ return len(tb.buttons)
150+}
151+
152+// ActiveTabID returns the currently active tab ID.
153+func (tb *TabBar) ActiveTabID() entity.TabID {
154+ tb.mu.RLock()
155+ defer tb.mu.RUnlock()
156+ return tb.activeTabID
157+}
158+
159+// SetVisible shows or hides the tab bar.
160+func (tb *TabBar) SetVisible(visible bool) {
161+ tb.box.SetVisible(visible)
162+}
163+
164+// Destroy cleans up all tab bar resources.
165+func (tb *TabBar) Destroy() {
166+ tb.mu.Lock()
167+ defer tb.mu.Unlock()
168+
169+ for _, button := range tb.buttons {
170+ button.Destroy()
171+ }
172+ tb.buttons = nil
173+
174+ if tb.box != nil {
175+ tb.box.Unref()
176+ tb.box = nil
177+ }
178+}
A internal/ui/component/tab_button.go +125 -0
@@ -0,0 +1,125 @@
1+// Package component provides reusable GTK UI components.
2+package component
3+
4+import (
5+ "github.com/bnema/dumber/internal/domain/entity"
6+ "github.com/jwijenbergh/puregotk/v4/gtk"
7+ "github.com/jwijenbergh/puregotk/v4/pango"
8+)
9+
10+// TabButton represents a single tab button in the tab bar.
11+type TabButton struct {
12+ button *gtk.Button
13+ label *gtk.Label
14+ tabID entity.TabID
15+ isActive bool
16+
17+ // Callback for click events
18+ onClick func(tabID entity.TabID)
19+}
20+
21+// NewTabButton creates a new tab button for the given tab.
22+func NewTabButton(tab *entity.Tab) *TabButton {
23+ tb := &TabButton{
24+ tabID: tab.ID,
25+ }
26+
27+ // Create the button
28+ tb.button = gtk.NewButton()
29+ if tb.button == nil {
30+ return nil
31+ }
32+
33+ // Prevent focus stealing from WebView
34+ tb.button.SetFocusOnClick(false)
35+ tb.button.SetCanFocus(false)
36+
37+ // Add CSS class
38+ tb.button.AddCssClass("tab-button")
39+
40+ // Create the label
41+ title := tab.Title()
42+ tb.label = gtk.NewLabel(title)
43+ if tb.label == nil {
44+ tb.button.Unref()
45+ return nil
46+ }
47+
48+ // Configure label for text overflow
49+ tb.label.SetEllipsize(pango.EllipsizeMiddleValue)
50+ tb.label.SetMaxWidthChars(20)
51+ tb.label.AddCssClass("tab-title")
52+
53+ // Set label as button child
54+ tb.button.SetChild(&tb.label.Widget)
55+
56+ return tb
57+}
58+
59+// Widget returns the underlying GTK widget for embedding.
60+func (tb *TabButton) Widget() *gtk.Widget {
61+ return &tb.button.Widget
62+}
63+
64+// Button returns the underlying GTK button.
65+func (tb *TabButton) Button() *gtk.Button {
66+ return tb.button
67+}
68+
69+// TabID returns the ID of the tab this button represents.
70+func (tb *TabButton) TabID() entity.TabID {
71+ return tb.tabID
72+}
73+
74+// SetTitle updates the button's label text.
75+func (tb *TabButton) SetTitle(title string) {
76+ if tb.label != nil {
77+ tb.label.SetText(title)
78+ }
79+}
80+
81+// SetActive updates the active state styling.
82+func (tb *TabButton) SetActive(active bool) {
83+ if tb.isActive == active {
84+ return
85+ }
86+ tb.isActive = active
87+
88+ if active {
89+ tb.button.AddCssClass("tab-button-active")
90+ } else {
91+ tb.button.RemoveCssClass("tab-button-active")
92+ }
93+}
94+
95+// IsActive returns whether this tab is currently active.
96+func (tb *TabButton) IsActive() bool {
97+ return tb.isActive
98+}
99+
100+// SetOnClick sets the callback for click events.
101+func (tb *TabButton) SetOnClick(fn func(tabID entity.TabID)) {
102+ tb.onClick = fn
103+
104+ if fn != nil {
105+ tabID := tb.tabID // Capture for closure
106+ clickCb := func(_ gtk.Button) {
107+ if tb.onClick != nil {
108+ tb.onClick(tabID)
109+ }
110+ }
111+ tb.button.ConnectClicked(&clickCb)
112+ }
113+}
114+
115+// Destroy cleans up the button resources.
116+func (tb *TabButton) Destroy() {
117+ if tb.label != nil {
118+ tb.label.Unref()
119+ tb.label = nil
120+ }
121+ if tb.button != nil {
122+ tb.button.Unref()
123+ tb.button = nil
124+ }
125+}
@@ -0,0 +1,125 @@
1+// Package component provides reusable GTK UI components.
2+package component
3+
4+import (
5+ "github.com/bnema/dumber/internal/domain/entity"
6+ "github.com/jwijenbergh/puregotk/v4/gtk"
7+ "github.com/jwijenbergh/puregotk/v4/pango"
8+)
9+
10+// TabButton represents a single tab button in the tab bar.
11+type TabButton struct {
12+ button *gtk.Button
13+ label *gtk.Label
14+ tabID entity.TabID
15+ isActive bool
16+
17+ // Callback for click events
18+ onClick func(tabID entity.TabID)
19+}
20+
21+// NewTabButton creates a new tab button for the given tab.
22+func NewTabButton(tab *entity.Tab) *TabButton {
23+ tb := &TabButton{
24+ tabID: tab.ID,
25+ }
26+
27+ // Create the button
28+ tb.button = gtk.NewButton()
29+ if tb.button == nil {
30+ return nil
31+ }
32+
33+ // Prevent focus stealing from WebView
34+ tb.button.SetFocusOnClick(false)
35+ tb.button.SetCanFocus(false)
36+
37+ // Add CSS class
38+ tb.button.AddCssClass("tab-button")
39+
40+ // Create the label
41+ title := tab.Title()
42+ tb.label = gtk.NewLabel(title)
43+ if tb.label == nil {
44+ tb.button.Unref()
45+ return nil
46+ }
47+
48+ // Configure label for text overflow
49+ tb.label.SetEllipsize(pango.EllipsizeMiddleValue)
50+ tb.label.SetMaxWidthChars(20)
51+ tb.label.AddCssClass("tab-title")
52+
53+ // Set label as button child
54+ tb.button.SetChild(&tb.label.Widget)
55+
56+ return tb
57+}
58+
59+// Widget returns the underlying GTK widget for embedding.
60+func (tb *TabButton) Widget() *gtk.Widget {
61+ return &tb.button.Widget
62+}
63+
64+// Button returns the underlying GTK button.
65+func (tb *TabButton) Button() *gtk.Button {
66+ return tb.button
67+}
68+
69+// TabID returns the ID of the tab this button represents.
70+func (tb *TabButton) TabID() entity.TabID {
71+ return tb.tabID
72+}
73+
74+// SetTitle updates the button's label text.
75+func (tb *TabButton) SetTitle(title string) {
76+ if tb.label != nil {
77+ tb.label.SetText(title)
78+ }
79+}
80+
81+// SetActive updates the active state styling.
82+func (tb *TabButton) SetActive(active bool) {
83+ if tb.isActive == active {
84+ return
85+ }
86+ tb.isActive = active
87+
88+ if active {
89+ tb.button.AddCssClass("tab-button-active")
90+ } else {
91+ tb.button.RemoveCssClass("tab-button-active")
92+ }
93+}
94+
95+// IsActive returns whether this tab is currently active.
96+func (tb *TabButton) IsActive() bool {
97+ return tb.isActive
98+}
99+
100+// SetOnClick sets the callback for click events.
101+func (tb *TabButton) SetOnClick(fn func(tabID entity.TabID)) {
102+ tb.onClick = fn
103+
104+ if fn != nil {
105+ tabID := tb.tabID // Capture for closure
106+ clickCb := func(_ gtk.Button) {
107+ if tb.onClick != nil {
108+ tb.onClick(tabID)
109+ }
110+ }
111+ tb.button.ConnectClicked(&clickCb)
112+ }
113+}
114+
115+// Destroy cleans up the button resources.
116+func (tb *TabButton) Destroy() {
117+ if tb.label != nil {
118+ tb.label.Unref()
119+ tb.label = nil
120+ }
121+ if tb.button != nil {
122+ tb.button.Unref()
123+ tb.button = nil
124+ }
125+}
A internal/ui/controller/tab_controller.go +250 -0
@@ -0,0 +1,250 @@
1+// Package controller provides controllers that bridge domain state and UI widgets.
2+package controller
3+
4+import (
5+ "context"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/rs/zerolog"
13+)
14+
15+// TabController synchronizes TabList domain state with the TabBar UI widget.
16+type TabController struct {
17+ tabs *entity.TabList
18+ tabBar *component.TabBar
19+ tabsUC *usecase.ManageTabsUseCase
20+
21+ // Callback when tab switch requires content swap
22+ onTabSwitch func(tabID entity.TabID, tab *entity.Tab)
23+
24+ ctx context.Context
25+ logger *zerolog.Logger
26+ mu sync.RWMutex
27+}
28+
29+// NewTabController creates a new controller linking TabList to TabBar.
30+func NewTabController(
31+ ctx context.Context,
32+ tabs *entity.TabList,
33+ tabBar *component.TabBar,
34+ tabsUC *usecase.ManageTabsUseCase,
35+) *TabController {
36+ logger := logging.FromContext(ctx)
37+
38+ tc := &TabController{
39+ tabs: tabs,
40+ tabBar: tabBar,
41+ tabsUC: tabsUC,
42+ ctx: ctx,
43+ logger: logger,
44+ }
45+
46+ // Wire tab bar callbacks to controller methods
47+ tabBar.SetOnSwitch(tc.handleTabSwitch)
48+ tabBar.SetOnClose(tc.handleTabClose)
49+ tabBar.SetOnCreate(tc.handleTabCreate)
50+
51+ return tc
52+}
53+
54+// SetOnTabSwitch sets the callback invoked when a tab switch occurs.
55+// The callback receives the tab ID and the Tab entity for content swapping.
56+func (tc *TabController) SetOnTabSwitch(fn func(tabID entity.TabID, tab *entity.Tab)) {
57+ tc.mu.Lock()
58+ defer tc.mu.Unlock()
59+ tc.onTabSwitch = fn
60+}
61+
62+// handleTabSwitch handles tab button click events from the tab bar.
63+func (tc *TabController) handleTabSwitch(tabID entity.TabID) {
64+ tc.mu.RLock()
65+ currentActive := tc.tabs.ActiveTabID
66+ tc.mu.RUnlock()
67+
68+ if tabID == currentActive {
69+ return // Already active
70+ }
71+
72+ tc.logger.Debug().
73+ Str("from", string(currentActive)).
74+ Str("to", string(tabID)).
75+ Msg("switching tab")
76+
77+ // Use the use case to update domain state
78+ if err := tc.tabsUC.Switch(tc.ctx, tc.tabs, tabID); err != nil {
79+ tc.logger.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
80+ return
81+ }
82+
83+ // Update UI
84+ tc.tabBar.SetActive(tabID)
85+
86+ // Notify for content swap
87+ tc.mu.RLock()
88+ callback := tc.onTabSwitch
89+ tab := tc.tabs.Find(tabID)
90+ tc.mu.RUnlock()
91+
92+ if callback != nil && tab != nil {
93+ callback(tabID, tab)
94+ }
95+}
96+
97+// handleTabClose handles tab close button click events.
98+func (tc *TabController) handleTabClose(tabID entity.TabID) {
99+ tc.logger.Debug().Str("tab_id", string(tabID)).Msg("closing tab")
100+
101+ wasLast, err := tc.tabsUC.Close(tc.ctx, tc.tabs, tabID)
102+ if err != nil {
103+ tc.logger.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to close tab")
104+ return
105+ }
106+
107+ // Remove from UI
108+ tc.tabBar.RemoveTab(tabID)
109+
110+ if wasLast {
111+ tc.logger.Info().Msg("last tab closed, application should exit")
112+ // The application should handle this by monitoring tab count
113+ return
114+ }
115+
116+ // Switch to new active tab
117+ tc.mu.RLock()
118+ newActiveID := tc.tabs.ActiveTabID
119+ newActiveTab := tc.tabs.Find(newActiveID)
120+ callback := tc.onTabSwitch
121+ tc.mu.RUnlock()
122+
123+ tc.tabBar.SetActive(newActiveID)
124+
125+ if callback != nil && newActiveTab != nil {
126+ callback(newActiveID, newActiveTab)
127+ }
128+}
129+
130+// handleTabCreate handles new tab creation requests.
131+func (tc *TabController) handleTabCreate() {
132+ tc.CreateTab("", "about:blank")
133+}
134+
135+// CreateTab creates a new tab with the given name and URL.
136+func (tc *TabController) CreateTab(name, url string) *entity.Tab {
137+ tc.logger.Debug().
138+ Str("name", name).
139+ Str("url", url).
140+ Msg("creating new tab")
141+
142+ output, err := tc.tabsUC.Create(tc.ctx, usecase.CreateTabInput{
143+ TabList: tc.tabs,
144+ Name: name,
145+ InitialURL: url,
146+ })
147+ if err != nil {
148+ tc.logger.Error().Err(err).Msg("failed to create tab")
149+ return nil
150+ }
151+
152+ // Add to UI
153+ tc.tabBar.AddTab(output.Tab)
154+ tc.tabBar.SetActive(output.Tab.ID)
155+
156+ // Notify for content setup
157+ tc.mu.RLock()
158+ callback := tc.onTabSwitch
159+ tc.mu.RUnlock()
160+
161+ if callback != nil {
162+ callback(output.Tab.ID, output.Tab)
163+ }
164+
165+ return output.Tab
166+}
167+
168+// SwitchToTab switches to the specified tab by ID.
169+func (tc *TabController) SwitchToTab(tabID entity.TabID) {
170+ tc.handleTabSwitch(tabID)
171+}
172+
173+// SwitchToNextTab switches to the next tab.
174+func (tc *TabController) SwitchToNextTab() {
175+ if err := tc.tabsUC.SwitchNext(tc.ctx, tc.tabs); err != nil {
176+ tc.logger.Error().Err(err).Msg("failed to switch to next tab")
177+ return
178+ }
179+
180+ tc.mu.RLock()
181+ activeID := tc.tabs.ActiveTabID
182+ activeTab := tc.tabs.Find(activeID)
183+ callback := tc.onTabSwitch
184+ tc.mu.RUnlock()
185+
186+ tc.tabBar.SetActive(activeID)
187+
188+ if callback != nil && activeTab != nil {
189+ callback(activeID, activeTab)
190+ }
191+}
192+
193+// SwitchToPreviousTab switches to the previous tab.
194+func (tc *TabController) SwitchToPreviousTab() {
195+ if err := tc.tabsUC.SwitchPrevious(tc.ctx, tc.tabs); err != nil {
196+ tc.logger.Error().Err(err).Msg("failed to switch to previous tab")
197+ return
198+ }
199+
200+ tc.mu.RLock()
201+ activeID := tc.tabs.ActiveTabID
202+ activeTab := tc.tabs.Find(activeID)
203+ callback := tc.onTabSwitch
204+ tc.mu.RUnlock()
205+
206+ tc.tabBar.SetActive(activeID)
207+
208+ if callback != nil && activeTab != nil {
209+ callback(activeID, activeTab)
210+ }
211+}
212+
213+// SwitchToTabByIndex switches to tab at the given 0-based index.
214+func (tc *TabController) SwitchToTabByIndex(index int) {
215+ if err := tc.tabsUC.SwitchByIndex(tc.ctx, tc.tabs, index); err != nil {
216+ tc.logger.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
217+ return
218+ }
219+
220+ tc.mu.RLock()
221+ activeID := tc.tabs.ActiveTabID
222+ activeTab := tc.tabs.Find(activeID)
223+ callback := tc.onTabSwitch
224+ tc.mu.RUnlock()
225+
226+ tc.tabBar.SetActive(activeID)
227+
228+ if callback != nil && activeTab != nil {
229+ callback(activeID, activeTab)
230+ }
231+}
232+
233+// UpdateTabTitle updates the title of a tab.
234+func (tc *TabController) UpdateTabTitle(tabID entity.TabID, title string) {
235+ tc.tabBar.UpdateTitle(tabID, title)
236+}
237+
238+// TabCount returns the current number of tabs.
239+func (tc *TabController) TabCount() int {
240+ tc.mu.RLock()
241+ defer tc.mu.RUnlock()
242+ return tc.tabs.Count()
243+}
244+
245+// ActiveTab returns the currently active tab.
246+func (tc *TabController) ActiveTab() *entity.Tab {
247+ tc.mu.RLock()
248+ defer tc.mu.RUnlock()
249+ return tc.tabs.ActiveTab()
250+}
@@ -0,0 +1,250 @@
1+// Package controller provides controllers that bridge domain state and UI widgets.
2+package controller
3+
4+import (
5+ "context"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/rs/zerolog"
13+)
14+
15+// TabController synchronizes TabList domain state with the TabBar UI widget.
16+type TabController struct {
17+ tabs *entity.TabList
18+ tabBar *component.TabBar
19+ tabsUC *usecase.ManageTabsUseCase
20+
21+ // Callback when tab switch requires content swap
22+ onTabSwitch func(tabID entity.TabID, tab *entity.Tab)
23+
24+ ctx context.Context
25+ logger *zerolog.Logger
26+ mu sync.RWMutex
27+}
28+
29+// NewTabController creates a new controller linking TabList to TabBar.
30+func NewTabController(
31+ ctx context.Context,
32+ tabs *entity.TabList,
33+ tabBar *component.TabBar,
34+ tabsUC *usecase.ManageTabsUseCase,
35+) *TabController {
36+ logger := logging.FromContext(ctx)
37+
38+ tc := &TabController{
39+ tabs: tabs,
40+ tabBar: tabBar,
41+ tabsUC: tabsUC,
42+ ctx: ctx,
43+ logger: logger,
44+ }
45+
46+ // Wire tab bar callbacks to controller methods
47+ tabBar.SetOnSwitch(tc.handleTabSwitch)
48+ tabBar.SetOnClose(tc.handleTabClose)
49+ tabBar.SetOnCreate(tc.handleTabCreate)
50+
51+ return tc
52+}
53+
54+// SetOnTabSwitch sets the callback invoked when a tab switch occurs.
55+// The callback receives the tab ID and the Tab entity for content swapping.
56+func (tc *TabController) SetOnTabSwitch(fn func(tabID entity.TabID, tab *entity.Tab)) {
57+ tc.mu.Lock()
58+ defer tc.mu.Unlock()
59+ tc.onTabSwitch = fn
60+}
61+
62+// handleTabSwitch handles tab button click events from the tab bar.
63+func (tc *TabController) handleTabSwitch(tabID entity.TabID) {
64+ tc.mu.RLock()
65+ currentActive := tc.tabs.ActiveTabID
66+ tc.mu.RUnlock()
67+
68+ if tabID == currentActive {
69+ return // Already active
70+ }
71+
72+ tc.logger.Debug().
73+ Str("from", string(currentActive)).
74+ Str("to", string(tabID)).
75+ Msg("switching tab")
76+
77+ // Use the use case to update domain state
78+ if err := tc.tabsUC.Switch(tc.ctx, tc.tabs, tabID); err != nil {
79+ tc.logger.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
80+ return
81+ }
82+
83+ // Update UI
84+ tc.tabBar.SetActive(tabID)
85+
86+ // Notify for content swap
87+ tc.mu.RLock()
88+ callback := tc.onTabSwitch
89+ tab := tc.tabs.Find(tabID)
90+ tc.mu.RUnlock()
91+
92+ if callback != nil && tab != nil {
93+ callback(tabID, tab)
94+ }
95+}
96+
97+// handleTabClose handles tab close button click events.
98+func (tc *TabController) handleTabClose(tabID entity.TabID) {
99+ tc.logger.Debug().Str("tab_id", string(tabID)).Msg("closing tab")
100+
101+ wasLast, err := tc.tabsUC.Close(tc.ctx, tc.tabs, tabID)
102+ if err != nil {
103+ tc.logger.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to close tab")
104+ return
105+ }
106+
107+ // Remove from UI
108+ tc.tabBar.RemoveTab(tabID)
109+
110+ if wasLast {
111+ tc.logger.Info().Msg("last tab closed, application should exit")
112+ // The application should handle this by monitoring tab count
113+ return
114+ }
115+
116+ // Switch to new active tab
117+ tc.mu.RLock()
118+ newActiveID := tc.tabs.ActiveTabID
119+ newActiveTab := tc.tabs.Find(newActiveID)
120+ callback := tc.onTabSwitch
121+ tc.mu.RUnlock()
122+
123+ tc.tabBar.SetActive(newActiveID)
124+
125+ if callback != nil && newActiveTab != nil {
126+ callback(newActiveID, newActiveTab)
127+ }
128+}
129+
130+// handleTabCreate handles new tab creation requests.
131+func (tc *TabController) handleTabCreate() {
132+ tc.CreateTab("", "about:blank")
133+}
134+
135+// CreateTab creates a new tab with the given name and URL.
136+func (tc *TabController) CreateTab(name, url string) *entity.Tab {
137+ tc.logger.Debug().
138+ Str("name", name).
139+ Str("url", url).
140+ Msg("creating new tab")
141+
142+ output, err := tc.tabsUC.Create(tc.ctx, usecase.CreateTabInput{
143+ TabList: tc.tabs,
144+ Name: name,
145+ InitialURL: url,
146+ })
147+ if err != nil {
148+ tc.logger.Error().Err(err).Msg("failed to create tab")
149+ return nil
150+ }
151+
152+ // Add to UI
153+ tc.tabBar.AddTab(output.Tab)
154+ tc.tabBar.SetActive(output.Tab.ID)
155+
156+ // Notify for content setup
157+ tc.mu.RLock()
158+ callback := tc.onTabSwitch
159+ tc.mu.RUnlock()
160+
161+ if callback != nil {
162+ callback(output.Tab.ID, output.Tab)
163+ }
164+
165+ return output.Tab
166+}
167+
168+// SwitchToTab switches to the specified tab by ID.
169+func (tc *TabController) SwitchToTab(tabID entity.TabID) {
170+ tc.handleTabSwitch(tabID)
171+}
172+
173+// SwitchToNextTab switches to the next tab.
174+func (tc *TabController) SwitchToNextTab() {
175+ if err := tc.tabsUC.SwitchNext(tc.ctx, tc.tabs); err != nil {
176+ tc.logger.Error().Err(err).Msg("failed to switch to next tab")
177+ return
178+ }
179+
180+ tc.mu.RLock()
181+ activeID := tc.tabs.ActiveTabID
182+ activeTab := tc.tabs.Find(activeID)
183+ callback := tc.onTabSwitch
184+ tc.mu.RUnlock()
185+
186+ tc.tabBar.SetActive(activeID)
187+
188+ if callback != nil && activeTab != nil {
189+ callback(activeID, activeTab)
190+ }
191+}
192+
193+// SwitchToPreviousTab switches to the previous tab.
194+func (tc *TabController) SwitchToPreviousTab() {
195+ if err := tc.tabsUC.SwitchPrevious(tc.ctx, tc.tabs); err != nil {
196+ tc.logger.Error().Err(err).Msg("failed to switch to previous tab")
197+ return
198+ }
199+
200+ tc.mu.RLock()
201+ activeID := tc.tabs.ActiveTabID
202+ activeTab := tc.tabs.Find(activeID)
203+ callback := tc.onTabSwitch
204+ tc.mu.RUnlock()
205+
206+ tc.tabBar.SetActive(activeID)
207+
208+ if callback != nil && activeTab != nil {
209+ callback(activeID, activeTab)
210+ }
211+}
212+
213+// SwitchToTabByIndex switches to tab at the given 0-based index.
214+func (tc *TabController) SwitchToTabByIndex(index int) {
215+ if err := tc.tabsUC.SwitchByIndex(tc.ctx, tc.tabs, index); err != nil {
216+ tc.logger.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
217+ return
218+ }
219+
220+ tc.mu.RLock()
221+ activeID := tc.tabs.ActiveTabID
222+ activeTab := tc.tabs.Find(activeID)
223+ callback := tc.onTabSwitch
224+ tc.mu.RUnlock()
225+
226+ tc.tabBar.SetActive(activeID)
227+
228+ if callback != nil && activeTab != nil {
229+ callback(activeID, activeTab)
230+ }
231+}
232+
233+// UpdateTabTitle updates the title of a tab.
234+func (tc *TabController) UpdateTabTitle(tabID entity.TabID, title string) {
235+ tc.tabBar.UpdateTitle(tabID, title)
236+}
237+
238+// TabCount returns the current number of tabs.
239+func (tc *TabController) TabCount() int {
240+ tc.mu.RLock()
241+ defer tc.mu.RUnlock()
242+ return tc.tabs.Count()
243+}
244+
245+// ActiveTab returns the currently active tab.
246+func (tc *TabController) ActiveTab() *entity.Tab {
247+ tc.mu.RLock()
248+ defer tc.mu.RUnlock()
249+ return tc.tabs.ActiveTab()
250+}
A internal/ui/deps.go +72 -0
@@ -0,0 +1,72 @@
1+// Package ui provides the GTK4 presentation layer for the dumber browser.
2+package ui
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/rs/zerolog"
13+)
14+
15+// Dependencies holds all injected dependencies for the UI layer.
16+// This struct is created once at startup and passed to UI components.
17+type Dependencies struct {
18+ // Core context and configuration
19+ Ctx context.Context
20+ Config *config.Config
21+ Logger *zerolog.Logger
22+
23+ // WebKit infrastructure
24+ WebContext *webkit.WebKitContext
25+ Pool port.WebViewPool
26+ Factory port.WebViewFactory
27+ Settings *webkit.SettingsManager
28+
29+ // Repositories
30+ HistoryRepo repository.HistoryRepository
31+ FavoriteRepo repository.FavoriteRepository
32+ ZoomRepo repository.ZoomRepository
33+ FilterRepo repository.ContentWhitelistRepository
34+
35+ // Use Cases
36+ TabsUC *usecase.ManageTabsUseCase
37+ PanesUC *usecase.ManagePanesUseCase
38+ NavigateUC *usecase.NavigateUseCase
39+ ZoomUC *usecase.ManageZoomUseCase
40+ FavoritesUC *usecase.ManageFavoritesUseCase
41+ HistoryUC *usecase.SearchHistoryUseCase
42+}
43+
44+// Validate checks that all required dependencies are set.
45+func (d *Dependencies) Validate() error {
46+ if d.Ctx == nil {
47+ return ErrMissingDependency("Ctx")
48+ }
49+ if d.Config == nil {
50+ return ErrMissingDependency("Config")
51+ }
52+ if d.Logger == nil {
53+ return ErrMissingDependency("Logger")
54+ }
55+ // WebKit dependencies are optional for testing without WebViews
56+ // Use cases are optional - can be nil if not needed
57+ return nil
58+}
59+
60+// DependencyError indicates a missing required dependency.
61+type DependencyError struct {
62+ Name string
63+}
64+
65+func (e DependencyError) Error() string {
66+ return "missing required dependency: " + e.Name
67+}
68+
69+// ErrMissingDependency creates a new DependencyError.
70+func ErrMissingDependency(name string) error {
71+ return DependencyError{Name: name}
72+}
@@ -0,0 +1,72 @@
1+// Package ui provides the GTK4 presentation layer for the dumber browser.
2+package ui
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/rs/zerolog"
13+)
14+
15+// Dependencies holds all injected dependencies for the UI layer.
16+// This struct is created once at startup and passed to UI components.
17+type Dependencies struct {
18+ // Core context and configuration
19+ Ctx context.Context
20+ Config *config.Config
21+ Logger *zerolog.Logger
22+
23+ // WebKit infrastructure
24+ WebContext *webkit.WebKitContext
25+ Pool port.WebViewPool
26+ Factory port.WebViewFactory
27+ Settings *webkit.SettingsManager
28+
29+ // Repositories
30+ HistoryRepo repository.HistoryRepository
31+ FavoriteRepo repository.FavoriteRepository
32+ ZoomRepo repository.ZoomRepository
33+ FilterRepo repository.ContentWhitelistRepository
34+
35+ // Use Cases
36+ TabsUC *usecase.ManageTabsUseCase
37+ PanesUC *usecase.ManagePanesUseCase
38+ NavigateUC *usecase.NavigateUseCase
39+ ZoomUC *usecase.ManageZoomUseCase
40+ FavoritesUC *usecase.ManageFavoritesUseCase
41+ HistoryUC *usecase.SearchHistoryUseCase
42+}
43+
44+// Validate checks that all required dependencies are set.
45+func (d *Dependencies) Validate() error {
46+ if d.Ctx == nil {
47+ return ErrMissingDependency("Ctx")
48+ }
49+ if d.Config == nil {
50+ return ErrMissingDependency("Config")
51+ }
52+ if d.Logger == nil {
53+ return ErrMissingDependency("Logger")
54+ }
55+ // WebKit dependencies are optional for testing without WebViews
56+ // Use cases are optional - can be nil if not needed
57+ return nil
58+}
59+
60+// DependencyError indicates a missing required dependency.
61+type DependencyError struct {
62+ Name string
63+}
64+
65+func (e DependencyError) Error() string {
66+ return "missing required dependency: " + e.Name
67+}
68+
69+// ErrMissingDependency creates a new DependencyError.
70+func ErrMissingDependency(name string) error {
71+ return DependencyError{Name: name}
72+}
A internal/ui/window/main_window.go +179 -0
@@ -0,0 +1,179 @@
1+// Package window provides GTK window implementations.
2+package window
3+
4+import (
5+ "github.com/bnema/dumber/internal/infrastructure/config"
6+ "github.com/bnema/dumber/internal/ui/component"
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+ "github.com/rs/zerolog"
9+)
10+
11+const (
12+ defaultWidth = 1280
13+ defaultHeight = 800
14+ windowTitle = "Dumber"
15+)
16+
17+// MainWindow represents the main browser window.
18+type MainWindow struct {
19+ window *gtk.ApplicationWindow
20+ rootBox *gtk.Box // Vertical: tab bar + content
21+ tabBar *component.TabBar
22+ contentArea *gtk.Box // Container for workspace content
23+
24+ cfg *config.Config
25+ logger *zerolog.Logger
26+}
27+
28+// New creates a new main browser window.
29+func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*MainWindow, error) {
30+ mw := &MainWindow{
31+ cfg: cfg,
32+ logger: logger,
33+ }
34+
35+ // Create the application window
36+ mw.window = gtk.NewApplicationWindow(app)
37+ if mw.window == nil {
38+ return nil, ErrWindowCreationFailed
39+ }
40+
41+ // Configure window
42+ mw.window.SetTitle(windowTitle)
43+ mw.window.SetDefaultSize(defaultWidth, defaultHeight)
44+
45+ // Create root container (vertical box)
46+ mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
47+ if mw.rootBox == nil {
48+ mw.window.Unref()
49+ return nil, ErrWidgetCreationFailed("rootBox")
50+ }
51+ mw.rootBox.SetHexpand(true)
52+ mw.rootBox.SetVexpand(true)
53+
54+ // Create tab bar
55+ mw.tabBar = component.NewTabBar()
56+ if mw.tabBar == nil {
57+ mw.rootBox.Unref()
58+ mw.window.Unref()
59+ return nil, ErrWidgetCreationFailed("tabBar")
60+ }
61+
62+ // Create content area
63+ mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
64+ if mw.contentArea == nil {
65+ mw.tabBar.Destroy()
66+ mw.rootBox.Unref()
67+ mw.window.Unref()
68+ return nil, ErrWidgetCreationFailed("contentArea")
69+ }
70+ mw.contentArea.SetHexpand(true)
71+ mw.contentArea.SetVexpand(true)
72+ mw.contentArea.AddCssClass("content-area")
73+
74+ // Assemble layout based on tab bar position
75+ mw.assembleLayout()
76+
77+ // Set root box as window content
78+ mw.window.SetChild(&mw.rootBox.Widget)
79+
80+ return mw, nil
81+}
82+
83+// assembleLayout arranges widgets based on configuration.
84+func (mw *MainWindow) assembleLayout() {
85+ tabBarPos := "top" // default
86+ if mw.cfg != nil {
87+ tabBarPos = mw.cfg.Workspace.TabBarPosition
88+ }
89+
90+ if tabBarPos == "bottom" {
91+ // Content first, then tab bar
92+ mw.rootBox.Append(&mw.contentArea.Widget)
93+ mw.rootBox.Append(mw.tabBar.Widget())
94+ } else {
95+ // Tab bar first, then content (default: top)
96+ mw.rootBox.Append(mw.tabBar.Widget())
97+ mw.rootBox.Append(&mw.contentArea.Widget)
98+ }
99+
100+ mw.logger.Debug().
101+ Str("tab_bar_position", tabBarPos).
102+ Msg("window layout assembled")
103+}
104+
105+// Show makes the window visible.
106+func (mw *MainWindow) Show() {
107+ mw.window.Present()
108+}
109+
110+// Close closes the window.
111+func (mw *MainWindow) Close() {
112+ mw.window.Close()
113+}
114+
115+// TabBar returns the tab bar component.
116+func (mw *MainWindow) TabBar() *component.TabBar {
117+ return mw.tabBar
118+}
119+
120+// ContentArea returns the content area box.
121+func (mw *MainWindow) ContentArea() *gtk.Box {
122+ return mw.contentArea
123+}
124+
125+// SetContent sets the content of the content area.
126+// This removes any existing children first.
127+func (mw *MainWindow) SetContent(widget *gtk.Widget) {
128+ // Remove existing children
129+ // Note: GTK4 Box doesn't have a simple "clear" method,
130+ // so we'd need to track children. For now, just append.
131+ // TODO: Track children for proper content swapping
132+ if widget != nil {
133+ mw.contentArea.Append(widget)
134+ }
135+}
136+
137+// Window returns the underlying GTK window.
138+func (mw *MainWindow) Window() *gtk.ApplicationWindow {
139+ return mw.window
140+}
141+
142+// Destroy cleans up window resources.
143+func (mw *MainWindow) Destroy() {
144+ if mw.tabBar != nil {
145+ mw.tabBar.Destroy()
146+ mw.tabBar = nil
147+ }
148+ if mw.contentArea != nil {
149+ mw.contentArea.Unref()
150+ mw.contentArea = nil
151+ }
152+ if mw.rootBox != nil {
153+ mw.rootBox.Unref()
154+ mw.rootBox = nil
155+ }
156+ if mw.window != nil {
157+ mw.window.Destroy()
158+ mw.window = nil
159+ }
160+}
161+
162+// WindowError represents a window-related error.
163+type WindowError struct {
164+ Message string
165+}
166+
167+func (e WindowError) Error() string {
168+ return e.Message
169+}
170+
171+// Error constants.
172+var (
173+ ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
174+)
175+
176+// ErrWidgetCreationFailed creates an error for widget creation failure.
177+func ErrWidgetCreationFailed(name string) error {
178+ return WindowError{Message: "failed to create widget: " + name}
179+}
@@ -0,0 +1,179 @@
1+// Package window provides GTK window implementations.
2+package window
3+
4+import (
5+ "github.com/bnema/dumber/internal/infrastructure/config"
6+ "github.com/bnema/dumber/internal/ui/component"
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+ "github.com/rs/zerolog"
9+)
10+
11+const (
12+ defaultWidth = 1280
13+ defaultHeight = 800
14+ windowTitle = "Dumber"
15+)
16+
17+// MainWindow represents the main browser window.
18+type MainWindow struct {
19+ window *gtk.ApplicationWindow
20+ rootBox *gtk.Box // Vertical: tab bar + content
21+ tabBar *component.TabBar
22+ contentArea *gtk.Box // Container for workspace content
23+
24+ cfg *config.Config
25+ logger *zerolog.Logger
26+}
27+
28+// New creates a new main browser window.
29+func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*MainWindow, error) {
30+ mw := &MainWindow{
31+ cfg: cfg,
32+ logger: logger,
33+ }
34+
35+ // Create the application window
36+ mw.window = gtk.NewApplicationWindow(app)
37+ if mw.window == nil {
38+ return nil, ErrWindowCreationFailed
39+ }
40+
41+ // Configure window
42+ mw.window.SetTitle(windowTitle)
43+ mw.window.SetDefaultSize(defaultWidth, defaultHeight)
44+
45+ // Create root container (vertical box)
46+ mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
47+ if mw.rootBox == nil {
48+ mw.window.Unref()
49+ return nil, ErrWidgetCreationFailed("rootBox")
50+ }
51+ mw.rootBox.SetHexpand(true)
52+ mw.rootBox.SetVexpand(true)
53+
54+ // Create tab bar
55+ mw.tabBar = component.NewTabBar()
56+ if mw.tabBar == nil {
57+ mw.rootBox.Unref()
58+ mw.window.Unref()
59+ return nil, ErrWidgetCreationFailed("tabBar")
60+ }
61+
62+ // Create content area
63+ mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
64+ if mw.contentArea == nil {
65+ mw.tabBar.Destroy()
66+ mw.rootBox.Unref()
67+ mw.window.Unref()
68+ return nil, ErrWidgetCreationFailed("contentArea")
69+ }
70+ mw.contentArea.SetHexpand(true)
71+ mw.contentArea.SetVexpand(true)
72+ mw.contentArea.AddCssClass("content-area")
73+
74+ // Assemble layout based on tab bar position
75+ mw.assembleLayout()
76+
77+ // Set root box as window content
78+ mw.window.SetChild(&mw.rootBox.Widget)
79+
80+ return mw, nil
81+}
82+
83+// assembleLayout arranges widgets based on configuration.
84+func (mw *MainWindow) assembleLayout() {
85+ tabBarPos := "top" // default
86+ if mw.cfg != nil {
87+ tabBarPos = mw.cfg.Workspace.TabBarPosition
88+ }
89+
90+ if tabBarPos == "bottom" {
91+ // Content first, then tab bar
92+ mw.rootBox.Append(&mw.contentArea.Widget)
93+ mw.rootBox.Append(mw.tabBar.Widget())
94+ } else {
95+ // Tab bar first, then content (default: top)
96+ mw.rootBox.Append(mw.tabBar.Widget())
97+ mw.rootBox.Append(&mw.contentArea.Widget)
98+ }
99+
100+ mw.logger.Debug().
101+ Str("tab_bar_position", tabBarPos).
102+ Msg("window layout assembled")
103+}
104+
105+// Show makes the window visible.
106+func (mw *MainWindow) Show() {
107+ mw.window.Present()
108+}
109+
110+// Close closes the window.
111+func (mw *MainWindow) Close() {
112+ mw.window.Close()
113+}
114+
115+// TabBar returns the tab bar component.
116+func (mw *MainWindow) TabBar() *component.TabBar {
117+ return mw.tabBar
118+}
119+
120+// ContentArea returns the content area box.
121+func (mw *MainWindow) ContentArea() *gtk.Box {
122+ return mw.contentArea
123+}
124+
125+// SetContent sets the content of the content area.
126+// This removes any existing children first.
127+func (mw *MainWindow) SetContent(widget *gtk.Widget) {
128+ // Remove existing children
129+ // Note: GTK4 Box doesn't have a simple "clear" method,
130+ // so we'd need to track children. For now, just append.
131+ // TODO: Track children for proper content swapping
132+ if widget != nil {
133+ mw.contentArea.Append(widget)
134+ }
135+}
136+
137+// Window returns the underlying GTK window.
138+func (mw *MainWindow) Window() *gtk.ApplicationWindow {
139+ return mw.window
140+}
141+
142+// Destroy cleans up window resources.
143+func (mw *MainWindow) Destroy() {
144+ if mw.tabBar != nil {
145+ mw.tabBar.Destroy()
146+ mw.tabBar = nil
147+ }
148+ if mw.contentArea != nil {
149+ mw.contentArea.Unref()
150+ mw.contentArea = nil
151+ }
152+ if mw.rootBox != nil {
153+ mw.rootBox.Unref()
154+ mw.rootBox = nil
155+ }
156+ if mw.window != nil {
157+ mw.window.Destroy()
158+ mw.window = nil
159+ }
160+}
161+
162+// WindowError represents a window-related error.
163+type WindowError struct {
164+ Message string
165+}
166+
167+func (e WindowError) Error() string {
168+ return e.Message
169+}
170+
171+// Error constants.
172+var (
173+ ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
174+)
175+
176+// ErrWidgetCreationFailed creates an error for widget creation failure.
177+func ErrWidgetCreationFailed(name string) error {
178+ return WindowError{Message: "failed to create widget: " + name}
179+}

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