detached 1518120

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close