v0.33.1 9577fee

manage_tabs.go RawHistoryFolder

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

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