feat(tabs): add modal tab mode interface
- Add EnterTabMode/ExitTabMode with visual border indicator - Implement HandleTabAction for modal key dispatch - Add timeout auto-exit (3 seconds default) - Support actions: new-tab, close-tab, next/previous, rename - Prevent closing last tab with user warning - Add visual border feedback (orange, distinct from pane mode blue) - Fix lock reentry deadlock with internal event dispatch
- commit
- 15181208aaa13b14018250df743b915df2bc36cd
- author
- bnema · · 2025-11-17 19:41 UTC
- parent
- 52bc74a
- signature
- unsigned
1 file · +267 -0
A internal/app/browser/tab_mode.go +267 -0
| @@ -0,0 +1,267 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // EnterTabMode activates the modal tab management mode. | |
| 12 | + | // This is triggered by Alt+T and provides visual feedback and temporary key bindings. | |
| 13 | + | func (tm *TabManager) EnterTabMode() { | |
| 14 | + | logging.Debug("[tab-mode] EnterTabMode called - attempting to acquire lock") | |
| 15 | + | tm.mu.Lock() | |
| 16 | + | defer tm.mu.Unlock() | |
| 17 | + | logging.Debug("[tab-mode] Lock acquired") | |
| 18 | + | ||
| 19 | + | // Already in tab mode? | |
| 20 | + | if tm.tabModeActive { | |
| 21 | + | logging.Debug("[tab-mode] Tab mode already active") | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | logging.Info("[tab-mode] Entering tab mode") | |
| 26 | + | ||
| 27 | + | logging.Debug("[tab-mode] Setting tabModeActive flag") | |
| 28 | + | tm.tabModeActive = true | |
| 29 | + | ||
| 30 | + | // Apply visual border indicator | |
| 31 | + | logging.Debug("[tab-mode] About to call applyTabModeBorder()") | |
| 32 | + | tm.applyTabModeBorder() | |
| 33 | + | logging.Debug("[tab-mode] Returned from applyTabModeBorder()") | |
| 34 | + | ||
| 35 | + | // Notify JavaScript for UI feedback (if needed) | |
| 36 | + | logging.Debug("[tab-mode] About to call dispatchTabModeEventInternal()") | |
| 37 | + | tm.dispatchTabModeEventInternal("entered", "") | |
| 38 | + | logging.Debug("[tab-mode] Returned from dispatchTabModeEventInternal()") | |
| 39 | + | ||
| 40 | + | // Start timeout timer to auto-exit tab mode | |
| 41 | + | logging.Debug("[tab-mode] Getting config") | |
| 42 | + | cfg := tm.getConfig() | |
| 43 | + | timeoutDuration := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 44 | + | logging.Debug(fmt.Sprintf("[tab-mode] Config retrieved, timeout: %v", timeoutDuration)) | |
| 45 | + | ||
| 46 | + | // Cancel existing timer if any | |
| 47 | + | if tm.tabModeTimer != nil { | |
| 48 | + | logging.Debug("[tab-mode] Stopping existing timer") | |
| 49 | + | tm.tabModeTimer.Stop() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | logging.Debug("[tab-mode] Creating new timeout timer") | |
| 53 | + | tm.tabModeTimer = time.AfterFunc(timeoutDuration, func() { | |
| 54 | + | logging.Debug("[tab-mode] Timeout reached, exiting tab mode") | |
| 55 | + | tm.ExitTabMode("timeout") | |
| 56 | + | }) | |
| 57 | + | ||
| 58 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode entered successfully, will auto-exit in %v", timeoutDuration)) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ExitTabMode deactivates tab mode and removes visual indicators. | |
| 62 | + | func (tm *TabManager) ExitTabMode(reason string) { | |
| 63 | + | tm.mu.Lock() | |
| 64 | + | defer tm.mu.Unlock() | |
| 65 | + | ||
| 66 | + | if !tm.tabModeActive { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | logging.Info(fmt.Sprintf("[tab-mode] Exiting tab mode: %s", reason)) | |
| 71 | + | ||
| 72 | + | tm.tabModeActive = false | |
| 73 | + | ||
| 74 | + | // Stop timeout timer | |
| 75 | + | if tm.tabModeTimer != nil { | |
| 76 | + | tm.tabModeTimer.Stop() | |
| 77 | + | tm.tabModeTimer = nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Remove visual border indicator | |
| 81 | + | tm.removeTabModeBorder() | |
| 82 | + | ||
| 83 | + | // Notify JavaScript (use internal version to avoid lock reentry) | |
| 84 | + | tm.dispatchTabModeEventInternal("exited", reason) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // HandleTabAction processes a tab mode action (new-tab, close-tab, etc.). | |
| 88 | + | func (tm *TabManager) HandleTabAction(action string) { | |
| 89 | + | tm.mu.Lock() | |
| 90 | + | ||
| 91 | + | if !tm.tabModeActive { | |
| 92 | + | tm.mu.Unlock() | |
| 93 | + | logging.Debug(fmt.Sprintf("[tab-mode] Ignoring action '%s': tab mode not active", action)) | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | tm.mu.Unlock() | |
| 98 | + | ||
| 99 | + | logging.Info(fmt.Sprintf("[tab-mode] Handling action '%s'", action)) | |
| 100 | + | ||
| 101 | + | switch action { | |
| 102 | + | case "new-tab": | |
| 103 | + | tm.handleNewTab() | |
| 104 | + | case "close-tab": | |
| 105 | + | tm.handleCloseTab() | |
| 106 | + | case "next-tab": | |
| 107 | + | tm.handleNextTab() | |
| 108 | + | case "previous-tab": | |
| 109 | + | tm.handlePreviousTab() | |
| 110 | + | case "rename-tab": | |
| 111 | + | tm.handleRenameTab() | |
| 112 | + | case "confirm": | |
| 113 | + | // Just exit tab mode | |
| 114 | + | tm.ExitTabMode("confirm") | |
| 115 | + | case "cancel": | |
| 116 | + | // Exit tab mode without action | |
| 117 | + | tm.ExitTabMode("cancel") | |
| 118 | + | default: | |
| 119 | + | logging.Warn(fmt.Sprintf("[tab-mode] Unknown action: %s", action)) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Exit tab mode after most actions (except rename which shows a dialog) | |
| 123 | + | if action != "cancel" && action != "confirm" && action != "rename-tab" { | |
| 124 | + | tm.ExitTabMode(action) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // handleNewTab creates a new tab. | |
| 129 | + | func (tm *TabManager) handleNewTab() { | |
| 130 | + | logging.Debug("[tab-mode] Creating new tab") | |
| 131 | + | ||
| 132 | + | if err := tm.CreateTab(""); err != nil { | |
| 133 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to create new tab: %v", err)) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // handleCloseTab closes the currently active tab. | |
| 138 | + | func (tm *TabManager) handleCloseTab() { | |
| 139 | + | logging.Debug("[tab-mode] Closing active tab") | |
| 140 | + | ||
| 141 | + | tm.mu.RLock() | |
| 142 | + | activeIndex := tm.activeIndex | |
| 143 | + | tabCount := len(tm.tabs) | |
| 144 | + | tm.mu.RUnlock() | |
| 145 | + | ||
| 146 | + | if tabCount <= 1 { | |
| 147 | + | logging.Warn("[tab-mode] Cannot close last tab") | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | if err := tm.CloseTab(activeIndex); err != nil { | |
| 152 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to close tab: %v", err)) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // handleNextTab switches to the next tab. | |
| 157 | + | func (tm *TabManager) handleNextTab() { | |
| 158 | + | logging.Debug("[tab-mode] Switching to next tab") | |
| 159 | + | ||
| 160 | + | if err := tm.NextTab(); err != nil { | |
| 161 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to next tab: %v", err)) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // handlePreviousTab switches to the previous tab. | |
| 166 | + | func (tm *TabManager) handlePreviousTab() { | |
| 167 | + | logging.Debug("[tab-mode] Switching to previous tab") | |
| 168 | + | ||
| 169 | + | if err := tm.PreviousTab(); err != nil { | |
| 170 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to previous tab: %v", err)) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // handleRenameTab shows a dialog to rename the active tab. | |
| 175 | + | func (tm *TabManager) handleRenameTab() { | |
| 176 | + | logging.Debug("[tab-mode] Opening rename dialog") | |
| 177 | + | ||
| 178 | + | // Exit tab mode first so the dialog can be interacted with | |
| 179 | + | tm.ExitTabMode("rename-dialog") | |
| 180 | + | ||
| 181 | + | tm.mu.RLock() | |
| 182 | + | activeIndex := tm.activeIndex | |
| 183 | + | var currentTitle string | |
| 184 | + | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | |
| 185 | + | tab := tm.tabs[activeIndex] | |
| 186 | + | if tab.customTitle != "" { | |
| 187 | + | currentTitle = tab.customTitle | |
| 188 | + | } else { | |
| 189 | + | currentTitle = tab.title | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | tm.mu.RUnlock() | |
| 193 | + | ||
| 194 | + | if activeIndex < 0 { | |
| 195 | + | logging.Error("[tab-mode] No active tab to rename") | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Show rename dialog (will be implemented in Step 10) | |
| 200 | + | // For now, just log | |
| 201 | + | logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle)) | |
| 202 | + | ||
| 203 | + | // TODO: Implement GTK dialog in Step 10 | |
| 204 | + | // tm.showRenameDialog(activeIndex, currentTitle) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // applyTabModeBorder applies visual indicator when tab mode is active. | |
| 208 | + | // This adds an orange border/highlight to the tab bar. | |
| 209 | + | func (tm *TabManager) applyTabModeBorder() { | |
| 210 | + | logging.Debug("[tab-mode] applyTabModeBorder: entry") | |
| 211 | + | ||
| 212 | + | if tm.window == nil { | |
| 213 | + | logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning") | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window") | |
| 218 | + | gtkWindow := tm.window.AsWindow() | |
| 219 | + | logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class") | |
| 220 | + | ||
| 221 | + | // Add CSS class to window for tab mode visual indicator | |
| 222 | + | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |
| 223 | + | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | |
| 224 | + | logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // removeTabModeBorder removes the visual indicator when exiting tab mode. | |
| 228 | + | func (tm *TabManager) removeTabModeBorder() { | |
| 229 | + | if tm.window == nil { | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Remove CSS class from window | |
| 234 | + | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |
| 235 | + | webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active") | |
| 236 | + | logging.Debug("[tab-mode] Removed tab mode visual indicator") | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // dispatchTabModeEventInternal sends an event without acquiring locks. | |
| 240 | + | // Must be called with lock already held. | |
| 241 | + | func (tm *TabManager) dispatchTabModeEventInternal(event string, detail string) { | |
| 242 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: entry (event=%s, detail=%s)", event, detail)) | |
| 243 | + | ||
| 244 | + | // Get active tab | |
| 245 | + | if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) { | |
| 246 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: invalid activeIndex=%d, returning", tm.activeIndex)) | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | activeTab := tm.tabs[tm.activeIndex] | |
| 251 | + | if activeTab == nil { | |
| 252 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: activeTab is nil, returning") | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Log tab mode events for debugging (no JS dispatch needed for simple text tab labels) | |
| 257 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode event '%s' with detail '%s' for tab %s", event, detail, activeTab.id)) | |
| 258 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: completed successfully") | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // dispatchTabModeEvent sends a custom event to the active tab's webview (with locking). | |
| 262 | + | // This can be used to provide UI feedback in the web content. | |
| 263 | + | func (tm *TabManager) dispatchTabModeEvent(event string, detail string) { | |
| 264 | + | tm.mu.RLock() | |
| 265 | + | defer tm.mu.RUnlock() | |
| 266 | + | tm.dispatchTabModeEventInternal(event, detail) | |
| 267 | + | } | |
| @@ -0,0 +1,267 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // EnterTabMode activates the modal tab management mode. | |||
| 12 | + | // This is triggered by Alt+T and provides visual feedback and temporary key bindings. | |||
| 13 | + | func (tm *TabManager) EnterTabMode() { | |||
| 14 | + | logging.Debug("[tab-mode] EnterTabMode called - attempting to acquire lock") | |||
| 15 | + | tm.mu.Lock() | |||
| 16 | + | defer tm.mu.Unlock() | |||
| 17 | + | logging.Debug("[tab-mode] Lock acquired") | |||
| 18 | + | ||||
| 19 | + | // Already in tab mode? | |||
| 20 | + | if tm.tabModeActive { | |||
| 21 | + | logging.Debug("[tab-mode] Tab mode already active") | |||
| 22 | + | return | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | logging.Info("[tab-mode] Entering tab mode") | |||
| 26 | + | ||||
| 27 | + | logging.Debug("[tab-mode] Setting tabModeActive flag") | |||
| 28 | + | tm.tabModeActive = true | |||
| 29 | + | ||||
| 30 | + | // Apply visual border indicator | |||
| 31 | + | logging.Debug("[tab-mode] About to call applyTabModeBorder()") | |||
| 32 | + | tm.applyTabModeBorder() | |||
| 33 | + | logging.Debug("[tab-mode] Returned from applyTabModeBorder()") | |||
| 34 | + | ||||
| 35 | + | // Notify JavaScript for UI feedback (if needed) | |||
| 36 | + | logging.Debug("[tab-mode] About to call dispatchTabModeEventInternal()") | |||
| 37 | + | tm.dispatchTabModeEventInternal("entered", "") | |||
| 38 | + | logging.Debug("[tab-mode] Returned from dispatchTabModeEventInternal()") | |||
| 39 | + | ||||
| 40 | + | // Start timeout timer to auto-exit tab mode | |||
| 41 | + | logging.Debug("[tab-mode] Getting config") | |||
| 42 | + | cfg := tm.getConfig() | |||
| 43 | + | timeoutDuration := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |||
| 44 | + | logging.Debug(fmt.Sprintf("[tab-mode] Config retrieved, timeout: %v", timeoutDuration)) | |||
| 45 | + | ||||
| 46 | + | // Cancel existing timer if any | |||
| 47 | + | if tm.tabModeTimer != nil { | |||
| 48 | + | logging.Debug("[tab-mode] Stopping existing timer") | |||
| 49 | + | tm.tabModeTimer.Stop() | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | logging.Debug("[tab-mode] Creating new timeout timer") | |||
| 53 | + | tm.tabModeTimer = time.AfterFunc(timeoutDuration, func() { | |||
| 54 | + | logging.Debug("[tab-mode] Timeout reached, exiting tab mode") | |||
| 55 | + | tm.ExitTabMode("timeout") | |||
| 56 | + | }) | |||
| 57 | + | ||||
| 58 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode entered successfully, will auto-exit in %v", timeoutDuration)) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // ExitTabMode deactivates tab mode and removes visual indicators. | |||
| 62 | + | func (tm *TabManager) ExitTabMode(reason string) { | |||
| 63 | + | tm.mu.Lock() | |||
| 64 | + | defer tm.mu.Unlock() | |||
| 65 | + | ||||
| 66 | + | if !tm.tabModeActive { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | logging.Info(fmt.Sprintf("[tab-mode] Exiting tab mode: %s", reason)) | |||
| 71 | + | ||||
| 72 | + | tm.tabModeActive = false | |||
| 73 | + | ||||
| 74 | + | // Stop timeout timer | |||
| 75 | + | if tm.tabModeTimer != nil { | |||
| 76 | + | tm.tabModeTimer.Stop() | |||
| 77 | + | tm.tabModeTimer = nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Remove visual border indicator | |||
| 81 | + | tm.removeTabModeBorder() | |||
| 82 | + | ||||
| 83 | + | // Notify JavaScript (use internal version to avoid lock reentry) | |||
| 84 | + | tm.dispatchTabModeEventInternal("exited", reason) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // HandleTabAction processes a tab mode action (new-tab, close-tab, etc.). | |||
| 88 | + | func (tm *TabManager) HandleTabAction(action string) { | |||
| 89 | + | tm.mu.Lock() | |||
| 90 | + | ||||
| 91 | + | if !tm.tabModeActive { | |||
| 92 | + | tm.mu.Unlock() | |||
| 93 | + | logging.Debug(fmt.Sprintf("[tab-mode] Ignoring action '%s': tab mode not active", action)) | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | tm.mu.Unlock() | |||
| 98 | + | ||||
| 99 | + | logging.Info(fmt.Sprintf("[tab-mode] Handling action '%s'", action)) | |||
| 100 | + | ||||
| 101 | + | switch action { | |||
| 102 | + | case "new-tab": | |||
| 103 | + | tm.handleNewTab() | |||
| 104 | + | case "close-tab": | |||
| 105 | + | tm.handleCloseTab() | |||
| 106 | + | case "next-tab": | |||
| 107 | + | tm.handleNextTab() | |||
| 108 | + | case "previous-tab": | |||
| 109 | + | tm.handlePreviousTab() | |||
| 110 | + | case "rename-tab": | |||
| 111 | + | tm.handleRenameTab() | |||
| 112 | + | case "confirm": | |||
| 113 | + | // Just exit tab mode | |||
| 114 | + | tm.ExitTabMode("confirm") | |||
| 115 | + | case "cancel": | |||
| 116 | + | // Exit tab mode without action | |||
| 117 | + | tm.ExitTabMode("cancel") | |||
| 118 | + | default: | |||
| 119 | + | logging.Warn(fmt.Sprintf("[tab-mode] Unknown action: %s", action)) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Exit tab mode after most actions (except rename which shows a dialog) | |||
| 123 | + | if action != "cancel" && action != "confirm" && action != "rename-tab" { | |||
| 124 | + | tm.ExitTabMode(action) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // handleNewTab creates a new tab. | |||
| 129 | + | func (tm *TabManager) handleNewTab() { | |||
| 130 | + | logging.Debug("[tab-mode] Creating new tab") | |||
| 131 | + | ||||
| 132 | + | if err := tm.CreateTab(""); err != nil { | |||
| 133 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to create new tab: %v", err)) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // handleCloseTab closes the currently active tab. | |||
| 138 | + | func (tm *TabManager) handleCloseTab() { | |||
| 139 | + | logging.Debug("[tab-mode] Closing active tab") | |||
| 140 | + | ||||
| 141 | + | tm.mu.RLock() | |||
| 142 | + | activeIndex := tm.activeIndex | |||
| 143 | + | tabCount := len(tm.tabs) | |||
| 144 | + | tm.mu.RUnlock() | |||
| 145 | + | ||||
| 146 | + | if tabCount <= 1 { | |||
| 147 | + | logging.Warn("[tab-mode] Cannot close last tab") | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | if err := tm.CloseTab(activeIndex); err != nil { | |||
| 152 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to close tab: %v", err)) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // handleNextTab switches to the next tab. | |||
| 157 | + | func (tm *TabManager) handleNextTab() { | |||
| 158 | + | logging.Debug("[tab-mode] Switching to next tab") | |||
| 159 | + | ||||
| 160 | + | if err := tm.NextTab(); err != nil { | |||
| 161 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to next tab: %v", err)) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // handlePreviousTab switches to the previous tab. | |||
| 166 | + | func (tm *TabManager) handlePreviousTab() { | |||
| 167 | + | logging.Debug("[tab-mode] Switching to previous tab") | |||
| 168 | + | ||||
| 169 | + | if err := tm.PreviousTab(); err != nil { | |||
| 170 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to previous tab: %v", err)) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // handleRenameTab shows a dialog to rename the active tab. | |||
| 175 | + | func (tm *TabManager) handleRenameTab() { | |||
| 176 | + | logging.Debug("[tab-mode] Opening rename dialog") | |||
| 177 | + | ||||
| 178 | + | // Exit tab mode first so the dialog can be interacted with | |||
| 179 | + | tm.ExitTabMode("rename-dialog") | |||
| 180 | + | ||||
| 181 | + | tm.mu.RLock() | |||
| 182 | + | activeIndex := tm.activeIndex | |||
| 183 | + | var currentTitle string | |||
| 184 | + | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | |||
| 185 | + | tab := tm.tabs[activeIndex] | |||
| 186 | + | if tab.customTitle != "" { | |||
| 187 | + | currentTitle = tab.customTitle | |||
| 188 | + | } else { | |||
| 189 | + | currentTitle = tab.title | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | tm.mu.RUnlock() | |||
| 193 | + | ||||
| 194 | + | if activeIndex < 0 { | |||
| 195 | + | logging.Error("[tab-mode] No active tab to rename") | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Show rename dialog (will be implemented in Step 10) | |||
| 200 | + | // For now, just log | |||
| 201 | + | logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle)) | |||
| 202 | + | ||||
| 203 | + | // TODO: Implement GTK dialog in Step 10 | |||
| 204 | + | // tm.showRenameDialog(activeIndex, currentTitle) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // applyTabModeBorder applies visual indicator when tab mode is active. | |||
| 208 | + | // This adds an orange border/highlight to the tab bar. | |||
| 209 | + | func (tm *TabManager) applyTabModeBorder() { | |||
| 210 | + | logging.Debug("[tab-mode] applyTabModeBorder: entry") | |||
| 211 | + | ||||
| 212 | + | if tm.window == nil { | |||
| 213 | + | logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning") | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window") | |||
| 218 | + | gtkWindow := tm.window.AsWindow() | |||
| 219 | + | logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class") | |||
| 220 | + | ||||
| 221 | + | // Add CSS class to window for tab mode visual indicator | |||
| 222 | + | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |||
| 223 | + | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | |||
| 224 | + | logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully") | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // removeTabModeBorder removes the visual indicator when exiting tab mode. | |||
| 228 | + | func (tm *TabManager) removeTabModeBorder() { | |||
| 229 | + | if tm.window == nil { | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // Remove CSS class from window | |||
| 234 | + | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |||
| 235 | + | webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active") | |||
| 236 | + | logging.Debug("[tab-mode] Removed tab mode visual indicator") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // dispatchTabModeEventInternal sends an event without acquiring locks. | |||
| 240 | + | // Must be called with lock already held. | |||
| 241 | + | func (tm *TabManager) dispatchTabModeEventInternal(event string, detail string) { | |||
| 242 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: entry (event=%s, detail=%s)", event, detail)) | |||
| 243 | + | ||||
| 244 | + | // Get active tab | |||
| 245 | + | if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) { | |||
| 246 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: invalid activeIndex=%d, returning", tm.activeIndex)) | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | activeTab := tm.tabs[tm.activeIndex] | |||
| 251 | + | if activeTab == nil { | |||
| 252 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: activeTab is nil, returning") | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // Log tab mode events for debugging (no JS dispatch needed for simple text tab labels) | |||
| 257 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode event '%s' with detail '%s' for tab %s", event, detail, activeTab.id)) | |||
| 258 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: completed successfully") | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // dispatchTabModeEvent sends a custom event to the active tab's webview (with locking). | |||
| 262 | + | // This can be used to provide UI feedback in the web content. | |||
| 263 | + | func (tm *TabManager) dispatchTabModeEvent(event string, detail string) { | |||
| 264 | + | tm.mu.RLock() | |||
| 265 | + | defer tm.mu.RUnlock() | |||
| 266 | + | tm.dispatchTabModeEventInternal(event, detail) | |||
| 267 | + | } | |||