v0.23.2 bfd5eb4

manage_tabs.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/dumber/internal/domain/url"
9	"github.com/bnema/dumber/internal/logging"
10)
11
12// IDGenerator is a function type for generating unique IDs.
13type IDGenerator func() string
14
15// ManageTabsUseCase handles tab lifecycle operations.
16type ManageTabsUseCase struct {
17	idGenerator IDGenerator
18}
19
20// NewManageTabsUseCase creates a new tab management use case.
21func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {
22	return &ManageTabsUseCase{
23		idGenerator: idGenerator,
24	}
25}
26
27// CreateTabInput contains parameters for creating a new tab.
28type CreateTabInput struct {
29	TabList    *entity.TabList
30	Name       string // Optional custom name
31	InitialURL string // URL to load (default: about:blank)
32	IsPinned   bool
33}
34
35// CreateTabOutput contains the result of tab creation.
36type CreateTabOutput struct {
37	Tab *entity.Tab
38}
39
40// Create creates a new tab with a workspace and initial pane.
41func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (*CreateTabOutput, error) {
42	log := logging.FromContext(ctx)
43	log.Debug().
44		Str("name", input.Name).
45		Str("initial_url", input.InitialURL).
46		Bool("is_pinned", input.IsPinned).
47		Msg("creating new tab")
48
49	if input.TabList == nil {
50		return nil, fmt.Errorf("tab list is required")
51	}
52
53	// Generate IDs
54	tabID := entity.TabID(uc.idGenerator())
55	workspaceID := entity.WorkspaceID(uc.idGenerator())
56	paneID := entity.PaneID(uc.idGenerator())
57
58	// Create initial pane with normalized URL
59	pane := entity.NewPane(paneID)
60	if input.InitialURL != "" {
61		pane.URI = url.Normalize(input.InitialURL)
62	}
63
64	// Create tab with workspace
65	tab := entity.NewTab(tabID, workspaceID, pane)
66	tab.Name = input.Name
67	tab.IsPinned = input.IsPinned
68
69	// Add to list (handles position and active tab)
70	input.TabList.Add(tab)
71
72	log.Info().
73		Str("tab_id", string(tabID)).
74		Str("workspace_id", string(workspaceID)).
75		Str("pane_id", string(paneID)).
76		Int("position", tab.Position).
77		Msg("tab created")
78
79	return &CreateTabOutput{Tab: tab}, nil
80}
81
82// Close removes a tab from the list.
83// Returns true if this was the last tab (caller should handle app exit).
84func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) {
85	ctx = logging.WithTabID(ctx, string(tabID))
86	log := logging.FromContext(ctx)
87	if uc == nil {
88		return false, fmt.Errorf("manage tabs use case is nil")
89	}
90
91	log.Debug().Msg("closing tab")
92
93	if tabs == nil {
94		return false, fmt.Errorf("tab list is required")
95	}
96
97	tab := tabs.Find(tabID)
98	if tab == nil {
99		log.Debug().Msg("tab not found")
100		return false, nil
101	}
102
103	// Check if this is the last tab
104	if tabs.Count() == 1 {
105		log.Info().Msg("closing last tab")
106		tabs.Remove(tabID)
107		return true, nil
108	}
109
110	// Remove tab (TabList handles active tab switching)
111	if !tabs.Remove(tabID) {
112		return false, fmt.Errorf("failed to remove tab")
113	}
114
115	log.Info().
116		Str("new_active", string(tabs.ActiveTabID)).
117		Int("remaining", tabs.Count()).
118		Msg("tab closed")
119
120	return false, nil
121}
122
123// Switch changes the active tab.
124func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error {
125	log := logging.FromContext(ctx)
126	if uc == nil {
127		return fmt.Errorf("manage tabs use case is nil")
128	}
129	log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab")
130
131	if tabs == nil {
132		return fmt.Errorf("tab list is required")
133	}
134
135	tab := tabs.Find(tabID)
136	if tab == nil {
137		return fmt.Errorf("tab not found: %s", tabID)
138	}
139
140	oldActive := tabs.ActiveTabID
141	tabs.SetActive(tabID) // Uses SetActive to track previous tab
142
143	log.Info().
144		Str("from", string(oldActive)).
145		Str("to", string(tabID)).
146		Msg("tab switched")
147
148	return nil
149}
150
151// Move repositions a tab within the tab bar.
152func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error {
153	log := logging.FromContext(ctx)
154	if uc == nil {
155		return fmt.Errorf("manage tabs use case is nil")
156	}
157	log.Debug().
158		Str("tab_id", string(tabID)).
159		Int("new_position", newPosition).
160		Msg("moving tab")
161
162	if tabs == nil {
163		return fmt.Errorf("tab list is required")
164	}
165
166	if !tabs.Move(tabID, newPosition) {
167		return fmt.Errorf("failed to move tab to position %d", newPosition)
168	}
169
170	log.Info().
171		Str("tab_id", string(tabID)).
172		Int("position", newPosition).
173		Msg("tab moved")
174
175	return nil
176}
177
178// Rename changes a tab's custom name.
179//
180//nolint:revive // receiver required for interface consistency
181func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error {
182	log := logging.FromContext(ctx)
183	log.Debug().
184		Str("tab_id", string(tabID)).
185		Str("name", name).
186		Msg("renaming tab")
187
188	if tabs == nil {
189		return fmt.Errorf("tab list is required")
190	}
191
192	tab := tabs.Find(tabID)
193	if tab == nil {
194		return fmt.Errorf("tab not found: %s", tabID)
195	}
196
197	tab.Name = name
198
199	log.Info().
200		Str("tab_id", string(tabID)).
201		Str("name", name).
202		Msg("tab renamed")
203
204	return nil
205}
206
207// GetNext returns the next tab ID in the given direction.
208// direction: 1 for next, -1 for previous.
209//
210//nolint:revive // receiver required for interface consistency
211func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID {
212	if tabs == nil || tabs.Count() == 0 {
213		return ""
214	}
215
216	// Find current active tab position
217	activeTab := tabs.ActiveTab()
218	if activeTab == nil {
219		// Return first tab if no active
220		if len(tabs.Tabs) > 0 {
221			return tabs.Tabs[0].ID
222		}
223		return ""
224	}
225
226	currentPos := activeTab.Position
227	newPos := currentPos + direction
228
229	// Wrap around
230	if newPos < 0 {
231		newPos = tabs.Count() - 1
232	} else if newPos >= tabs.Count() {
233		newPos = 0
234	}
235
236	if newPos >= 0 && newPos < len(tabs.Tabs) {
237		return tabs.Tabs[newPos].ID
238	}
239
240	return tabs.ActiveTabID
241}
242
243// SwitchNext switches to the next tab (wraps around).
244func (uc *ManageTabsUseCase) SwitchNext(ctx context.Context, tabs *entity.TabList) error {
245	nextID := uc.GetNext(tabs, 1)
246	if nextID == "" || nextID == tabs.ActiveTabID {
247		return nil
248	}
249	return uc.Switch(ctx, tabs, nextID)
250}
251
252// SwitchPrevious switches to the previous tab (wraps around).
253func (uc *ManageTabsUseCase) SwitchPrevious(ctx context.Context, tabs *entity.TabList) error {
254	prevID := uc.GetNext(tabs, -1)
255	if prevID == "" || prevID == tabs.ActiveTabID {
256		return nil
257	}
258	return uc.Switch(ctx, tabs, prevID)
259}
260
261// SwitchByIndex switches to tab at the given index (0-based).
262func (uc *ManageTabsUseCase) SwitchByIndex(ctx context.Context, tabs *entity.TabList, index int) error {
263	log := logging.FromContext(ctx)
264
265	if tabs == nil || index < 0 || index >= tabs.Count() {
266		log.Debug().Int("index", index).Msg("invalid tab index")
267		return nil
268	}
269
270	return uc.Switch(ctx, tabs, tabs.Tabs[index].ID)
271}
272
273// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
274func (uc *ManageTabsUseCase) SwitchToLastActive(ctx context.Context, tabs *entity.TabList) error {
275	log := logging.FromContext(ctx)
276
277	if tabs == nil {
278		return fmt.Errorf("tab list is required")
279	}
280
281	// If no previous tab recorded, do nothing
282	if tabs.PreviousActiveTabID == "" {
283		log.Debug().Msg("no previous active tab")
284		return nil
285	}
286
287	// Verify previous tab still exists
288	if tabs.Find(tabs.PreviousActiveTabID) == nil {
289		log.Debug().Str("prev_id", string(tabs.PreviousActiveTabID)).Msg("previous tab no longer exists")
290		tabs.PreviousActiveTabID = ""
291		return nil
292	}
293
294	return uc.Switch(ctx, tabs, tabs.PreviousActiveTabID)
295}
296
297// Pin toggles the pinned state of a tab.
298//
299//nolint:revive // receiver required for interface consistency
300func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error {
301	log := logging.FromContext(ctx)
302	log.Debug().
303		Str("tab_id", string(tabID)).
304		Bool("pinned", pinned).
305		Msg("toggling tab pin state")
306
307	if tabs == nil {
308		return fmt.Errorf("tab list is required")
309	}
310
311	tab := tabs.Find(tabID)
312	if tab == nil {
313		return fmt.Errorf("tab not found: %s", tabID)
314	}
315
316	tab.IsPinned = pinned
317
318	log.Info().
319		Str("tab_id", string(tabID)).
320		Bool("pinned", pinned).
321		Msg("tab pin state changed")
322
323	return nil
324}
325
326// CreateTabWithPaneInput contains parameters for creating a tab with an existing pane.
327type CreateTabWithPaneInput struct {
328	TabList    *entity.TabList
329	Name       string       // Optional custom name
330	Pane       *entity.Pane // Pre-created pane (for popups)
331	InitialURL string       // URL for the pane
332}
333
334// CreateWithPane creates a new tab using a pre-existing pane.
335// This is used for tabbed popup behavior where the popup pane is already created.
336func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTabWithPaneInput) (*CreateTabOutput, error) {
337	log := logging.FromContext(ctx)
338	log.Debug().
339		Str("name", input.Name).
340		Str("pane_id", string(input.Pane.ID)).
341		Str("initial_url", input.InitialURL).
342		Msg("creating new tab with existing pane")
343
344	if input.TabList == nil {
345		return nil, fmt.Errorf("tab list is required")
346	}
347	if input.Pane == nil {
348		return nil, fmt.Errorf("pane is required")
349	}
350
351	// Generate IDs for tab and workspace
352	tabID := entity.TabID(uc.idGenerator())
353	workspaceID := entity.WorkspaceID(uc.idGenerator())
354
355	// Use the provided pane
356	pane := input.Pane
357	if input.InitialURL != "" {
358		pane.URI = url.Normalize(input.InitialURL)
359	}
360
361	// Create tab with workspace using the pre-created pane
362	tab := entity.NewTab(tabID, workspaceID, pane)
363	if input.Name != "" {
364		tab.Name = input.Name
365	} else if pane.Title != "" {
366		tab.Name = pane.Title
367	}
368
369	// Add to list
370	input.TabList.Add(tab)
371
372	log.Info().
373		Str("tab_id", string(tabID)).
374		Str("workspace_id", string(workspaceID)).
375		Str("pane_id", string(pane.ID)).
376		Int("position", tab.Position).
377		Msg("tab created with existing pane")
378
379	return &CreateTabOutput{Tab: tab}, nil
380}

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