v0.14.0 76d1892

tab_mode.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"time"
6
7	"github.com/bnema/dumber/internal/logging"
8	"github.com/bnema/dumber/pkg/webkit"
9	"github.com/diamondburned/gotk4/pkg/gtk/v4"
10)
11
12// EnterTabMode activates the modal tab management mode.
13// This is triggered by Alt+T and provides visual feedback and temporary key bindings.
14func (tm *TabManager) EnterTabMode() {
15	logging.Debug("[tab-mode] EnterTabMode called - attempting to acquire lock")
16	tm.mu.Lock()
17	defer tm.mu.Unlock()
18	logging.Debug("[tab-mode] Lock acquired")
19
20	// Already in tab mode?
21	if tm.tabModeActive {
22		logging.Debug("[tab-mode] Tab mode already active")
23		return
24	}
25
26	logging.Info("[tab-mode] Entering tab mode")
27
28	logging.Debug("[tab-mode] Setting tabModeActive flag")
29	tm.tabModeActive = true
30
31	// Apply visual border indicator
32	logging.Debug("[tab-mode] About to call applyTabModeBorder()")
33	tm.applyTabModeBorder()
34	logging.Debug("[tab-mode] Returned from applyTabModeBorder()")
35
36	// Notify JavaScript for UI feedback (if needed)
37	logging.Debug("[tab-mode] About to call dispatchTabModeEventInternal()")
38	tm.dispatchTabModeEventInternal("entered", "")
39	logging.Debug("[tab-mode] Returned from dispatchTabModeEventInternal()")
40
41	// Start timeout timer to auto-exit tab mode
42	logging.Debug("[tab-mode] Getting config")
43	cfg := tm.getConfig()
44	timeoutDuration := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
45	logging.Debug(fmt.Sprintf("[tab-mode] Config retrieved, timeout: %v", timeoutDuration))
46
47	// Cancel existing timer if any
48	if tm.tabModeTimer != nil {
49		logging.Debug("[tab-mode] Stopping existing timer")
50		tm.tabModeTimer.Stop()
51	}
52
53	logging.Debug("[tab-mode] Creating new timeout timer")
54	tm.tabModeTimer = time.AfterFunc(timeoutDuration, func() {
55		logging.Debug("[tab-mode] Timeout reached, exiting tab mode")
56		tm.ExitTabMode("timeout")
57	})
58
59	logging.Debug(fmt.Sprintf("[tab-mode] Tab mode entered successfully, will auto-exit in %v", timeoutDuration))
60}
61
62// ExitTabMode deactivates tab mode and removes visual indicators.
63func (tm *TabManager) ExitTabMode(reason string) {
64	tm.mu.Lock()
65	defer tm.mu.Unlock()
66
67	if !tm.tabModeActive {
68		return
69	}
70
71	logging.Info(fmt.Sprintf("[tab-mode] Exiting tab mode: %s", reason))
72
73	tm.tabModeActive = false
74
75	// Stop timeout timer
76	if tm.tabModeTimer != nil {
77		tm.tabModeTimer.Stop()
78		tm.tabModeTimer = nil
79	}
80
81	// Remove visual border indicator
82	tm.removeTabModeBorder()
83
84	// Notify JavaScript (use internal version to avoid lock reentry)
85	tm.dispatchTabModeEventInternal("exited", reason)
86}
87
88// HandleTabAction processes a tab mode action (new-tab, close-tab, etc.).
89func (tm *TabManager) HandleTabAction(action string) {
90	tm.mu.Lock()
91
92	if !tm.tabModeActive {
93		tm.mu.Unlock()
94		logging.Debug(fmt.Sprintf("[tab-mode] Ignoring action '%s': tab mode not active", action))
95		return
96	}
97
98	tm.mu.Unlock()
99
100	logging.Info(fmt.Sprintf("[tab-mode] Handling action '%s'", action))
101
102	switch action {
103	case "new-tab":
104		tm.handleNewTab()
105	case "close-tab":
106		tm.handleCloseTab()
107	case "next-tab":
108		tm.handleNextTab()
109	case "previous-tab":
110		tm.handlePreviousTab()
111	case "rename-tab":
112		tm.handleRenameTab()
113	case "confirm":
114		// Just exit tab mode
115		tm.ExitTabMode("confirm")
116	case "cancel":
117		// Exit tab mode without action
118		tm.ExitTabMode("cancel")
119	default:
120		logging.Warn(fmt.Sprintf("[tab-mode] Unknown action: %s", action))
121	}
122
123	// Exit tab mode after most actions (except rename which shows a dialog)
124	if action != "cancel" && action != "confirm" && action != "rename-tab" {
125		tm.ExitTabMode(action)
126	}
127}
128
129// handleNewTab creates a new tab.
130func (tm *TabManager) handleNewTab() {
131	logging.Debug("[tab-mode] Creating new tab")
132
133	if err := tm.CreateTab(""); err != nil {
134		logging.Error(fmt.Sprintf("[tab-mode] Failed to create new tab: %v", err))
135	}
136}
137
138// handleCloseTab closes the currently active tab.
139func (tm *TabManager) handleCloseTab() {
140	logging.Debug("[tab-mode] Closing active tab")
141
142	tm.mu.RLock()
143	activeIndex := tm.activeIndex
144	tabCount := len(tm.tabs)
145	tm.mu.RUnlock()
146
147	if tabCount <= 1 {
148		logging.Warn("[tab-mode] Cannot close last tab")
149		return
150	}
151
152	if err := tm.CloseTab(activeIndex); err != nil {
153		logging.Error(fmt.Sprintf("[tab-mode] Failed to close tab: %v", err))
154	}
155}
156
157// handleNextTab switches to the next tab.
158func (tm *TabManager) handleNextTab() {
159	logging.Debug("[tab-mode] Switching to next tab")
160
161	if err := tm.NextTab(); err != nil {
162		logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to next tab: %v", err))
163	}
164}
165
166// handlePreviousTab switches to the previous tab.
167func (tm *TabManager) handlePreviousTab() {
168	logging.Debug("[tab-mode] Switching to previous tab")
169
170	if err := tm.PreviousTab(); err != nil {
171		logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to previous tab: %v", err))
172	}
173}
174
175// handleRenameTab shows a dialog to rename the active tab.
176func (tm *TabManager) handleRenameTab() {
177	logging.Debug("[tab-mode] Opening rename dialog")
178
179	// Exit tab mode first so the dialog can be interacted with
180	tm.ExitTabMode("rename-dialog")
181
182	tm.mu.RLock()
183	activeIndex := tm.activeIndex
184	var currentTitle string
185	if activeIndex >= 0 && activeIndex < len(tm.tabs) {
186		tab := tm.tabs[activeIndex]
187		if tab.customTitle != "" {
188			currentTitle = tab.customTitle
189		} else {
190			currentTitle = tab.title
191		}
192	}
193	tm.mu.RUnlock()
194
195	if activeIndex < 0 {
196		logging.Error("[tab-mode] No active tab to rename")
197		return
198	}
199
200	// Show rename dialog (will be implemented in Step 10)
201	// For now, just log
202	logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle))
203
204	// TODO: Implement GTK dialog in Step 10
205	// tm.showRenameDialog(activeIndex, currentTitle)
206}
207
208// applyTabModeBorder applies visual indicator when tab mode is active.
209// This adds an orange border around the entire workspace area.
210func (tm *TabManager) applyTabModeBorder() {
211	logging.Debug("[tab-mode] applyTabModeBorder: entry")
212
213	if tm.window == nil {
214		logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning")
215		return
216	}
217
218	// Determine which container should receive the margins:
219	// - Prefer the root container (direct child of the window) so the window background shows
220	// - Fallback to ContentArea for older builds
221	var target gtk.Widgetter
222	if tm.rootContainer != nil {
223		target = tm.rootContainer
224		logging.Debug("[tab-mode] applyTabModeBorder: using root container for border")
225	} else if tm.ContentArea != nil {
226		target = tm.ContentArea
227		logging.Debug("[tab-mode] applyTabModeBorder: using content area fallback for border")
228	} else {
229		logging.Debug("[tab-mode] applyTabModeBorder: no container available, returning")
230		return
231	}
232
233	// Remember the container for removal
234	tm.tabModeTarget = target
235
236	// Apply 4px margins to create space for the border
237	webkit.WidgetSetMargin(target, 4)
238
239	// Add CSS class to window for background color (the "border" color shows in the margin space)
240	gtkWindow := tm.window.AsWindow()
241	webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active")
242
243	// Force resize/allocation to apply margin changes immediately
244	webkit.WidgetQueueResize(target)
245	webkit.WidgetQueueAllocate(target)
246
247	// Queue redraw to show changes
248	webkit.WidgetQueueDraw(gtkWindow)
249	webkit.WidgetQueueDraw(target)
250
251	logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully")
252}
253
254// removeTabModeBorder removes the visual indicator when exiting tab mode.
255func (tm *TabManager) removeTabModeBorder() {
256	if tm.window == nil {
257		return
258	}
259
260	// Use whichever container received margins
261	target := tm.tabModeTarget
262	if target == nil {
263		if tm.rootContainer != nil {
264			target = tm.rootContainer
265		} else {
266			target = tm.ContentArea
267		}
268	}
269	if target == nil {
270		return
271	}
272
273	// Remove margins
274	webkit.WidgetSetMargin(target, 0)
275
276	// Remove CSS class from window
277	gtkWindow := tm.window.AsWindow()
278	webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active")
279
280	// Force resize/allocation to apply margin changes immediately
281	webkit.WidgetQueueResize(target)
282	webkit.WidgetQueueAllocate(target)
283
284	// Queue redraw to show changes
285	webkit.WidgetQueueDraw(gtkWindow)
286	webkit.WidgetQueueDraw(target)
287
288	tm.tabModeTarget = nil
289
290	logging.Debug("[tab-mode] Removed tab mode visual indicator")
291}
292
293// dispatchTabModeEventInternal sends an event without acquiring locks.
294// Must be called with lock already held.
295func (tm *TabManager) dispatchTabModeEventInternal(event string, detail string) {
296	logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: entry (event=%s, detail=%s)", event, detail))
297
298	// Get active tab
299	if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
300		logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: invalid activeIndex=%d, returning", tm.activeIndex))
301		return
302	}
303
304	activeTab := tm.tabs[tm.activeIndex]
305	if activeTab == nil {
306		logging.Debug("[tab-mode] dispatchTabModeEventInternal: activeTab is nil, returning")
307		return
308	}
309
310	// Log tab mode events for debugging (no JS dispatch needed for simple text tab labels)
311	logging.Debug(fmt.Sprintf("[tab-mode] Tab mode event '%s' with detail '%s' for tab %s", event, detail, activeTab.id))
312	logging.Debug("[tab-mode] dispatchTabModeEventInternal: completed successfully")
313}
314
315// dispatchTabModeEvent sends a custom event to the active tab's webview (with locking).
316// This can be used to provide UI feedback in the web content.
317func (tm *TabManager) dispatchTabModeEvent(event string, detail string) {
318	tm.mu.RLock()
319	defer tm.mu.RUnlock()
320	tm.dispatchTabModeEventInternal(event, detail)
321}

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