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 | + | } | |||