detached e5a8cf8

Merge pull request #42 from bnema/feature/move-pane-to-tab

Move pane to tab
commit
e5a8cf8abd51beedb3a93cd36457cd8bfc8eb6cd
author
bnema · · 2025-12-27 16:07 UTC
committer
GitHub · · 2025-12-27 16:07 UTC
merge of
4c2047b + 53b38cf · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +1620 -40
M CHANGELOG.md +6 -0
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
9+ - `Ctrl+P → m`: Opens a tab picker modal.
10+ - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
11+ - New config: `workspace.switch_to_tab_on_move` (default: true).
12+
713 ### Fixed
814 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
915 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
9+ - `Ctrl+P → m`: Opens a tab picker modal.
10+ - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
11+ - New config: `workspace.switch_to_tab_on_move` (default: true).
12+
7 ### Fixed13 ### Fixed
8 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.14 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
9 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).15 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
M README.md +2 -0
@@ -77,6 +77,8 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
7777 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
7878 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
7979 | **X** (in pane mode) | Close Pane | Close current pane |
80+| **m** (in pane mode) | Move Pane To Tab | Opens a tab picker modal |
81+| **M** (in pane mode) | Move Pane To Next Tab | Moves to the next tab, creates new tab if needed |
8082 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
8183 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
8284 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
@@ -77,6 +77,8 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
77 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |77 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
78 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |78 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
79 | **X** (in pane mode) | Close Pane | Close current pane |79 | **X** (in pane mode) | Close Pane | Close current pane |
80+| **m** (in pane mode) | Move Pane To Tab | Opens a tab picker modal |
81+| **M** (in pane mode) | Move Pane To Next Tab | Moves to the next tab, creates new tab if needed |
80 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |82 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
81 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |83 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
82 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |84 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
M docs/CONFIG.md +3 -0
@@ -201,6 +201,7 @@ border = "#363636"
201201 | Key | Type | Default | Description |
202202 |-----|------|---------|-------------|
203203 | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |
204+| `workspace.switch_to_tab_on_move` | bool | `true` | When moving a pane to another tab, automatically switch to the destination tab |
204205
205206 **Example:**
206207 ```toml
@@ -225,6 +226,8 @@ split-up = ["arrowup", "u"]
225226 split-down = ["arrowdown", "d"]
226227 stack-pane = ["s"]
227228 close-pane = ["x"]
229+move-pane-to-tab = ["m"]
230+move-pane-to-next-tab = ["M", "shift+m"]
228231
229232 # Consume-or-expel (niri-style) - very alpha
230233 consume-or-expel-left = ["bracketleft", "["]
@@ -201,6 +201,7 @@ border = "#363636"
201 | Key | Type | Default | Description |201 | Key | Type | Default | Description |
202 |-----|------|---------|-------------|202 |-----|------|---------|-------------|
203 | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |203 | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |
204+| `workspace.switch_to_tab_on_move` | bool | `true` | When moving a pane to another tab, automatically switch to the destination tab |
204 205
205 **Example:**206 **Example:**
206 ```toml207 ```toml
@@ -225,6 +226,8 @@ split-up = ["arrowup", "u"]
225 split-down = ["arrowdown", "d"]226 split-down = ["arrowdown", "d"]
226 stack-pane = ["s"]227 stack-pane = ["s"]
227 close-pane = ["x"]228 close-pane = ["x"]
229+move-pane-to-tab = ["m"]
230+move-pane-to-next-tab = ["M", "shift+m"]
228 231
229 # Consume-or-expel (niri-style) - very alpha232 # Consume-or-expel (niri-style) - very alpha
230 consume-or-expel-left = ["bracketleft", "["]233 consume-or-expel-left = ["bracketleft", "["]
A internal/application/usecase/move_pane_to_tab.go +382 -0
@@ -0,0 +1,382 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// MovePaneToTabUseCase moves a pane from one tab's workspace to another.
10+//
11+// It is pure domain manipulation: it depends only on entities and an ID generator.
12+type MovePaneToTabUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17+ return &MovePaneToTabUseCase{idGenerator: idGenerator}
18+}
19+
20+type MovePaneToTabInput struct {
21+ TabList *entity.TabList
22+ SourceTabID entity.TabID
23+ SourcePaneID entity.PaneID
24+ TargetTabID entity.TabID // empty means create new tab
25+}
26+
27+type MovePaneToTabOutput struct {
28+ TargetTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+ NewTabCreated bool
32+}
33+
34+func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) {
35+ if err := validateMovePaneToTabInput(uc, input); err != nil {
36+ return nil, err
37+ }
38+
39+ sourceTab, err := findSourceTab(input.TabList, input.SourceTabID)
40+ if err != nil {
41+ return nil, err
42+ }
43+
44+ movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
45+ if err != nil {
46+ return nil, err
47+ }
48+
49+ if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil {
50+ return nil, detachErr
51+ }
52+
53+ sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab)
54+
55+ targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane)
56+ if err != nil {
57+ return nil, err
58+ }
59+ if targetTab == nil || targetTab.Workspace == nil {
60+ return nil, fmt.Errorf("target tab/workspace is nil")
61+ }
62+
63+ if newTabCreated {
64+ return &MovePaneToTabOutput{
65+ TargetTab: targetTab,
66+ MovedPaneNode: targetTab.Workspace.Root,
67+ SourceTabClosed: sourceTabClosed,
68+ NewTabCreated: true,
69+ }, nil
70+ }
71+
72+ movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane)
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ return &MovePaneToTabOutput{
78+ TargetTab: targetTab,
79+ MovedPaneNode: movedNode,
80+ SourceTabClosed: sourceTabClosed,
81+ NewTabCreated: false,
82+ }, nil
83+}
84+
85+func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error {
86+ if uc == nil {
87+ return fmt.Errorf("move pane to tab use case is nil")
88+ }
89+ if input.TabList == nil {
90+ return fmt.Errorf("tab list is required")
91+ }
92+ if input.SourceTabID == "" {
93+ return fmt.Errorf("source tab id is required")
94+ }
95+ if input.SourcePaneID == "" {
96+ return fmt.Errorf("source pane id is required")
97+ }
98+ if input.TargetTabID == input.SourceTabID {
99+ return fmt.Errorf("cannot move pane to same tab")
100+ }
101+ return nil
102+}
103+
104+func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) {
105+ sourceTab := tl.Find(id)
106+ if sourceTab == nil {
107+ return nil, fmt.Errorf("source tab not found: %s", id)
108+ }
109+ if sourceTab.Workspace == nil {
110+ return nil, fmt.Errorf("source workspace is nil")
111+ }
112+ return sourceTab, nil
113+}
114+
115+func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) {
116+ if ws == nil {
117+ return nil, nil, fmt.Errorf("workspace is required")
118+ }
119+ sourceNode := ws.FindPane(paneID)
120+ if sourceNode == nil || sourceNode.Pane == nil {
121+ return nil, nil, fmt.Errorf("source pane not found: %s", paneID)
122+ }
123+ return sourceNode.Pane, sourceNode, nil
124+}
125+
126+func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool {
127+ if tl == nil || sourceTab == nil || sourceTab.Workspace == nil {
128+ return false
129+ }
130+ if sourceTab.Workspace.PaneCount() != 0 {
131+ return false
132+ }
133+ return tl.Remove(sourceTab.ID)
134+}
135+
136+func (uc *MovePaneToTabUseCase) resolveTargetTab(
137+ tl *entity.TabList,
138+ targetID entity.TabID,
139+ movedPane *entity.Pane,
140+) (*entity.Tab, bool, error) {
141+ if tl == nil {
142+ return nil, false, fmt.Errorf("tab list is required")
143+ }
144+ if movedPane == nil {
145+ return nil, false, fmt.Errorf("moved pane is required")
146+ }
147+
148+ if targetID != "" {
149+ if targetTab := tl.Find(targetID); targetTab != nil {
150+ return targetTab, false, nil
151+ }
152+ // Treat missing as "create new".
153+ }
154+
155+ if uc.idGenerator == nil {
156+ return nil, false, fmt.Errorf("id generator is required to create new tab")
157+ }
158+ tabID := entity.TabID(uc.idGenerator())
159+ wsID := entity.WorkspaceID(uc.idGenerator())
160+ targetTab := entity.NewTab(tabID, wsID, movedPane)
161+ tl.Add(targetTab)
162+ return targetTab, true, nil
163+}
164+
165+func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) {
166+ if ws == nil {
167+ return nil, fmt.Errorf("workspace is required")
168+ }
169+ if movedPane == nil {
170+ return nil, fmt.Errorf("moved pane is required")
171+ }
172+
173+ movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane}
174+
175+ if ws.Root == nil {
176+ ws.Root = movedNode
177+ ws.ActivePaneID = movedPane.ID
178+ return movedNode, nil
179+ }
180+
181+ targetActive := ws.ActivePane()
182+ if targetActive == nil || targetActive.Pane == nil {
183+ return nil, fmt.Errorf("target tab has no active pane")
184+ }
185+
186+ if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil {
187+ return nil, err
188+ }
189+ ws.ActivePaneID = movedPane.ID
190+ return movedNode, nil
191+}
192+
193+func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
194+ if ws == nil {
195+ return fmt.Errorf("workspace is required")
196+ }
197+ if leaf == nil || leaf.Pane == nil {
198+ return fmt.Errorf("pane node is required")
199+ }
200+
201+ detachedFromStack, err := detachFromStackIfNeeded(ws, leaf)
202+ if err != nil {
203+ return err
204+ }
205+ if detachedFromStack {
206+ return nil
207+ }
208+
209+ if !leaf.IsLeaf() {
210+ return fmt.Errorf("can only move leaf panes")
211+ }
212+ return detachLeafFromWorkspace(ws, leaf)
213+}
214+
215+func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) {
216+ if ws == nil || leaf == nil {
217+ return false, nil
218+ }
219+ if leaf.Parent == nil || !leaf.Parent.IsStacked {
220+ return false, nil
221+ }
222+
223+ stackNode := leaf.Parent
224+
225+ idx := -1
226+ for i, child := range stackNode.Children {
227+ if child == leaf {
228+ idx = i
229+ break
230+ }
231+ }
232+ if idx < 0 {
233+ return false, fmt.Errorf("pane not found in stack")
234+ }
235+
236+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239+ }
240+ if stackNode.ActiveStackIndex < 0 {
241+ stackNode.ActiveStackIndex = 0
242+ }
243+
244+ // If only one pane remains in stack, dissolve it into a leaf node.
245+ if len(stackNode.Children) == 1 {
246+ remaining := stackNode.Children[0]
247+ stackNode.Pane = remaining.Pane
248+ stackNode.Children = nil
249+ stackNode.IsStacked = false
250+ stackNode.ActiveStackIndex = 0
251+ if stackNode.Pane != nil {
252+ ws.ActivePaneID = stackNode.Pane.ID
253+ } else {
254+ ws.ActivePaneID = ""
255+ }
256+ return true, nil
257+ }
258+
259+ // Otherwise, set workspace active pane to the stack's current active.
260+ if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {
261+ ws.ActivePaneID = stackNode.ActivePane().Pane.ID
262+ }
263+ return true, nil
264+}
265+
266+func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
267+ parent := leaf.Parent
268+ if parent == nil {
269+ ws.Root = nil
270+ ws.ActivePaneID = ""
271+ return nil
272+ }
273+ if !parent.IsSplit() {
274+ return fmt.Errorf("pane parent is not a split")
275+ }
276+
277+ sibling := findSibling(parent, leaf)
278+ if sibling == nil {
279+ return fmt.Errorf("no sibling found")
280+ }
281+
282+ promoteSibling(ws, parent, sibling)
283+ ws.ActivePaneID = findFirstLeafPaneID(sibling)
284+ return nil
285+}
286+
287+func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode {
288+ if parent == nil {
289+ return nil
290+ }
291+ for _, child := range parent.Children {
292+ if child != leaf {
293+ return child
294+ }
295+ }
296+ return nil
297+}
298+
299+func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) {
300+ if ws == nil || sibling == nil {
301+ return
302+ }
303+
304+ grandparent := parent.Parent
305+ if grandparent == nil {
306+ ws.Root = sibling
307+ sibling.Parent = nil
308+ return
309+ }
310+ for i, child := range grandparent.Children {
311+ if child == parent {
312+ grandparent.Children[i] = sibling
313+ break
314+ }
315+ }
316+ sibling.Parent = grandparent
317+}
318+
319+func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID {
320+ if node == nil {
321+ return ""
322+ }
323+ var active entity.PaneID
324+ node.Walk(func(n *entity.PaneNode) bool {
325+ if n.IsLeaf() && n.Pane != nil {
326+ active = n.Pane.ID
327+ return false
328+ }
329+ return true
330+ })
331+ return active
332+}
333+
334+func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error {
335+ if ws == nil {
336+ return fmt.Errorf("workspace is required")
337+ }
338+ if activeNode == nil {
339+ return fmt.Errorf("active pane is required")
340+ }
341+ if newLeaf == nil || newLeaf.Pane == nil {
342+ return fmt.Errorf("new pane node is required")
343+ }
344+ if idGen == nil {
345+ return fmt.Errorf("id generator is required")
346+ }
347+
348+ targetNode := activeNode
349+ // Split around stacks when inserting left/right.
350+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
351+ targetNode = targetNode.Parent
352+ }
353+
354+ parentID := idGen()
355+ splitParent := &entity.PaneNode{
356+ ID: parentID,
357+ SplitDir: entity.SplitHorizontal,
358+ SplitRatio: 0.5,
359+ Children: make([]*entity.PaneNode, 2),
360+ }
361+
362+ splitParent.Children[0] = targetNode
363+ splitParent.Children[1] = newLeaf
364+
365+ newLeaf.Parent = splitParent
366+ oldParent := targetNode.Parent
367+ targetNode.Parent = splitParent
368+
369+ if oldParent == nil {
370+ ws.Root = splitParent
371+ } else {
372+ for i, child := range oldParent.Children {
373+ if child == targetNode {
374+ oldParent.Children[i] = splitParent
375+ break
376+ }
377+ }
378+ splitParent.Parent = oldParent
379+ }
380+
381+ return nil
382+}
@@ -0,0 +1,382 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// MovePaneToTabUseCase moves a pane from one tab's workspace to another.
10+//
11+// It is pure domain manipulation: it depends only on entities and an ID generator.
12+type MovePaneToTabUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17+ return &MovePaneToTabUseCase{idGenerator: idGenerator}
18+}
19+
20+type MovePaneToTabInput struct {
21+ TabList *entity.TabList
22+ SourceTabID entity.TabID
23+ SourcePaneID entity.PaneID
24+ TargetTabID entity.TabID // empty means create new tab
25+}
26+
27+type MovePaneToTabOutput struct {
28+ TargetTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+ NewTabCreated bool
32+}
33+
34+func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) {
35+ if err := validateMovePaneToTabInput(uc, input); err != nil {
36+ return nil, err
37+ }
38+
39+ sourceTab, err := findSourceTab(input.TabList, input.SourceTabID)
40+ if err != nil {
41+ return nil, err
42+ }
43+
44+ movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
45+ if err != nil {
46+ return nil, err
47+ }
48+
49+ if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil {
50+ return nil, detachErr
51+ }
52+
53+ sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab)
54+
55+ targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane)
56+ if err != nil {
57+ return nil, err
58+ }
59+ if targetTab == nil || targetTab.Workspace == nil {
60+ return nil, fmt.Errorf("target tab/workspace is nil")
61+ }
62+
63+ if newTabCreated {
64+ return &MovePaneToTabOutput{
65+ TargetTab: targetTab,
66+ MovedPaneNode: targetTab.Workspace.Root,
67+ SourceTabClosed: sourceTabClosed,
68+ NewTabCreated: true,
69+ }, nil
70+ }
71+
72+ movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane)
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ return &MovePaneToTabOutput{
78+ TargetTab: targetTab,
79+ MovedPaneNode: movedNode,
80+ SourceTabClosed: sourceTabClosed,
81+ NewTabCreated: false,
82+ }, nil
83+}
84+
85+func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error {
86+ if uc == nil {
87+ return fmt.Errorf("move pane to tab use case is nil")
88+ }
89+ if input.TabList == nil {
90+ return fmt.Errorf("tab list is required")
91+ }
92+ if input.SourceTabID == "" {
93+ return fmt.Errorf("source tab id is required")
94+ }
95+ if input.SourcePaneID == "" {
96+ return fmt.Errorf("source pane id is required")
97+ }
98+ if input.TargetTabID == input.SourceTabID {
99+ return fmt.Errorf("cannot move pane to same tab")
100+ }
101+ return nil
102+}
103+
104+func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) {
105+ sourceTab := tl.Find(id)
106+ if sourceTab == nil {
107+ return nil, fmt.Errorf("source tab not found: %s", id)
108+ }
109+ if sourceTab.Workspace == nil {
110+ return nil, fmt.Errorf("source workspace is nil")
111+ }
112+ return sourceTab, nil
113+}
114+
115+func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) {
116+ if ws == nil {
117+ return nil, nil, fmt.Errorf("workspace is required")
118+ }
119+ sourceNode := ws.FindPane(paneID)
120+ if sourceNode == nil || sourceNode.Pane == nil {
121+ return nil, nil, fmt.Errorf("source pane not found: %s", paneID)
122+ }
123+ return sourceNode.Pane, sourceNode, nil
124+}
125+
126+func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool {
127+ if tl == nil || sourceTab == nil || sourceTab.Workspace == nil {
128+ return false
129+ }
130+ if sourceTab.Workspace.PaneCount() != 0 {
131+ return false
132+ }
133+ return tl.Remove(sourceTab.ID)
134+}
135+
136+func (uc *MovePaneToTabUseCase) resolveTargetTab(
137+ tl *entity.TabList,
138+ targetID entity.TabID,
139+ movedPane *entity.Pane,
140+) (*entity.Tab, bool, error) {
141+ if tl == nil {
142+ return nil, false, fmt.Errorf("tab list is required")
143+ }
144+ if movedPane == nil {
145+ return nil, false, fmt.Errorf("moved pane is required")
146+ }
147+
148+ if targetID != "" {
149+ if targetTab := tl.Find(targetID); targetTab != nil {
150+ return targetTab, false, nil
151+ }
152+ // Treat missing as "create new".
153+ }
154+
155+ if uc.idGenerator == nil {
156+ return nil, false, fmt.Errorf("id generator is required to create new tab")
157+ }
158+ tabID := entity.TabID(uc.idGenerator())
159+ wsID := entity.WorkspaceID(uc.idGenerator())
160+ targetTab := entity.NewTab(tabID, wsID, movedPane)
161+ tl.Add(targetTab)
162+ return targetTab, true, nil
163+}
164+
165+func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) {
166+ if ws == nil {
167+ return nil, fmt.Errorf("workspace is required")
168+ }
169+ if movedPane == nil {
170+ return nil, fmt.Errorf("moved pane is required")
171+ }
172+
173+ movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane}
174+
175+ if ws.Root == nil {
176+ ws.Root = movedNode
177+ ws.ActivePaneID = movedPane.ID
178+ return movedNode, nil
179+ }
180+
181+ targetActive := ws.ActivePane()
182+ if targetActive == nil || targetActive.Pane == nil {
183+ return nil, fmt.Errorf("target tab has no active pane")
184+ }
185+
186+ if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil {
187+ return nil, err
188+ }
189+ ws.ActivePaneID = movedPane.ID
190+ return movedNode, nil
191+}
192+
193+func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
194+ if ws == nil {
195+ return fmt.Errorf("workspace is required")
196+ }
197+ if leaf == nil || leaf.Pane == nil {
198+ return fmt.Errorf("pane node is required")
199+ }
200+
201+ detachedFromStack, err := detachFromStackIfNeeded(ws, leaf)
202+ if err != nil {
203+ return err
204+ }
205+ if detachedFromStack {
206+ return nil
207+ }
208+
209+ if !leaf.IsLeaf() {
210+ return fmt.Errorf("can only move leaf panes")
211+ }
212+ return detachLeafFromWorkspace(ws, leaf)
213+}
214+
215+func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) {
216+ if ws == nil || leaf == nil {
217+ return false, nil
218+ }
219+ if leaf.Parent == nil || !leaf.Parent.IsStacked {
220+ return false, nil
221+ }
222+
223+ stackNode := leaf.Parent
224+
225+ idx := -1
226+ for i, child := range stackNode.Children {
227+ if child == leaf {
228+ idx = i
229+ break
230+ }
231+ }
232+ if idx < 0 {
233+ return false, fmt.Errorf("pane not found in stack")
234+ }
235+
236+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239+ }
240+ if stackNode.ActiveStackIndex < 0 {
241+ stackNode.ActiveStackIndex = 0
242+ }
243+
244+ // If only one pane remains in stack, dissolve it into a leaf node.
245+ if len(stackNode.Children) == 1 {
246+ remaining := stackNode.Children[0]
247+ stackNode.Pane = remaining.Pane
248+ stackNode.Children = nil
249+ stackNode.IsStacked = false
250+ stackNode.ActiveStackIndex = 0
251+ if stackNode.Pane != nil {
252+ ws.ActivePaneID = stackNode.Pane.ID
253+ } else {
254+ ws.ActivePaneID = ""
255+ }
256+ return true, nil
257+ }
258+
259+ // Otherwise, set workspace active pane to the stack's current active.
260+ if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {
261+ ws.ActivePaneID = stackNode.ActivePane().Pane.ID
262+ }
263+ return true, nil
264+}
265+
266+func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
267+ parent := leaf.Parent
268+ if parent == nil {
269+ ws.Root = nil
270+ ws.ActivePaneID = ""
271+ return nil
272+ }
273+ if !parent.IsSplit() {
274+ return fmt.Errorf("pane parent is not a split")
275+ }
276+
277+ sibling := findSibling(parent, leaf)
278+ if sibling == nil {
279+ return fmt.Errorf("no sibling found")
280+ }
281+
282+ promoteSibling(ws, parent, sibling)
283+ ws.ActivePaneID = findFirstLeafPaneID(sibling)
284+ return nil
285+}
286+
287+func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode {
288+ if parent == nil {
289+ return nil
290+ }
291+ for _, child := range parent.Children {
292+ if child != leaf {
293+ return child
294+ }
295+ }
296+ return nil
297+}
298+
299+func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) {
300+ if ws == nil || sibling == nil {
301+ return
302+ }
303+
304+ grandparent := parent.Parent
305+ if grandparent == nil {
306+ ws.Root = sibling
307+ sibling.Parent = nil
308+ return
309+ }
310+ for i, child := range grandparent.Children {
311+ if child == parent {
312+ grandparent.Children[i] = sibling
313+ break
314+ }
315+ }
316+ sibling.Parent = grandparent
317+}
318+
319+func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID {
320+ if node == nil {
321+ return ""
322+ }
323+ var active entity.PaneID
324+ node.Walk(func(n *entity.PaneNode) bool {
325+ if n.IsLeaf() && n.Pane != nil {
326+ active = n.Pane.ID
327+ return false
328+ }
329+ return true
330+ })
331+ return active
332+}
333+
334+func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error {
335+ if ws == nil {
336+ return fmt.Errorf("workspace is required")
337+ }
338+ if activeNode == nil {
339+ return fmt.Errorf("active pane is required")
340+ }
341+ if newLeaf == nil || newLeaf.Pane == nil {
342+ return fmt.Errorf("new pane node is required")
343+ }
344+ if idGen == nil {
345+ return fmt.Errorf("id generator is required")
346+ }
347+
348+ targetNode := activeNode
349+ // Split around stacks when inserting left/right.
350+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
351+ targetNode = targetNode.Parent
352+ }
353+
354+ parentID := idGen()
355+ splitParent := &entity.PaneNode{
356+ ID: parentID,
357+ SplitDir: entity.SplitHorizontal,
358+ SplitRatio: 0.5,
359+ Children: make([]*entity.PaneNode, 2),
360+ }
361+
362+ splitParent.Children[0] = targetNode
363+ splitParent.Children[1] = newLeaf
364+
365+ newLeaf.Parent = splitParent
366+ oldParent := targetNode.Parent
367+ targetNode.Parent = splitParent
368+
369+ if oldParent == nil {
370+ ws.Root = splitParent
371+ } else {
372+ for i, child := range oldParent.Children {
373+ if child == targetNode {
374+ oldParent.Children[i] = splitParent
375+ break
376+ }
377+ }
378+ splitParent.Parent = oldParent
379+ }
380+
381+ return nil
382+}
A internal/application/usecase/move_pane_to_tab_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12+ id := newTestIDGen()
13+ uc := NewMovePaneToTabUseCase(id)
14+
15+ tabs := entity.NewTabList()
16+
17+ paneA := entity.NewPane(entity.PaneID("pA"))
18+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19+ tabs.Add(tabA)
20+
21+ paneB := entity.NewPane(entity.PaneID("pB"))
22+ tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23+ tabs.Add(tabB)
24+ // Ensure tabB active pane is paneB.
25+ tabB.Workspace.ActivePaneID = paneB.ID
26+
27+ out, err := uc.Execute(MovePaneToTabInput{
28+ TabList: tabs,
29+ SourceTabID: tabA.ID,
30+ SourcePaneID: paneA.ID,
31+ TargetTabID: tabB.ID,
32+ })
33+ require.NoError(t, err)
34+ require.True(t, out.SourceTabClosed)
35+ require.False(t, out.NewTabCreated)
36+ require.Equal(t, tabB.ID, out.TargetTab.ID)
37+ require.NotNil(t, out.MovedPaneNode)
38+
39+ require.Nil(t, tabs.Find(tabA.ID))
40+ // Inserted as split in tabB.
41+ require.NotNil(t, tabB.Workspace.Root)
42+ require.True(t, tabB.Workspace.Root.IsSplit())
43+ require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44+ require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45+ // Active pane becomes moved pane.
46+ require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47+}
48+
49+func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50+ id := newTestIDGen()
51+ uc := NewMovePaneToTabUseCase(id)
52+
53+ tabs := entity.NewTabList()
54+ paneA := entity.NewPane(entity.PaneID("pA"))
55+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56+ tabs.Add(tabA)
57+
58+ out, err := uc.Execute(MovePaneToTabInput{
59+ TabList: tabs,
60+ SourceTabID: tabA.ID,
61+ SourcePaneID: paneA.ID,
62+ TargetTabID: "",
63+ })
64+ require.NoError(t, err)
65+ require.True(t, out.NewTabCreated)
66+ require.True(t, out.SourceTabClosed)
67+ require.Equal(t, 1, tabs.Count())
68+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69+ require.NotNil(t, out.TargetTab.Workspace.Root)
70+ require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72+}
73+
74+func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75+ uc := NewMovePaneToTabUseCase(newTestIDGen())
76+ abs := entity.NewTabList()
77+ pane := entity.NewPane(entity.PaneID("pA"))
78+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79+ abs.Add(tab)
80+
81+ _, err := uc.Execute(MovePaneToTabInput{
82+ TabList: abs,
83+ SourceTabID: tab.ID,
84+ SourcePaneID: pane.ID,
85+ TargetTabID: tab.ID,
86+ })
87+ require.Error(t, err)
88+}
89+
90+func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91+ uc := NewMovePaneToTabUseCase(newTestIDGen())
92+ tabs := entity.NewTabList()
93+ pane := entity.NewPane(entity.PaneID("pA"))
94+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95+ tabs.Add(tab)
96+
97+ _, err := uc.Execute(MovePaneToTabInput{
98+ TabList: tabs,
99+ SourceTabID: tab.ID,
100+ SourcePaneID: entity.PaneID("missing"),
101+ TargetTabID: entity.TabID("tB"),
102+ })
103+ require.Error(t, err)
104+}
105+
106+func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107+ id := newTestIDGen()
108+ uc := NewMovePaneToTabUseCase(id)
109+
110+ tabs := entity.NewTabList()
111+
112+ // Source workspace: split with two panes; move one, leaving one.
113+ paneLeft := entity.NewPane(entity.PaneID("pL"))
114+ paneRight := entity.NewPane(entity.PaneID("pR"))
115+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116+ // Build a split root manually.
117+ source.Workspace.Root = &entity.PaneNode{
118+ ID: "root",
119+ SplitDir: entity.SplitHorizontal,
120+ SplitRatio: 0.5,
121+ Children: []*entity.PaneNode{
122+ {ID: string(paneLeft.ID), Pane: paneLeft},
123+ {ID: string(paneRight.ID), Pane: paneRight},
124+ },
125+ }
126+ source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127+ source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128+ source.Workspace.ActivePaneID = paneRight.ID
129+ tabs.Add(source)
130+
131+ targetPane := entity.NewPane(entity.PaneID("pT"))
132+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133+ tabs.Add(target)
134+
135+ out, err := uc.Execute(MovePaneToTabInput{
136+ TabList: tabs,
137+ SourceTabID: source.ID,
138+ SourcePaneID: paneRight.ID,
139+ TargetTabID: target.ID,
140+ })
141+ require.NoError(t, err)
142+ require.False(t, out.SourceTabClosed)
143+ // Source should now be single leaf.
144+ require.Equal(t, 1, source.Workspace.PaneCount())
145+ require.NotNil(t, source.Workspace.Root)
146+ require.True(t, source.Workspace.Root.IsLeaf())
147+ require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148+}
149+
150+func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151+ id := newTestIDGen()
152+ uc := NewMovePaneToTabUseCase(id)
153+
154+ tabs := entity.NewTabList()
155+
156+ paneA := entity.NewPane(entity.PaneID("pA"))
157+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158+ tabs.Add(source)
159+
160+ // Target workspace: stacked container with two panes; active is second.
161+ pane1 := entity.NewPane(entity.PaneID("p1"))
162+ pane2 := entity.NewPane(entity.PaneID("p2"))
163+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164+ stack := &entity.PaneNode{
165+ ID: "stack",
166+ IsStacked: true,
167+ ActiveStackIndex: 1,
168+ Children: []*entity.PaneNode{
169+ {ID: string(pane1.ID), Pane: pane1},
170+ {ID: string(pane2.ID), Pane: pane2},
171+ },
172+ }
173+ stack.Children[0].Parent = stack
174+ stack.Children[1].Parent = stack
175+ target.Workspace.Root = stack
176+ target.Workspace.ActivePaneID = pane2.ID
177+ tabs.Add(target)
178+
179+ out, err := uc.Execute(MovePaneToTabInput{
180+ TabList: tabs,
181+ SourceTabID: source.ID,
182+ SourcePaneID: paneA.ID,
183+ TargetTabID: target.ID,
184+ })
185+ require.NoError(t, err)
186+ require.NotNil(t, out.TargetTab.Workspace.Root)
187+ require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188+ // Left child should be the stack container.
189+ require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190+ // Right child is moved pane.
191+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192+}
193+
194+func newTestIDGen() func() string {
195+ counter := 0
196+ return func() string {
197+ counter++
198+ return fmt.Sprintf("id%d", counter)
199+ }
200+}
@@ -0,0 +1,200 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12+ id := newTestIDGen()
13+ uc := NewMovePaneToTabUseCase(id)
14+
15+ tabs := entity.NewTabList()
16+
17+ paneA := entity.NewPane(entity.PaneID("pA"))
18+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19+ tabs.Add(tabA)
20+
21+ paneB := entity.NewPane(entity.PaneID("pB"))
22+ tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23+ tabs.Add(tabB)
24+ // Ensure tabB active pane is paneB.
25+ tabB.Workspace.ActivePaneID = paneB.ID
26+
27+ out, err := uc.Execute(MovePaneToTabInput{
28+ TabList: tabs,
29+ SourceTabID: tabA.ID,
30+ SourcePaneID: paneA.ID,
31+ TargetTabID: tabB.ID,
32+ })
33+ require.NoError(t, err)
34+ require.True(t, out.SourceTabClosed)
35+ require.False(t, out.NewTabCreated)
36+ require.Equal(t, tabB.ID, out.TargetTab.ID)
37+ require.NotNil(t, out.MovedPaneNode)
38+
39+ require.Nil(t, tabs.Find(tabA.ID))
40+ // Inserted as split in tabB.
41+ require.NotNil(t, tabB.Workspace.Root)
42+ require.True(t, tabB.Workspace.Root.IsSplit())
43+ require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44+ require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45+ // Active pane becomes moved pane.
46+ require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47+}
48+
49+func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50+ id := newTestIDGen()
51+ uc := NewMovePaneToTabUseCase(id)
52+
53+ tabs := entity.NewTabList()
54+ paneA := entity.NewPane(entity.PaneID("pA"))
55+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56+ tabs.Add(tabA)
57+
58+ out, err := uc.Execute(MovePaneToTabInput{
59+ TabList: tabs,
60+ SourceTabID: tabA.ID,
61+ SourcePaneID: paneA.ID,
62+ TargetTabID: "",
63+ })
64+ require.NoError(t, err)
65+ require.True(t, out.NewTabCreated)
66+ require.True(t, out.SourceTabClosed)
67+ require.Equal(t, 1, tabs.Count())
68+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69+ require.NotNil(t, out.TargetTab.Workspace.Root)
70+ require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72+}
73+
74+func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75+ uc := NewMovePaneToTabUseCase(newTestIDGen())
76+ abs := entity.NewTabList()
77+ pane := entity.NewPane(entity.PaneID("pA"))
78+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79+ abs.Add(tab)
80+
81+ _, err := uc.Execute(MovePaneToTabInput{
82+ TabList: abs,
83+ SourceTabID: tab.ID,
84+ SourcePaneID: pane.ID,
85+ TargetTabID: tab.ID,
86+ })
87+ require.Error(t, err)
88+}
89+
90+func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91+ uc := NewMovePaneToTabUseCase(newTestIDGen())
92+ tabs := entity.NewTabList()
93+ pane := entity.NewPane(entity.PaneID("pA"))
94+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95+ tabs.Add(tab)
96+
97+ _, err := uc.Execute(MovePaneToTabInput{
98+ TabList: tabs,
99+ SourceTabID: tab.ID,
100+ SourcePaneID: entity.PaneID("missing"),
101+ TargetTabID: entity.TabID("tB"),
102+ })
103+ require.Error(t, err)
104+}
105+
106+func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107+ id := newTestIDGen()
108+ uc := NewMovePaneToTabUseCase(id)
109+
110+ tabs := entity.NewTabList()
111+
112+ // Source workspace: split with two panes; move one, leaving one.
113+ paneLeft := entity.NewPane(entity.PaneID("pL"))
114+ paneRight := entity.NewPane(entity.PaneID("pR"))
115+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116+ // Build a split root manually.
117+ source.Workspace.Root = &entity.PaneNode{
118+ ID: "root",
119+ SplitDir: entity.SplitHorizontal,
120+ SplitRatio: 0.5,
121+ Children: []*entity.PaneNode{
122+ {ID: string(paneLeft.ID), Pane: paneLeft},
123+ {ID: string(paneRight.ID), Pane: paneRight},
124+ },
125+ }
126+ source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127+ source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128+ source.Workspace.ActivePaneID = paneRight.ID
129+ tabs.Add(source)
130+
131+ targetPane := entity.NewPane(entity.PaneID("pT"))
132+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133+ tabs.Add(target)
134+
135+ out, err := uc.Execute(MovePaneToTabInput{
136+ TabList: tabs,
137+ SourceTabID: source.ID,
138+ SourcePaneID: paneRight.ID,
139+ TargetTabID: target.ID,
140+ })
141+ require.NoError(t, err)
142+ require.False(t, out.SourceTabClosed)
143+ // Source should now be single leaf.
144+ require.Equal(t, 1, source.Workspace.PaneCount())
145+ require.NotNil(t, source.Workspace.Root)
146+ require.True(t, source.Workspace.Root.IsLeaf())
147+ require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148+}
149+
150+func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151+ id := newTestIDGen()
152+ uc := NewMovePaneToTabUseCase(id)
153+
154+ tabs := entity.NewTabList()
155+
156+ paneA := entity.NewPane(entity.PaneID("pA"))
157+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158+ tabs.Add(source)
159+
160+ // Target workspace: stacked container with two panes; active is second.
161+ pane1 := entity.NewPane(entity.PaneID("p1"))
162+ pane2 := entity.NewPane(entity.PaneID("p2"))
163+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164+ stack := &entity.PaneNode{
165+ ID: "stack",
166+ IsStacked: true,
167+ ActiveStackIndex: 1,
168+ Children: []*entity.PaneNode{
169+ {ID: string(pane1.ID), Pane: pane1},
170+ {ID: string(pane2.ID), Pane: pane2},
171+ },
172+ }
173+ stack.Children[0].Parent = stack
174+ stack.Children[1].Parent = stack
175+ target.Workspace.Root = stack
176+ target.Workspace.ActivePaneID = pane2.ID
177+ tabs.Add(target)
178+
179+ out, err := uc.Execute(MovePaneToTabInput{
180+ TabList: tabs,
181+ SourceTabID: source.ID,
182+ SourcePaneID: paneA.ID,
183+ TargetTabID: target.ID,
184+ })
185+ require.NoError(t, err)
186+ require.NotNil(t, out.TargetTab.Workspace.Root)
187+ require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188+ // Left child should be the stack container.
189+ require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190+ // Right child is moved pane.
191+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192+}
193+
194+func newTestIDGen() func() string {
195+ counter := 0
196+ return func() string {
197+ counter++
198+ return fmt.Sprintf("id%d", counter)
199+ }
200+}
M internal/domain/entity/tab.go +9 -4
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
7979 tl.Tabs[j].Position = j
8080 }
8181 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {
83- if i < len(tl.Tabs) {
84- tl.ActiveTabID = tl.Tabs[i].ID
82+ if tl.ActiveTabID == id {
83+ if len(tl.Tabs) > 0 {
84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
8589 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
8792 }
8893 }
8994 return true
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
79 tl.Tabs[j].Position = j79 tl.Tabs[j].Position = j
80 }80 }
81 // Update active tab if needed81 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {82+ if tl.ActiveTabID == id {
83- if i < len(tl.Tabs) {83+ if len(tl.Tabs) > 0 {
84- tl.ActiveTabID = tl.Tabs[i].ID84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
85 } else {89 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
87 }92 }
88 }93 }
89 return true94 return true
M internal/infrastructure/config/defaults.go +10 -7
@@ -155,17 +155,20 @@ func DefaultConfig() *Config {
155155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157157 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,
158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
159160 PaneMode: PaneModeConfig{
160161 ActivationShortcut: defaultPaneActivationShortcut,
161162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162163 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},
168- "close-pane": {"x"},
164+ "split-right": {"arrowright", "r"},
165+ "split-left": {"arrowleft", "l"},
166+ "split-up": {"arrowup", "u"},
167+ "split-down": {"arrowdown", "d"},
168+ "stack-pane": {"s"},
169+ "close-pane": {"x"},
170+ "move-pane-to-tab": {"m"},
171+ "move-pane-to-next-tab": {"M", "shift+m"},
169172
170173 "consume-or-expel-left": {"bracketleft", "["},
171174 "consume-or-expel-right": {"bracketright", "]"},
@@ -155,17 +155,20 @@ func DefaultConfig() *Config {
155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157 Workspace: WorkspaceConfig{157 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
159 PaneMode: PaneModeConfig{160 PaneMode: PaneModeConfig{
160 ActivationShortcut: defaultPaneActivationShortcut,161 ActivationShortcut: defaultPaneActivationShortcut,
161 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162 Actions: map[string][]string{163 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},164+ "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},165+ "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},166+ "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},167+ "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},168+ "stack-pane": {"s"},
168- "close-pane": {"x"},169+ "close-pane": {"x"},
170+ "move-pane-to-tab": {"m"},
171+ "move-pane-to-next-tab": {"M", "shift+m"},
169 172
170 "consume-or-expel-left": {"bracketleft", "["},173 "consume-or-expel-left": {"bracketleft", "["},
171 "consume-or-expel-right": {"bracketright", "]"},174 "consume-or-expel-right": {"bracketright", "]"},
M internal/infrastructure/config/loader.go +1 -0
@@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
404+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
404405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
404+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
404 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
M internal/infrastructure/config/schema.go +3 -0
@@ -313,6 +313,9 @@ type WorkspaceConfig struct {
313313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
316+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
317+ // Default: true
318+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
316319 // Popups configures default popup placement rules.
317320 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318321 // Styling configures workspace visual appearance.
@@ -313,6 +313,9 @@ type WorkspaceConfig struct {
313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
316+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
317+ // Default: true
318+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
316 // Popups configures default popup placement rules.319 // Popups configures default popup placement rules.
317 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`320 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318 // Styling configures workspace visual appearance.321 // Styling configures workspace visual appearance.
M internal/ui/app.go +299 -2
@@ -90,6 +90,9 @@ type App struct {
9090
9191 // Session management
9292 sessionManager *component.SessionManager
93+ tabPicker *component.TabPicker
94+ tabPickerWidget layout.Widget
95+ tabPickerPaneID entity.PaneID
9396 snapshotService *snapshot.Service
9497
9598 // Update management
@@ -99,6 +102,8 @@ type App struct {
99102 idCounter uint64
100103 idMu sync.Mutex
101104
105+ movePaneToTabUC *usecase.MovePaneToTabUseCase
106+
102107 // lifecycle
103108 cancel context.CancelCauseFunc
104109 }
@@ -197,6 +202,7 @@ func (a *App) onActivate(ctx context.Context) {
197202 a.initOmniboxConfig(ctx)
198203 a.initFindBarConfig()
199204 a.initSessionManager(ctx)
205+ a.initTabPicker(ctx)
200206 a.initSnapshotService(ctx)
201207 a.initUpdateCoordinator(ctx)
202208 a.createInitialTab(ctx)
@@ -333,10 +339,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
333339 a.handleModeChange(ctx, from, to)
334340 })
335341 a.keyboardHandler.SetShouldBypassInput(func() bool {
336- // Bypass keyboard handler when session manager is visible
342+ // Bypass keyboard handler when modals are visible
337343 if a.sessionManager != nil && a.sessionManager.IsVisible() {
338344 return true
339345 }
346+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
347+ return true
348+ }
340349 wsView := a.activeWorkspaceView()
341350 if wsView == nil {
342351 return false
@@ -480,6 +489,280 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
480489 a.sessionManager.Toggle(ctx)
481490 }
482491
492+func (a *App) attachTabPickerToActivePane() {
493+ if a.tabPicker == nil || a.widgetFactory == nil {
494+ return
495+ }
496+ wsView := a.activeWorkspaceView()
497+ if wsView == nil {
498+ return
499+ }
500+
501+ activeTab := a.tabs.ActiveTab()
502+ if activeTab == nil || activeTab.Workspace == nil {
503+ return
504+ }
505+ activePaneID := activeTab.Workspace.ActivePaneID
506+ if activePaneID == "" {
507+ return
508+ }
509+ pv := wsView.GetPaneView(activePaneID)
510+ if pv == nil {
511+ return
512+ }
513+
514+ if a.tabPickerWidget == nil {
515+ a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
516+ if a.tabPickerWidget == nil {
517+ return
518+ }
519+ }
520+
521+ // If currently attached to a different pane overlay, detach.
522+ if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
523+ for _, view := range a.workspaceViews {
524+ if view == nil {
525+ continue
526+ }
527+ if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
528+ if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
529+ oldPV.RemoveOverlayWidget(a.tabPickerWidget)
530+ } else if parent != nil {
531+ a.tabPickerWidget.Unparent()
532+ }
533+ break
534+ }
535+ }
536+ }
537+
538+ // Ensure the widget can be reparented.
539+ if parent := a.tabPickerWidget.GetParent(); parent != nil {
540+ a.tabPickerWidget.Unparent()
541+ }
542+
543+ a.tabPicker.SetParentOverlay(pv.Overlay())
544+ pv.AddOverlayWidget(a.tabPickerWidget)
545+ a.tabPickerPaneID = activePaneID
546+}
547+
548+func (a *App) HandleMovePaneToTab(ctx context.Context) error {
549+ if a.movePaneToTabUC == nil {
550+ return nil
551+ }
552+ if a.tabPicker == nil {
553+ return nil
554+ }
555+
556+ sourceTab := a.tabs.ActiveTab()
557+ if sourceTab == nil || sourceTab.Workspace == nil {
558+ return nil
559+ }
560+
561+ // If there is only 1 tab, auto-create a new tab and move there.
562+ if a.tabs.Count() <= 1 {
563+ return a.MoveActivePaneToTab(ctx, "")
564+ }
565+
566+ items := make([]component.TabPickerItem, 0, a.tabs.Count())
567+ for _, tab := range a.tabs.Tabs {
568+ if tab == nil {
569+ continue
570+ }
571+ if tab.ID == sourceTab.ID {
572+ continue
573+ }
574+ items = append(items, component.TabPickerItem{
575+ TabID: tab.ID,
576+ Title: tab.Title(),
577+ IsNew: false,
578+ Index: tab.Position,
579+ })
580+ }
581+ items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
582+
583+ a.attachTabPickerToActivePane()
584+ a.tabPicker.Show(ctx, items)
585+ return nil
586+}
587+
588+func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
589+ if a.movePaneToTabUC == nil {
590+ return nil
591+ }
592+ active := a.tabs.ActiveTab()
593+ if active == nil {
594+ return nil
595+ }
596+
597+ nextPos := active.Position + 1
598+ if nextPos >= a.tabs.Count() {
599+ // Create a new tab on the right.
600+ return a.MoveActivePaneToTab(ctx, "")
601+ }
602+ next := a.tabs.TabAt(nextPos)
603+ if next == nil {
604+ return nil
605+ }
606+ return a.MoveActivePaneToTab(ctx, next.ID)
607+}
608+
609+func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
610+ in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
611+ if in == nil {
612+ return nil
613+ }
614+
615+ out, err := a.movePaneToTabUC.Execute(*in)
616+ if err != nil {
617+ return err
618+ }
619+ if out == nil || out.TargetTab == nil {
620+ return nil
621+ }
622+
623+ a.applyMovePaneToTabUI(ctx, out, sourceTab)
624+ a.switchToTargetTabIfConfigured(ctx, out)
625+ a.updateTabBarVisibility(ctx)
626+ a.MarkDirty()
627+ return nil
628+}
629+
630+func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
631+ if a.movePaneToTabUC == nil {
632+ return nil, nil
633+ }
634+ activeTab := a.tabs.ActiveTab()
635+ if activeTab == nil || activeTab.Workspace == nil {
636+ return nil, nil
637+ }
638+ sourcePaneID := activeTab.Workspace.ActivePaneID
639+ if sourcePaneID == "" {
640+ return nil, nil
641+ }
642+
643+ in := &usecase.MovePaneToTabInput{
644+ TabList: a.tabs,
645+ SourceTabID: activeTab.ID,
646+ SourcePaneID: sourcePaneID,
647+ TargetTabID: targetTabID,
648+ }
649+ return in, activeTab
650+}
651+
652+func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
653+ if out == nil || out.TargetTab == nil {
654+ return
655+ }
656+ sourceTabID := entity.TabID("")
657+ if sourceTab != nil {
658+ sourceTabID = sourceTab.ID
659+ }
660+
661+ if out.SourceTabClosed {
662+ a.removeSourceTabUI(sourceTabID)
663+ }
664+ if out.NewTabCreated {
665+ a.ensureTargetTabUI(ctx, out.TargetTab)
666+ }
667+
668+ if !out.SourceTabClosed {
669+ a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
670+ }
671+ a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
672+}
673+
674+func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
675+ delete(a.workspaceViews, sourceTabID)
676+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
677+ a.mainWindow.TabBar().RemoveTab(sourceTabID)
678+ }
679+}
680+
681+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
682+ if tab == nil {
683+ return
684+ }
685+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
686+ a.mainWindow.TabBar().AddTab(tab)
687+ }
688+ a.createWorkspaceViewWithoutAttach(ctx, tab)
689+}
690+
691+func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
692+ wsView := a.workspaceViews[tabID]
693+ if wsView == nil {
694+ return
695+ }
696+ _ = wsView.Rebuild(ctx)
697+
698+ if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
699+ return
700+ }
701+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
702+}
703+
704+func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
705+ if out == nil || out.TargetTab == nil {
706+ return
707+ }
708+
709+ switchToTarget := config.Get().Workspace.SwitchToTabOnMove
710+ if out.SourceTabClosed {
711+ switchToTarget = true
712+ }
713+ if !switchToTarget {
714+ return
715+ }
716+
717+ a.tabs.SetActive(out.TargetTab.ID)
718+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
719+ a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
720+ }
721+ a.switchWorkspaceView(ctx, out.TargetTab.ID)
722+}
723+
724+func (a *App) updateTabBarVisibility(ctx context.Context) {
725+ if a.tabCoord != nil {
726+ a.tabCoord.UpdateBarVisibility(ctx)
727+ }
728+}
729+
730+func (a *App) initTabPicker(ctx context.Context) {
731+ log := logging.FromContext(ctx)
732+
733+ if a.deps == nil || a.deps.Config == nil {
734+ log.Debug().Msg("deps/config not available, skipping tab picker")
735+ return
736+ }
737+
738+ a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
739+ UIScale: a.deps.Config.DefaultUIScale,
740+ OnClose: func() {
741+ log.Debug().Msg("tab picker closed")
742+ },
743+ OnSelect: func(item component.TabPickerItem) {
744+ cb := glib.SourceFunc(func(_ uintptr) bool {
745+ var targetID entity.TabID
746+ if !item.IsNew {
747+ targetID = item.TabID
748+ }
749+ if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
750+ log.Warn().Err(err).Msg("move pane to tab failed")
751+ }
752+ return false
753+ })
754+ glib.IdleAdd(&cb, 0)
755+ },
756+ })
757+
758+ if a.tabPicker == nil {
759+ log.Warn().Msg("failed to create tab picker")
760+ return
761+ }
762+
763+ log.Debug().Msg("tab picker initialized")
764+}
765+
483766 func (a *App) initSnapshotService(ctx context.Context) {
484767 log := logging.FromContext(ctx)
485768
@@ -798,6 +1081,9 @@ func (a *App) initCoordinators(ctx context.Context) {
7981081 return nil
7991082 })
8001083
1084+ // Move pane use case (cross-tab)
1085+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1086+
8011087 // 4. Navigation Coordinator
8021088 a.navCoord = coordinator.NewNavigationCoordinator(
8031089 ctx,
@@ -829,7 +1115,12 @@ func (a *App) initCoordinators(ctx context.Context) {
8291115
8301116 // Hide loading skeleton once the WebView paints
8311117 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
832- if wsView := a.activeWorkspaceView(); wsView != nil {
1118+ // The WebView can be shown while its pane is in a background tab, so scan
1119+ // all WorkspaceViews rather than only the active one.
1120+ for _, wsView := range a.workspaceViews {
1121+ if wsView == nil {
1122+ continue
1123+ }
8331124 if pv := wsView.GetPaneView(paneID); pv != nil {
8341125 pv.HideLoadingSkeleton()
8351126 }
@@ -862,6 +1153,12 @@ func (a *App) initCoordinators(ctx context.Context) {
8621153 a.CloseFindBar(ctx)
8631154 return nil
8641155 })
1156+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1157+ return a.HandleMovePaneToTab(ctx)
1158+ })
1159+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1160+ return a.HandleMovePaneToNextTab(ctx)
1161+ })
8651162
8661163 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
8671164 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
@@ -90,6 +90,9 @@ type App struct {
90 90
91 // Session management91 // Session management
92 sessionManager *component.SessionManager92 sessionManager *component.SessionManager
93+ tabPicker *component.TabPicker
94+ tabPickerWidget layout.Widget
95+ tabPickerPaneID entity.PaneID
93 snapshotService *snapshot.Service96 snapshotService *snapshot.Service
94 97
95 // Update management98 // Update management
@@ -99,6 +102,8 @@ type App struct {
99 idCounter uint64102 idCounter uint64
100 idMu sync.Mutex103 idMu sync.Mutex
101 104
105+ movePaneToTabUC *usecase.MovePaneToTabUseCase
106+
102 // lifecycle107 // lifecycle
103 cancel context.CancelCauseFunc108 cancel context.CancelCauseFunc
104 }109 }
@@ -197,6 +202,7 @@ func (a *App) onActivate(ctx context.Context) {
197 a.initOmniboxConfig(ctx)202 a.initOmniboxConfig(ctx)
198 a.initFindBarConfig()203 a.initFindBarConfig()
199 a.initSessionManager(ctx)204 a.initSessionManager(ctx)
205+ a.initTabPicker(ctx)
200 a.initSnapshotService(ctx)206 a.initSnapshotService(ctx)
201 a.initUpdateCoordinator(ctx)207 a.initUpdateCoordinator(ctx)
202 a.createInitialTab(ctx)208 a.createInitialTab(ctx)
@@ -333,10 +339,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
333 a.handleModeChange(ctx, from, to)339 a.handleModeChange(ctx, from, to)
334 })340 })
335 a.keyboardHandler.SetShouldBypassInput(func() bool {341 a.keyboardHandler.SetShouldBypassInput(func() bool {
336- // Bypass keyboard handler when session manager is visible342+ // Bypass keyboard handler when modals are visible
337 if a.sessionManager != nil && a.sessionManager.IsVisible() {343 if a.sessionManager != nil && a.sessionManager.IsVisible() {
338 return true344 return true
339 }345 }
346+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
347+ return true
348+ }
340 wsView := a.activeWorkspaceView()349 wsView := a.activeWorkspaceView()
341 if wsView == nil {350 if wsView == nil {
342 return false351 return false
@@ -480,6 +489,280 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
480 a.sessionManager.Toggle(ctx)489 a.sessionManager.Toggle(ctx)
481 }490 }
482 491
492+func (a *App) attachTabPickerToActivePane() {
493+ if a.tabPicker == nil || a.widgetFactory == nil {
494+ return
495+ }
496+ wsView := a.activeWorkspaceView()
497+ if wsView == nil {
498+ return
499+ }
500+
501+ activeTab := a.tabs.ActiveTab()
502+ if activeTab == nil || activeTab.Workspace == nil {
503+ return
504+ }
505+ activePaneID := activeTab.Workspace.ActivePaneID
506+ if activePaneID == "" {
507+ return
508+ }
509+ pv := wsView.GetPaneView(activePaneID)
510+ if pv == nil {
511+ return
512+ }
513+
514+ if a.tabPickerWidget == nil {
515+ a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
516+ if a.tabPickerWidget == nil {
517+ return
518+ }
519+ }
520+
521+ // If currently attached to a different pane overlay, detach.
522+ if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
523+ for _, view := range a.workspaceViews {
524+ if view == nil {
525+ continue
526+ }
527+ if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
528+ if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
529+ oldPV.RemoveOverlayWidget(a.tabPickerWidget)
530+ } else if parent != nil {
531+ a.tabPickerWidget.Unparent()
532+ }
533+ break
534+ }
535+ }
536+ }
537+
538+ // Ensure the widget can be reparented.
539+ if parent := a.tabPickerWidget.GetParent(); parent != nil {
540+ a.tabPickerWidget.Unparent()
541+ }
542+
543+ a.tabPicker.SetParentOverlay(pv.Overlay())
544+ pv.AddOverlayWidget(a.tabPickerWidget)
545+ a.tabPickerPaneID = activePaneID
546+}
547+
548+func (a *App) HandleMovePaneToTab(ctx context.Context) error {
549+ if a.movePaneToTabUC == nil {
550+ return nil
551+ }
552+ if a.tabPicker == nil {
553+ return nil
554+ }
555+
556+ sourceTab := a.tabs.ActiveTab()
557+ if sourceTab == nil || sourceTab.Workspace == nil {
558+ return nil
559+ }
560+
561+ // If there is only 1 tab, auto-create a new tab and move there.
562+ if a.tabs.Count() <= 1 {
563+ return a.MoveActivePaneToTab(ctx, "")
564+ }
565+
566+ items := make([]component.TabPickerItem, 0, a.tabs.Count())
567+ for _, tab := range a.tabs.Tabs {
568+ if tab == nil {
569+ continue
570+ }
571+ if tab.ID == sourceTab.ID {
572+ continue
573+ }
574+ items = append(items, component.TabPickerItem{
575+ TabID: tab.ID,
576+ Title: tab.Title(),
577+ IsNew: false,
578+ Index: tab.Position,
579+ })
580+ }
581+ items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
582+
583+ a.attachTabPickerToActivePane()
584+ a.tabPicker.Show(ctx, items)
585+ return nil
586+}
587+
588+func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
589+ if a.movePaneToTabUC == nil {
590+ return nil
591+ }
592+ active := a.tabs.ActiveTab()
593+ if active == nil {
594+ return nil
595+ }
596+
597+ nextPos := active.Position + 1
598+ if nextPos >= a.tabs.Count() {
599+ // Create a new tab on the right.
600+ return a.MoveActivePaneToTab(ctx, "")
601+ }
602+ next := a.tabs.TabAt(nextPos)
603+ if next == nil {
604+ return nil
605+ }
606+ return a.MoveActivePaneToTab(ctx, next.ID)
607+}
608+
609+func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
610+ in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
611+ if in == nil {
612+ return nil
613+ }
614+
615+ out, err := a.movePaneToTabUC.Execute(*in)
616+ if err != nil {
617+ return err
618+ }
619+ if out == nil || out.TargetTab == nil {
620+ return nil
621+ }
622+
623+ a.applyMovePaneToTabUI(ctx, out, sourceTab)
624+ a.switchToTargetTabIfConfigured(ctx, out)
625+ a.updateTabBarVisibility(ctx)
626+ a.MarkDirty()
627+ return nil
628+}
629+
630+func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
631+ if a.movePaneToTabUC == nil {
632+ return nil, nil
633+ }
634+ activeTab := a.tabs.ActiveTab()
635+ if activeTab == nil || activeTab.Workspace == nil {
636+ return nil, nil
637+ }
638+ sourcePaneID := activeTab.Workspace.ActivePaneID
639+ if sourcePaneID == "" {
640+ return nil, nil
641+ }
642+
643+ in := &usecase.MovePaneToTabInput{
644+ TabList: a.tabs,
645+ SourceTabID: activeTab.ID,
646+ SourcePaneID: sourcePaneID,
647+ TargetTabID: targetTabID,
648+ }
649+ return in, activeTab
650+}
651+
652+func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
653+ if out == nil || out.TargetTab == nil {
654+ return
655+ }
656+ sourceTabID := entity.TabID("")
657+ if sourceTab != nil {
658+ sourceTabID = sourceTab.ID
659+ }
660+
661+ if out.SourceTabClosed {
662+ a.removeSourceTabUI(sourceTabID)
663+ }
664+ if out.NewTabCreated {
665+ a.ensureTargetTabUI(ctx, out.TargetTab)
666+ }
667+
668+ if !out.SourceTabClosed {
669+ a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
670+ }
671+ a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
672+}
673+
674+func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
675+ delete(a.workspaceViews, sourceTabID)
676+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
677+ a.mainWindow.TabBar().RemoveTab(sourceTabID)
678+ }
679+}
680+
681+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
682+ if tab == nil {
683+ return
684+ }
685+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
686+ a.mainWindow.TabBar().AddTab(tab)
687+ }
688+ a.createWorkspaceViewWithoutAttach(ctx, tab)
689+}
690+
691+func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
692+ wsView := a.workspaceViews[tabID]
693+ if wsView == nil {
694+ return
695+ }
696+ _ = wsView.Rebuild(ctx)
697+
698+ if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
699+ return
700+ }
701+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
702+}
703+
704+func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
705+ if out == nil || out.TargetTab == nil {
706+ return
707+ }
708+
709+ switchToTarget := config.Get().Workspace.SwitchToTabOnMove
710+ if out.SourceTabClosed {
711+ switchToTarget = true
712+ }
713+ if !switchToTarget {
714+ return
715+ }
716+
717+ a.tabs.SetActive(out.TargetTab.ID)
718+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
719+ a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
720+ }
721+ a.switchWorkspaceView(ctx, out.TargetTab.ID)
722+}
723+
724+func (a *App) updateTabBarVisibility(ctx context.Context) {
725+ if a.tabCoord != nil {
726+ a.tabCoord.UpdateBarVisibility(ctx)
727+ }
728+}
729+
730+func (a *App) initTabPicker(ctx context.Context) {
731+ log := logging.FromContext(ctx)
732+
733+ if a.deps == nil || a.deps.Config == nil {
734+ log.Debug().Msg("deps/config not available, skipping tab picker")
735+ return
736+ }
737+
738+ a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
739+ UIScale: a.deps.Config.DefaultUIScale,
740+ OnClose: func() {
741+ log.Debug().Msg("tab picker closed")
742+ },
743+ OnSelect: func(item component.TabPickerItem) {
744+ cb := glib.SourceFunc(func(_ uintptr) bool {
745+ var targetID entity.TabID
746+ if !item.IsNew {
747+ targetID = item.TabID
748+ }
749+ if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
750+ log.Warn().Err(err).Msg("move pane to tab failed")
751+ }
752+ return false
753+ })
754+ glib.IdleAdd(&cb, 0)
755+ },
756+ })
757+
758+ if a.tabPicker == nil {
759+ log.Warn().Msg("failed to create tab picker")
760+ return
761+ }
762+
763+ log.Debug().Msg("tab picker initialized")
764+}
765+
483 func (a *App) initSnapshotService(ctx context.Context) {766 func (a *App) initSnapshotService(ctx context.Context) {
484 log := logging.FromContext(ctx)767 log := logging.FromContext(ctx)
485 768
@@ -798,6 +1081,9 @@ func (a *App) initCoordinators(ctx context.Context) {
798 return nil1081 return nil
799 })1082 })
800 1083
1084+ // Move pane use case (cross-tab)
1085+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1086+
801 // 4. Navigation Coordinator1087 // 4. Navigation Coordinator
802 a.navCoord = coordinator.NewNavigationCoordinator(1088 a.navCoord = coordinator.NewNavigationCoordinator(
803 ctx,1089 ctx,
@@ -829,7 +1115,12 @@ func (a *App) initCoordinators(ctx context.Context) {
829 1115
830 // Hide loading skeleton once the WebView paints1116 // Hide loading skeleton once the WebView paints
831 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {1117 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
832- if wsView := a.activeWorkspaceView(); wsView != nil {1118+ // The WebView can be shown while its pane is in a background tab, so scan
1119+ // all WorkspaceViews rather than only the active one.
1120+ for _, wsView := range a.workspaceViews {
1121+ if wsView == nil {
1122+ continue
1123+ }
833 if pv := wsView.GetPaneView(paneID); pv != nil {1124 if pv := wsView.GetPaneView(paneID); pv != nil {
834 pv.HideLoadingSkeleton()1125 pv.HideLoadingSkeleton()
835 }1126 }
@@ -862,6 +1153,12 @@ func (a *App) initCoordinators(ctx context.Context) {
862 a.CloseFindBar(ctx)1153 a.CloseFindBar(ctx)
863 return nil1154 return nil
864 })1155 })
1156+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1157+ return a.HandleMovePaneToTab(ctx)
1158+ })
1159+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1160+ return a.HandleMovePaneToNextTab(ctx)
1161+ })
865 1162
866 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)1163 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
867 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {1164 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
M internal/ui/component/modal_sizing.go +15 -0
@@ -65,6 +65,21 @@ var SessionManagerListDefaults = ListDisplayDefaults{
6565 MaxResults: 50,
6666 }
6767
68+// TabPickerSizeDefaults provides default sizing for tab picker modal.
69+var TabPickerSizeDefaults = ModalSizeConfig{
70+ WidthPct: 0.6,
71+ MaxWidth: 600,
72+ TopMarginPct: 0.15,
73+ FallbackWidth: 600,
74+ FallbackHeight: 600,
75+}
76+
77+// TabPickerListDefaults provides display limits for tab picker modal.
78+var TabPickerListDefaults = ListDisplayDefaults{
79+ MaxVisibleRows: 8,
80+ MaxResults: 20,
81+}
82+
6883 // CalculateModalDimensions computes width and top margin based on parent overlay.
6984 // Returns calculated width and top margin in pixels.
7085 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
@@ -65,6 +65,21 @@ var SessionManagerListDefaults = ListDisplayDefaults{
65 MaxResults: 50,65 MaxResults: 50,
66 }66 }
67 67
68+// TabPickerSizeDefaults provides default sizing for tab picker modal.
69+var TabPickerSizeDefaults = ModalSizeConfig{
70+ WidthPct: 0.6,
71+ MaxWidth: 600,
72+ TopMarginPct: 0.15,
73+ FallbackWidth: 600,
74+ FallbackHeight: 600,
75+}
76+
77+// TabPickerListDefaults provides display limits for tab picker modal.
78+var TabPickerListDefaults = ListDisplayDefaults{
79+ MaxVisibleRows: 8,
80+ MaxResults: 20,
81+}
82+
68 // CalculateModalDimensions computes width and top margin based on parent overlay.83 // CalculateModalDimensions computes width and top margin based on parent overlay.
69 // Returns calculated width and top margin in pixels.84 // Returns calculated width and top margin in pixels.
70 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {85 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
M internal/ui/component/pane_view.go +10 -1
@@ -144,13 +144,22 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
144144 pv.webViewWidget = widget
145145
146146 if widget != nil {
147+ // If this widget already had a parent, we're reparenting an existing WebView
148+ // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149+ // already painted, so the loading skeleton should be hidden immediately.
150+ hadParent := widget.GetParent() != nil
151+
147152 // Unparent widget from any previous parent (critical for rebuild scenarios)
148153 // In GTK4, a widget can only have one parent at a time
149- if widget.GetParent() != nil {
154+ if hadParent {
150155 widget.Unparent()
151156 }
152157 widget.SetVisible(true)
153158 pv.overlay.SetChild(widget)
159+
160+ if hadParent && pv.loading != nil {
161+ pv.loading.SetVisible(false)
162+ }
154163 }
155164 }
156165
@@ -144,13 +144,22 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
144 pv.webViewWidget = widget144 pv.webViewWidget = widget
145 145
146 if widget != nil {146 if widget != nil {
147+ // If this widget already had a parent, we're reparenting an existing WebView
148+ // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149+ // already painted, so the loading skeleton should be hidden immediately.
150+ hadParent := widget.GetParent() != nil
151+
147 // Unparent widget from any previous parent (critical for rebuild scenarios)152 // Unparent widget from any previous parent (critical for rebuild scenarios)
148 // In GTK4, a widget can only have one parent at a time153 // In GTK4, a widget can only have one parent at a time
149- if widget.GetParent() != nil {154+ if hadParent {
150 widget.Unparent()155 widget.Unparent()
151 }156 }
152 widget.SetVisible(true)157 widget.SetVisible(true)
153 pv.overlay.SetChild(widget)158 pv.overlay.SetChild(widget)
159+
160+ if hadParent && pv.loading != nil {
161+ pv.loading.SetVisible(false)
162+ }
154 }163 }
155 }164 }
156 165
A internal/ui/component/tab_picker.go +482 -0
@@ -0,0 +1,482 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/jwijenbergh/puregotk/v4/gtk"
12+)
13+
14+type TabPickerItem struct {
15+ TabID entity.TabID
16+ Title string
17+ IsNew bool
18+ Index int // 0-based index in tab list, -1 for + New
19+}
20+
21+// TabPicker is a modal overlay for selecting a tab destination.
22+//
23+// It is intentionally similar to SessionManager/Omnibox patterns.
24+type TabPicker struct {
25+ // GTK widgets
26+ outerBox *gtk.Box
27+ mainBox *gtk.Box
28+ headerBox *gtk.Box
29+ titleLabel *gtk.Label
30+ scrolledWindow *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+ footerLabel *gtk.Label
33+
34+ parentOverlay layout.OverlayWidget
35+
36+ mu sync.RWMutex
37+ visible bool
38+ items []TabPickerItem
39+ selectedIndex int
40+ uiScale float64
41+
42+ onClose func()
43+ onSelect func(item TabPickerItem)
44+
45+ retainedCallbacks []interface{}
46+ ctx context.Context
47+}
48+
49+type TabPickerConfig struct {
50+ UIScale float64
51+ OnClose func()
52+ OnSelect func(item TabPickerItem)
53+}
54+
55+func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56+ uiScale := cfg.UIScale
57+ if uiScale <= 0 {
58+ uiScale = 1.0
59+ }
60+
61+ tp := &TabPicker{
62+ ctx: ctx,
63+ onClose: cfg.OnClose,
64+ onSelect: cfg.OnSelect,
65+ selectedIndex: 0,
66+ uiScale: uiScale,
67+ }
68+
69+ if err := tp.createWidgets(); err != nil {
70+ return nil
71+ }
72+ tp.attachKeyController()
73+ return tp
74+}
75+
76+func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77+ tp.mu.Lock()
78+ defer tp.mu.Unlock()
79+ tp.parentOverlay = overlay
80+}
81+
82+func (tp *TabPicker) Widget() *gtk.Widget {
83+ if tp.outerBox == nil {
84+ return nil
85+ }
86+ return &tp.outerBox.Widget
87+}
88+
89+func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90+ if tp.outerBox == nil {
91+ return nil
92+ }
93+ return factory.WrapWidget(&tp.outerBox.Widget)
94+}
95+
96+func (tp *TabPicker) IsVisible() bool {
97+ tp.mu.RLock()
98+ defer tp.mu.RUnlock()
99+ return tp.visible
100+}
101+
102+func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103+ tp.mu.Lock()
104+ if tp.visible {
105+ tp.mu.Unlock()
106+ return
107+ }
108+ tp.visible = true
109+ tp.items = items
110+ tp.selectedIndex = 0
111+ tp.mu.Unlock()
112+
113+ tp.populateList()
114+ tp.resizeAndCenter()
115+ if tp.outerBox != nil {
116+ tp.outerBox.SetVisible(true)
117+ }
118+ if tp.listBox != nil {
119+ tp.listBox.GrabFocus()
120+ }
121+}
122+
123+func (tp *TabPicker) Hide(ctx context.Context) {
124+ tp.mu.Lock()
125+ if !tp.visible {
126+ tp.mu.Unlock()
127+ return
128+ }
129+ tp.visible = false
130+ tp.mu.Unlock()
131+
132+ if tp.outerBox != nil {
133+ tp.outerBox.SetVisible(false)
134+ }
135+ if tp.listBox != nil {
136+ tp.listBox.RemoveAll()
137+ }
138+
139+ if tp.onClose != nil {
140+ tp.onClose()
141+ }
142+}
143+
144+func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145+ if tp.IsVisible() {
146+ tp.Hide(ctx)
147+ } else {
148+ tp.Show(ctx, items)
149+ }
150+}
151+
152+func (tp *TabPicker) createWidgets() error {
153+ if err := tp.createOuter(); err != nil {
154+ return err
155+ }
156+ if err := tp.createMain(); err != nil {
157+ return err
158+ }
159+ if err := tp.createHeader(); err != nil {
160+ return err
161+ }
162+ if err := tp.createList(); err != nil {
163+ return err
164+ }
165+ if err := tp.createFooter(); err != nil {
166+ return err
167+ }
168+ tp.assemble()
169+ return nil
170+}
171+
172+func (tp *TabPicker) createOuter() error {
173+ tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174+ if tp.outerBox == nil {
175+ return errNilWidget("tabPickerOuterBox")
176+ }
177+ tp.outerBox.AddCssClass("tab-picker-outer")
178+ tp.outerBox.SetHalign(gtk.AlignCenterValue)
179+ tp.outerBox.SetValign(gtk.AlignStartValue)
180+ tp.outerBox.SetVisible(false)
181+ return nil
182+}
183+
184+func (tp *TabPicker) createMain() error {
185+ tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186+ if tp.mainBox == nil {
187+ return errNilWidget("tabPickerMainBox")
188+ }
189+ tp.mainBox.AddCssClass("tab-picker-container")
190+ return nil
191+}
192+
193+func (tp *TabPicker) createHeader() error {
194+ tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195+ if tp.headerBox == nil {
196+ return errNilWidget("tabPickerHeaderBox")
197+ }
198+ tp.headerBox.AddCssClass("tab-picker-header")
199+
200+ title := "Move Pane To Tab"
201+ tp.titleLabel = gtk.NewLabel(&title)
202+ if tp.titleLabel == nil {
203+ return errNilWidget("tabPickerTitleLabel")
204+ }
205+ tp.titleLabel.AddCssClass("tab-picker-title")
206+ tp.titleLabel.SetHalign(gtk.AlignStartValue)
207+ tp.titleLabel.SetHexpand(true)
208+ tp.headerBox.Append(&tp.titleLabel.Widget)
209+
210+ shortcutText := "Ctrl+P m"
211+ shortcutLabel := gtk.NewLabel(&shortcutText)
212+ if shortcutLabel != nil {
213+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214+ tp.headerBox.Append(&shortcutLabel.Widget)
215+ }
216+ return nil
217+}
218+
219+func (tp *TabPicker) createList() error {
220+ tp.scrolledWindow = gtk.NewScrolledWindow()
221+ if tp.scrolledWindow == nil {
222+ return errNilWidget("tabPickerScrolledWindow")
223+ }
224+ tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225+ tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226+ tp.scrolledWindow.SetPropagateNaturalHeight(false)
227+
228+ tp.listBox = gtk.NewListBox()
229+ if tp.listBox == nil {
230+ return errNilWidget("tabPickerListBox")
231+ }
232+ tp.listBox.AddCssClass("tab-picker-list")
233+ tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234+
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ if rowPtr == 0 {
237+ return
238+ }
239+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240+ if row == nil {
241+ return
242+ }
243+ idx := row.GetIndex()
244+ tp.mu.Lock()
245+ tp.selectedIndex = idx
246+ tp.mu.Unlock()
247+ }
248+ tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249+ tp.listBox.ConnectRowSelected(&rowSelectedCb)
250+
251+ tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252+ return nil
253+}
254+
255+func (tp *TabPicker) createFooter() error {
256+ footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257+ tp.footerLabel = gtk.NewLabel(&footerText)
258+ if tp.footerLabel == nil {
259+ return errNilWidget("tabPickerFooterLabel")
260+ }
261+ tp.footerLabel.AddCssClass("tab-picker-footer")
262+ tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263+ return nil
264+}
265+
266+func (tp *TabPicker) assemble() {
267+ if tp.outerBox == nil || tp.mainBox == nil {
268+ return
269+ }
270+ if tp.headerBox != nil {
271+ tp.mainBox.Append(&tp.headerBox.Widget)
272+ }
273+ if tp.scrolledWindow != nil {
274+ tp.mainBox.Append(&tp.scrolledWindow.Widget)
275+ }
276+ if tp.footerLabel != nil {
277+ tp.mainBox.Append(&tp.footerLabel.Widget)
278+ }
279+ tp.outerBox.Append(&tp.mainBox.Widget)
280+}
281+
282+func (tp *TabPicker) resizeAndCenter() {
283+ if tp.outerBox == nil || tp.mainBox == nil {
284+ return
285+ }
286+
287+ width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288+ tp.mainBox.SetSizeRequest(width, -1)
289+ tp.outerBox.SetMarginTop(marginTop)
290+
291+ // Height based on rows
292+ tp.mu.RLock()
293+ count := len(tp.items)
294+ tp.mu.RUnlock()
295+
296+ if tp.scrolledWindow != nil {
297+ rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298+ maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299+ h := count * rowH
300+ if h > maxH {
301+ h = maxH
302+ }
303+ if h < rowH {
304+ h = rowH
305+ }
306+ cb := glib.SourceFunc(func(_ uintptr) bool {
307+ SetScrolledWindowHeight(tp.scrolledWindow, h)
308+ tp.outerBox.QueueResize()
309+ return false
310+ })
311+ glib.IdleAdd(&cb, 0)
312+ }
313+}
314+
315+func (tp *TabPicker) populateList() {
316+ if tp.listBox == nil {
317+ return
318+ }
319+ p := tp.listBox
320+ p.RemoveAll()
321+
322+ tp.mu.RLock()
323+ items := append([]TabPickerItem(nil), tp.items...)
324+ tp.mu.RUnlock()
325+
326+ for _, it := range items {
327+ row := gtk.NewListBoxRow()
328+ if row == nil {
329+ continue
330+ }
331+ row.AddCssClass("tab-picker-row")
332+
333+ const rowSpacing = 8
334+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335+ if hbox == nil {
336+ continue
337+ }
338+ hbox.SetHexpand(true)
339+
340+ labelText := it.Title
341+ if it.IsNew {
342+ labelText = "+ New Tab"
343+ }
344+
345+ label := gtk.NewLabel(&labelText)
346+ if label != nil {
347+ label.AddCssClass("tab-picker-row-title")
348+ label.SetHalign(gtk.AlignStartValue)
349+ label.SetHexpand(true)
350+ hbox.Append(&label.Widget)
351+ }
352+
353+ row.SetChild(&hbox.Widget)
354+ p.Append(&row.Widget)
355+ }
356+
357+ // select first row
358+ if row := p.GetRowAtIndex(0); row != nil {
359+ p.SelectRow(row)
360+ }
361+}
362+
363+func (tp *TabPicker) attachKeyController() {
364+ controller := gtk.NewEventControllerKey()
365+ if controller == nil {
366+ return
367+ }
368+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369+
370+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371+ return tp.handleKeyPress(keyval)
372+ }
373+ tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374+ controller.ConnectKeyPressed(&keyPressedCb)
375+ tp.outerBox.AddController(&controller.EventController)
376+}
377+
378+func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379+ switch keyval {
380+ case uint(gdk.KEY_Escape):
381+ tp.Hide(tp.ctx)
382+ return true
383+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384+ tp.selectRelative(-1)
385+ return true
386+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387+ tp.selectRelative(1)
388+ return true
389+ case uint(gdk.KEY_n):
390+ tp.chooseNewTab()
391+ return true
392+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393+ tp.confirmSelection()
394+ return true
395+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397+ idx := int(keyval - uint(gdk.KEY_1))
398+ tp.selectIndex(idx)
399+ tp.confirmSelection()
400+ return true
401+ default:
402+ return false
403+ }
404+}
405+
406+func (tp *TabPicker) selectRelative(delta int) {
407+ tp.mu.Lock()
408+ defer tp.mu.Unlock()
409+
410+ count := len(tp.items)
411+ if count == 0 {
412+ return
413+ }
414+
415+ i := tp.selectedIndex + delta
416+ if i < 0 {
417+ i = count - 1
418+ } else if i >= count {
419+ i = 0
420+ }
421+ tp.selectedIndex = i
422+ if tp.listBox != nil {
423+ if row := tp.listBox.GetRowAtIndex(i); row != nil {
424+ tp.listBox.SelectRow(row)
425+ row.GrabFocus()
426+ }
427+ }
428+}
429+
430+func (tp *TabPicker) selectIndex(index int) {
431+ tp.mu.Lock()
432+ defer tp.mu.Unlock()
433+
434+ count := len(tp.items)
435+ if count == 0 {
436+ return
437+ }
438+ if index < 0 {
439+ index = 0
440+ }
441+ if index >= count {
442+ index = count - 1
443+ }
444+ tp.selectedIndex = index
445+ if tp.listBox != nil {
446+ if row := tp.listBox.GetRowAtIndex(index); row != nil {
447+ tp.listBox.SelectRow(row)
448+ row.GrabFocus()
449+ }
450+ }
451+}
452+
453+func (tp *TabPicker) chooseNewTab() {
454+ tp.mu.RLock()
455+ items := append([]TabPickerItem(nil), tp.items...)
456+ tp.mu.RUnlock()
457+
458+ for i := range items {
459+ if items[i].IsNew {
460+ tp.selectIndex(i)
461+ tp.confirmSelection()
462+ return
463+ }
464+ }
465+}
466+
467+func (tp *TabPicker) confirmSelection() {
468+ tp.mu.RLock()
469+ idx := tp.selectedIndex
470+ items := append([]TabPickerItem(nil), tp.items...)
471+ tp.mu.RUnlock()
472+
473+ if idx < 0 || idx >= len(items) {
474+ return
475+ }
476+ item := items[idx]
477+
478+ tp.Hide(tp.ctx)
479+ if tp.onSelect != nil {
480+ tp.onSelect(item)
481+ }
482+}
@@ -0,0 +1,482 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/jwijenbergh/puregotk/v4/gtk"
12+)
13+
14+type TabPickerItem struct {
15+ TabID entity.TabID
16+ Title string
17+ IsNew bool
18+ Index int // 0-based index in tab list, -1 for + New
19+}
20+
21+// TabPicker is a modal overlay for selecting a tab destination.
22+//
23+// It is intentionally similar to SessionManager/Omnibox patterns.
24+type TabPicker struct {
25+ // GTK widgets
26+ outerBox *gtk.Box
27+ mainBox *gtk.Box
28+ headerBox *gtk.Box
29+ titleLabel *gtk.Label
30+ scrolledWindow *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+ footerLabel *gtk.Label
33+
34+ parentOverlay layout.OverlayWidget
35+
36+ mu sync.RWMutex
37+ visible bool
38+ items []TabPickerItem
39+ selectedIndex int
40+ uiScale float64
41+
42+ onClose func()
43+ onSelect func(item TabPickerItem)
44+
45+ retainedCallbacks []interface{}
46+ ctx context.Context
47+}
48+
49+type TabPickerConfig struct {
50+ UIScale float64
51+ OnClose func()
52+ OnSelect func(item TabPickerItem)
53+}
54+
55+func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56+ uiScale := cfg.UIScale
57+ if uiScale <= 0 {
58+ uiScale = 1.0
59+ }
60+
61+ tp := &TabPicker{
62+ ctx: ctx,
63+ onClose: cfg.OnClose,
64+ onSelect: cfg.OnSelect,
65+ selectedIndex: 0,
66+ uiScale: uiScale,
67+ }
68+
69+ if err := tp.createWidgets(); err != nil {
70+ return nil
71+ }
72+ tp.attachKeyController()
73+ return tp
74+}
75+
76+func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77+ tp.mu.Lock()
78+ defer tp.mu.Unlock()
79+ tp.parentOverlay = overlay
80+}
81+
82+func (tp *TabPicker) Widget() *gtk.Widget {
83+ if tp.outerBox == nil {
84+ return nil
85+ }
86+ return &tp.outerBox.Widget
87+}
88+
89+func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90+ if tp.outerBox == nil {
91+ return nil
92+ }
93+ return factory.WrapWidget(&tp.outerBox.Widget)
94+}
95+
96+func (tp *TabPicker) IsVisible() bool {
97+ tp.mu.RLock()
98+ defer tp.mu.RUnlock()
99+ return tp.visible
100+}
101+
102+func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103+ tp.mu.Lock()
104+ if tp.visible {
105+ tp.mu.Unlock()
106+ return
107+ }
108+ tp.visible = true
109+ tp.items = items
110+ tp.selectedIndex = 0
111+ tp.mu.Unlock()
112+
113+ tp.populateList()
114+ tp.resizeAndCenter()
115+ if tp.outerBox != nil {
116+ tp.outerBox.SetVisible(true)
117+ }
118+ if tp.listBox != nil {
119+ tp.listBox.GrabFocus()
120+ }
121+}
122+
123+func (tp *TabPicker) Hide(ctx context.Context) {
124+ tp.mu.Lock()
125+ if !tp.visible {
126+ tp.mu.Unlock()
127+ return
128+ }
129+ tp.visible = false
130+ tp.mu.Unlock()
131+
132+ if tp.outerBox != nil {
133+ tp.outerBox.SetVisible(false)
134+ }
135+ if tp.listBox != nil {
136+ tp.listBox.RemoveAll()
137+ }
138+
139+ if tp.onClose != nil {
140+ tp.onClose()
141+ }
142+}
143+
144+func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145+ if tp.IsVisible() {
146+ tp.Hide(ctx)
147+ } else {
148+ tp.Show(ctx, items)
149+ }
150+}
151+
152+func (tp *TabPicker) createWidgets() error {
153+ if err := tp.createOuter(); err != nil {
154+ return err
155+ }
156+ if err := tp.createMain(); err != nil {
157+ return err
158+ }
159+ if err := tp.createHeader(); err != nil {
160+ return err
161+ }
162+ if err := tp.createList(); err != nil {
163+ return err
164+ }
165+ if err := tp.createFooter(); err != nil {
166+ return err
167+ }
168+ tp.assemble()
169+ return nil
170+}
171+
172+func (tp *TabPicker) createOuter() error {
173+ tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174+ if tp.outerBox == nil {
175+ return errNilWidget("tabPickerOuterBox")
176+ }
177+ tp.outerBox.AddCssClass("tab-picker-outer")
178+ tp.outerBox.SetHalign(gtk.AlignCenterValue)
179+ tp.outerBox.SetValign(gtk.AlignStartValue)
180+ tp.outerBox.SetVisible(false)
181+ return nil
182+}
183+
184+func (tp *TabPicker) createMain() error {
185+ tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186+ if tp.mainBox == nil {
187+ return errNilWidget("tabPickerMainBox")
188+ }
189+ tp.mainBox.AddCssClass("tab-picker-container")
190+ return nil
191+}
192+
193+func (tp *TabPicker) createHeader() error {
194+ tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195+ if tp.headerBox == nil {
196+ return errNilWidget("tabPickerHeaderBox")
197+ }
198+ tp.headerBox.AddCssClass("tab-picker-header")
199+
200+ title := "Move Pane To Tab"
201+ tp.titleLabel = gtk.NewLabel(&title)
202+ if tp.titleLabel == nil {
203+ return errNilWidget("tabPickerTitleLabel")
204+ }
205+ tp.titleLabel.AddCssClass("tab-picker-title")
206+ tp.titleLabel.SetHalign(gtk.AlignStartValue)
207+ tp.titleLabel.SetHexpand(true)
208+ tp.headerBox.Append(&tp.titleLabel.Widget)
209+
210+ shortcutText := "Ctrl+P m"
211+ shortcutLabel := gtk.NewLabel(&shortcutText)
212+ if shortcutLabel != nil {
213+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214+ tp.headerBox.Append(&shortcutLabel.Widget)
215+ }
216+ return nil
217+}
218+
219+func (tp *TabPicker) createList() error {
220+ tp.scrolledWindow = gtk.NewScrolledWindow()
221+ if tp.scrolledWindow == nil {
222+ return errNilWidget("tabPickerScrolledWindow")
223+ }
224+ tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225+ tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226+ tp.scrolledWindow.SetPropagateNaturalHeight(false)
227+
228+ tp.listBox = gtk.NewListBox()
229+ if tp.listBox == nil {
230+ return errNilWidget("tabPickerListBox")
231+ }
232+ tp.listBox.AddCssClass("tab-picker-list")
233+ tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234+
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ if rowPtr == 0 {
237+ return
238+ }
239+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240+ if row == nil {
241+ return
242+ }
243+ idx := row.GetIndex()
244+ tp.mu.Lock()
245+ tp.selectedIndex = idx
246+ tp.mu.Unlock()
247+ }
248+ tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249+ tp.listBox.ConnectRowSelected(&rowSelectedCb)
250+
251+ tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252+ return nil
253+}
254+
255+func (tp *TabPicker) createFooter() error {
256+ footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257+ tp.footerLabel = gtk.NewLabel(&footerText)
258+ if tp.footerLabel == nil {
259+ return errNilWidget("tabPickerFooterLabel")
260+ }
261+ tp.footerLabel.AddCssClass("tab-picker-footer")
262+ tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263+ return nil
264+}
265+
266+func (tp *TabPicker) assemble() {
267+ if tp.outerBox == nil || tp.mainBox == nil {
268+ return
269+ }
270+ if tp.headerBox != nil {
271+ tp.mainBox.Append(&tp.headerBox.Widget)
272+ }
273+ if tp.scrolledWindow != nil {
274+ tp.mainBox.Append(&tp.scrolledWindow.Widget)
275+ }
276+ if tp.footerLabel != nil {
277+ tp.mainBox.Append(&tp.footerLabel.Widget)
278+ }
279+ tp.outerBox.Append(&tp.mainBox.Widget)
280+}
281+
282+func (tp *TabPicker) resizeAndCenter() {
283+ if tp.outerBox == nil || tp.mainBox == nil {
284+ return
285+ }
286+
287+ width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288+ tp.mainBox.SetSizeRequest(width, -1)
289+ tp.outerBox.SetMarginTop(marginTop)
290+
291+ // Height based on rows
292+ tp.mu.RLock()
293+ count := len(tp.items)
294+ tp.mu.RUnlock()
295+
296+ if tp.scrolledWindow != nil {
297+ rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298+ maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299+ h := count * rowH
300+ if h > maxH {
301+ h = maxH
302+ }
303+ if h < rowH {
304+ h = rowH
305+ }
306+ cb := glib.SourceFunc(func(_ uintptr) bool {
307+ SetScrolledWindowHeight(tp.scrolledWindow, h)
308+ tp.outerBox.QueueResize()
309+ return false
310+ })
311+ glib.IdleAdd(&cb, 0)
312+ }
313+}
314+
315+func (tp *TabPicker) populateList() {
316+ if tp.listBox == nil {
317+ return
318+ }
319+ p := tp.listBox
320+ p.RemoveAll()
321+
322+ tp.mu.RLock()
323+ items := append([]TabPickerItem(nil), tp.items...)
324+ tp.mu.RUnlock()
325+
326+ for _, it := range items {
327+ row := gtk.NewListBoxRow()
328+ if row == nil {
329+ continue
330+ }
331+ row.AddCssClass("tab-picker-row")
332+
333+ const rowSpacing = 8
334+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335+ if hbox == nil {
336+ continue
337+ }
338+ hbox.SetHexpand(true)
339+
340+ labelText := it.Title
341+ if it.IsNew {
342+ labelText = "+ New Tab"
343+ }
344+
345+ label := gtk.NewLabel(&labelText)
346+ if label != nil {
347+ label.AddCssClass("tab-picker-row-title")
348+ label.SetHalign(gtk.AlignStartValue)
349+ label.SetHexpand(true)
350+ hbox.Append(&label.Widget)
351+ }
352+
353+ row.SetChild(&hbox.Widget)
354+ p.Append(&row.Widget)
355+ }
356+
357+ // select first row
358+ if row := p.GetRowAtIndex(0); row != nil {
359+ p.SelectRow(row)
360+ }
361+}
362+
363+func (tp *TabPicker) attachKeyController() {
364+ controller := gtk.NewEventControllerKey()
365+ if controller == nil {
366+ return
367+ }
368+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369+
370+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371+ return tp.handleKeyPress(keyval)
372+ }
373+ tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374+ controller.ConnectKeyPressed(&keyPressedCb)
375+ tp.outerBox.AddController(&controller.EventController)
376+}
377+
378+func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379+ switch keyval {
380+ case uint(gdk.KEY_Escape):
381+ tp.Hide(tp.ctx)
382+ return true
383+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384+ tp.selectRelative(-1)
385+ return true
386+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387+ tp.selectRelative(1)
388+ return true
389+ case uint(gdk.KEY_n):
390+ tp.chooseNewTab()
391+ return true
392+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393+ tp.confirmSelection()
394+ return true
395+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397+ idx := int(keyval - uint(gdk.KEY_1))
398+ tp.selectIndex(idx)
399+ tp.confirmSelection()
400+ return true
401+ default:
402+ return false
403+ }
404+}
405+
406+func (tp *TabPicker) selectRelative(delta int) {
407+ tp.mu.Lock()
408+ defer tp.mu.Unlock()
409+
410+ count := len(tp.items)
411+ if count == 0 {
412+ return
413+ }
414+
415+ i := tp.selectedIndex + delta
416+ if i < 0 {
417+ i = count - 1
418+ } else if i >= count {
419+ i = 0
420+ }
421+ tp.selectedIndex = i
422+ if tp.listBox != nil {
423+ if row := tp.listBox.GetRowAtIndex(i); row != nil {
424+ tp.listBox.SelectRow(row)
425+ row.GrabFocus()
426+ }
427+ }
428+}
429+
430+func (tp *TabPicker) selectIndex(index int) {
431+ tp.mu.Lock()
432+ defer tp.mu.Unlock()
433+
434+ count := len(tp.items)
435+ if count == 0 {
436+ return
437+ }
438+ if index < 0 {
439+ index = 0
440+ }
441+ if index >= count {
442+ index = count - 1
443+ }
444+ tp.selectedIndex = index
445+ if tp.listBox != nil {
446+ if row := tp.listBox.GetRowAtIndex(index); row != nil {
447+ tp.listBox.SelectRow(row)
448+ row.GrabFocus()
449+ }
450+ }
451+}
452+
453+func (tp *TabPicker) chooseNewTab() {
454+ tp.mu.RLock()
455+ items := append([]TabPickerItem(nil), tp.items...)
456+ tp.mu.RUnlock()
457+
458+ for i := range items {
459+ if items[i].IsNew {
460+ tp.selectIndex(i)
461+ tp.confirmSelection()
462+ return
463+ }
464+ }
465+}
466+
467+func (tp *TabPicker) confirmSelection() {
468+ tp.mu.RLock()
469+ idx := tp.selectedIndex
470+ items := append([]TabPickerItem(nil), tp.items...)
471+ tp.mu.RUnlock()
472+
473+ if idx < 0 || idx >= len(items) {
474+ return
475+ }
476+ item := items[idx]
477+
478+ tp.Hide(tp.ctx)
479+ if tp.onSelect != nil {
480+ tp.onSelect(item)
481+ }
482+}
M internal/ui/component/workspace_view.go +12 -2
@@ -608,7 +608,13 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
608608
609609 // Remove from pane overlay
610610 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
611- pv.RemoveOverlayWidget(wv.omniboxWidget)
611+ // The omnibox widget may have been reparented during a rebuild/move.
612+ // Avoid GTK criticals by only removing from the overlay that owns it.
613+ if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
614+ pv.RemoveOverlayWidget(wv.omniboxWidget)
615+ } else if parent != nil {
616+ wv.omniboxWidget.Unparent()
617+ }
612618 }
613619
614620 // Clear references
@@ -715,7 +721,11 @@ func (wv *WorkspaceView) hideFindBarInternal() {
715721 wv.findBar.Hide()
716722
717723 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
718- pv.RemoveOverlayWidget(wv.findBarWidget)
724+ if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
725+ pv.RemoveOverlayWidget(wv.findBarWidget)
726+ } else if parent != nil {
727+ wv.findBarWidget.Unparent()
728+ }
719729 }
720730
721731 wv.findBar = nil
@@ -608,7 +608,13 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
608 608
609 // Remove from pane overlay609 // Remove from pane overlay
610 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {610 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
611- pv.RemoveOverlayWidget(wv.omniboxWidget)611+ // The omnibox widget may have been reparented during a rebuild/move.
612+ // Avoid GTK criticals by only removing from the overlay that owns it.
613+ if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
614+ pv.RemoveOverlayWidget(wv.omniboxWidget)
615+ } else if parent != nil {
616+ wv.omniboxWidget.Unparent()
617+ }
612 }618 }
613 619
614 // Clear references620 // Clear references
@@ -715,7 +721,11 @@ func (wv *WorkspaceView) hideFindBarInternal() {
715 wv.findBar.Hide()721 wv.findBar.Hide()
716 722
717 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {723 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
718- pv.RemoveOverlayWidget(wv.findBarWidget)724+ if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
725+ pv.RemoveOverlayWidget(wv.findBarWidget)
726+ } else if parent != nil {
727+ wv.findBarWidget.Unparent()
728+ }
719 }729 }
720 730
721 wv.findBar = nil731 wv.findBar = nil
M internal/ui/dispatcher/keyboard.go +44 -12
@@ -17,18 +17,20 @@ import (
1717
1818 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1919 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()
27- onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error
20+ tabCoord *coordinator.TabCoordinator
21+ wsCoord *coordinator.WorkspaceCoordinator
22+ navCoord *coordinator.NavigationCoordinator
23+ zoomUC *usecase.ManageZoomUseCase
24+ copyURLUC *usecase.CopyURLUseCase
25+ actionHandlers map[input.Action]func(ctx context.Context) error
26+ onQuit func()
27+ onFindOpen func(ctx context.Context) error
28+ onFindNext func(ctx context.Context) error
29+ onFindPrev func(ctx context.Context) error
30+ onFindClose func(ctx context.Context) error
31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
3234 }
3335
3436 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
8486 d.onSessionOpen = fn
8587 }
8688
89+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90+ d.onMovePaneToTab = fn
91+}
92+
93+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94+ d.onMovePaneToNext = fn
95+}
96+
8797 func (d *KeyboardDispatcher) initActionHandlers() {
8898 const (
8999 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129139 input.ActionClosePane: d.wsCoord.ClosePane,
130140 input.ActionStackPane: d.wsCoord.StackPane,
141+ input.ActionMovePaneToTab: func(ctx context.Context) error {
142+ return d.handleMovePaneToTab(ctx)
143+ },
144+ input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145+ return d.handleMovePaneToNextTab(ctx)
146+ },
131147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239255 return nil
240256 }
241257
258+func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259+ if d.onMovePaneToTab != nil {
260+ return d.onMovePaneToTab(ctx)
261+ }
262+ logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263+ return nil
264+}
265+
266+func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267+ if d.onMovePaneToNext != nil {
268+ return d.onMovePaneToNext(ctx)
269+ }
270+ logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271+ return nil
272+}
273+
242274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243275 if ctx == nil {
244276 return fmt.Errorf("missing context")
@@ -17,18 +17,20 @@ import (
17 17
18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
19 type KeyboardDispatcher struct {19 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator20+ tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator21+ wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator22+ navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase23+ zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase24+ copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error25+ actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()26+ onQuit func()
27- onFindOpen func(ctx context.Context) error27+ onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error28+ onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error29+ onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error30+ onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
32 }34 }
33 35
34 // NewKeyboardDispatcher creates a new KeyboardDispatcher.36 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
84 d.onSessionOpen = fn86 d.onSessionOpen = fn
85 }87 }
86 88
89+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90+ d.onMovePaneToTab = fn
91+}
92+
93+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94+ d.onMovePaneToNext = fn
95+}
96+
87 func (d *KeyboardDispatcher) initActionHandlers() {97 func (d *KeyboardDispatcher) initActionHandlers() {
88 const (98 const (
89 firstTabIndex = 099 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129 input.ActionClosePane: d.wsCoord.ClosePane,139 input.ActionClosePane: d.wsCoord.ClosePane,
130 input.ActionStackPane: d.wsCoord.StackPane,140 input.ActionStackPane: d.wsCoord.StackPane,
141+ input.ActionMovePaneToTab: func(ctx context.Context) error {
142+ return d.handleMovePaneToTab(ctx)
143+ },
144+ input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145+ return d.handleMovePaneToNextTab(ctx)
146+ },
131 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133 },149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239 return nil255 return nil
240 }256 }
241 257
258+func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259+ if d.onMovePaneToTab != nil {
260+ return d.onMovePaneToTab(ctx)
261+ }
262+ logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263+ return nil
264+}
265+
266+func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267+ if d.onMovePaneToNext != nil {
268+ return d.onMovePaneToNext(ctx)
269+ }
270+ logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271+ return nil
272+}
273+
242 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243 if ctx == nil {275 if ctx == nil {
244 return fmt.Errorf("missing context")276 return fmt.Errorf("missing context")
M internal/ui/input/keyboard.go +8 -0
@@ -139,6 +139,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
139139
140140 // Build KeyBinding from event
141141 modifiers := Modifier(state) & modifierMask
142+
143+ // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144+ // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145+ // keyvals and rely on ModShift to represent Shift.
146+ if keyval >= uint('A') && keyval <= uint('Z') {
147+ keyval += (uint('a') - uint('A'))
148+ }
149+
142150 binding := KeyBinding{
143151 Keyval: keyval,
144152 Modifiers: modifiers,
@@ -139,6 +139,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
139 139
140 // Build KeyBinding from event140 // Build KeyBinding from event
141 modifiers := Modifier(state) & modifierMask141 modifiers := Modifier(state) & modifierMask
142+
143+ // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144+ // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145+ // keyvals and rely on ModShift to represent Shift.
146+ if keyval >= uint('A') && keyval <= uint('Z') {
147+ keyval += (uint('a') - uint('A'))
148+ }
149+
142 binding := KeyBinding{150 binding := KeyBinding{
143 Keyval: keyval,151 Keyval: keyval,
144 Modifiers: modifiers,152 Modifiers: modifiers,
M internal/ui/input/shortcuts.go +32 -11
@@ -134,6 +134,9 @@ const (
134134 ActionClosePane Action = "close_pane"
135135 ActionStackPane Action = "stack_pane"
136136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -353,7 +356,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
353356 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
354357 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
355358 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
356- s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
359+ s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
357360 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
358361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
359362 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -365,7 +368,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
365368 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
366369 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
367370 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
368- s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
371+ s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
369372 // Session management - direct shortcut to open session manager
370373 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
371374 }
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429432 "rename-tab": ActionRenameTab,
430433
431434 // Pane actions
432- "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,
435+ "split-right": ActionSplitRight,
436+ "split-left": ActionSplitLeft,
437+ "split-up": ActionSplitUp,
438+ "split-down": ActionSplitDown,
439+ "close-pane": ActionClosePane,
440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438443
439444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484489 return KeyBinding{}, false
485490 }
486491
487- s = strings.ToLower(strings.TrimSpace(s))
492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488496 if s == "+" {
489497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499507 if part == "" {
500508 continue
501509 }
502- switch part {
510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503513 case "ctrl", "control":
504514 modifiers |= ModCtrl
505515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523533 return KeyBinding{}, false
524534 }
525535
536+ // Treat uppercase single-letter keys as Shift+<letter>.
537+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
538+ modifiers |= ModShift
539+ keyPart = strings.ToLower(keyPart)
540+ }
541+
526542 keyval, ok := stringToKeyval(keyPart)
527543 if !ok {
528544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536552
537553 // stringToKeyval converts a key name to its GDK keyval.
538554 func stringToKeyval(s string) (uint, bool) {
539- if keyval, ok := keyvalByName[s]; ok {
555+ if s == "" {
556+ return 0, false
557+ }
558+
559+ if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
540560 return keyval, true
541561 }
542562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592613 ActionOpenSessionManager:
593614 return true
@@ -134,6 +134,9 @@ const (
134 ActionClosePane Action = "close_pane"134 ActionClosePane Action = "close_pane"
135 ActionStackPane Action = "stack_pane"135 ActionStackPane Action = "stack_pane"
136 136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138 ActionConsumeOrExpelRight Action = "consume_or_expel_right"141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139 ActionConsumeOrExpelUp Action = "consume_or_expel_up"142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -353,7 +356,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
353 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext356 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
354 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev357 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
355 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload358 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
356- s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload359+ s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
357 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload360 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
358 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
359 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools362 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -365,7 +368,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
365 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset368 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
366 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit369 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
367 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen370 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
368- s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL371+ s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
369 // Session management - direct shortcut to open session manager372 // Session management - direct shortcut to open session manager
370 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager373 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
371 }374 }
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429 "rename-tab": ActionRenameTab,432 "rename-tab": ActionRenameTab,
430 433
431 // Pane actions434 // Pane actions
432- "split-right": ActionSplitRight,435+ "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,436+ "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,437+ "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,438+ "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,439+ "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438 443
439 "consume-or-expel-left": ActionConsumeOrExpelLeft,444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440 "consume-or-expel-right": ActionConsumeOrExpelRight,445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484 return KeyBinding{}, false489 return KeyBinding{}, false
485 }490 }
486 491
487- s = strings.ToLower(strings.TrimSpace(s))492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488 if s == "+" {496 if s == "+" {
489 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490 }498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499 if part == "" {507 if part == "" {
500 continue508 continue
501 }509 }
502- switch part {510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503 case "ctrl", "control":513 case "ctrl", "control":
504 modifiers |= ModCtrl514 modifiers |= ModCtrl
505 case "shift":515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523 return KeyBinding{}, false533 return KeyBinding{}, false
524 }534 }
525 535
536+ // Treat uppercase single-letter keys as Shift+<letter>.
537+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
538+ modifiers |= ModShift
539+ keyPart = strings.ToLower(keyPart)
540+ }
541+
526 keyval, ok := stringToKeyval(keyPart)542 keyval, ok := stringToKeyval(keyPart)
527 if !ok {543 if !ok {
528 return KeyBinding{}, false544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536 552
537 // stringToKeyval converts a key name to its GDK keyval.553 // stringToKeyval converts a key name to its GDK keyval.
538 func stringToKeyval(s string) (uint, bool) {554 func stringToKeyval(s string) (uint, bool) {
539- if keyval, ok := keyvalByName[s]; ok {555+ if s == "" {
556+ return 0, false
557+ }
558+
559+ if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
540 return keyval, true560 return keyval, true
541 }561 }
542 562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588 case ActionNewTab, ActionCloseTab, ActionRenameTab,608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590 ActionClosePane, ActionStackPane,610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592 ActionOpenSessionManager:613 ActionOpenSessionManager:
593 return true614 return true
M internal/ui/input/shortcuts_test.go +16 -1
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207207 input1 string
208208 input2 string
209209 }{
210- {"ctrl+t", "CTRL+T"},
210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211212 {"Escape", "escape"},
212213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214216 }
215217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230232 }
231233 }
232234
235+func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
236+ got1, ok1 := ParseKeyString("M")
237+ got2, ok2 := ParseKeyString("shift+m")
238+ if !ok1 || !ok2 {
239+ t.Fatalf("ParseKeyString should succeed for M and shift+m")
240+ }
241+ if got1 != got2 {
242+ t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
243+ }
244+}
245+
233246 func TestShortcutSet_Lookup(t *testing.T) {
234247 set := &ShortcutSet{
235248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336349 ActionSplitDown,
337350 ActionClosePane,
338351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339354 }
340355
341356 stayActions := []Action{
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207 input1 string207 input1 string
208 input2 string208 input2 string
209 }{209 }{
210- {"ctrl+t", "CTRL+T"},210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211 {"Escape", "escape"},212 {"Escape", "escape"},
212 {"Tab", "TAB"},213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213 {"Ctrl+Shift+T", "ctrl+shift+t"},215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214 }216 }
215 217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230 }232 }
231 }233 }
232 234
235+func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
236+ got1, ok1 := ParseKeyString("M")
237+ got2, ok2 := ParseKeyString("shift+m")
238+ if !ok1 || !ok2 {
239+ t.Fatalf("ParseKeyString should succeed for M and shift+m")
240+ }
241+ if got1 != got2 {
242+ t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
243+ }
244+}
245+
233 func TestShortcutSet_Lookup(t *testing.T) {246 func TestShortcutSet_Lookup(t *testing.T) {
234 set := &ShortcutSet{247 set := &ShortcutSet{
235 Global: ShortcutTable{248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336 ActionSplitDown,349 ActionSplitDown,
337 ActionClosePane,350 ActionClosePane,
338 ActionStackPane,351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339 }354 }
340 355
341 stayActions := []Action{356 stayActions := []Action{
M internal/ui/theme/css.go +5 -0
@@ -114,6 +114,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114114
115115 // Session manager styling
116116 sb.WriteString(generateSessionManagerCSS(p))
117+ sb.WriteString("\n")
118+
119+ // Tab picker styling
120+ sb.WriteString(generateTabPickerCSS(p))
117121
118122 return sb.String()
119123 }
@@ -596,6 +600,7 @@ progressbar.osd progress {
596600 // generateSessionManagerCSS creates session manager modal styles.
597601 // Uses em units for scalable UI, matches omnibox styling patterns.
598602 func generateSessionManagerCSS(p Palette) string {
603+ _ = p
599604 return `/* ===== Session Manager Styling ===== */
600605
601606 /* Session manager outer container - for positioning in overlay */
@@ -114,6 +114,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114 114
115 // Session manager styling115 // Session manager styling
116 sb.WriteString(generateSessionManagerCSS(p))116 sb.WriteString(generateSessionManagerCSS(p))
117+ sb.WriteString("\n")
118+
119+ // Tab picker styling
120+ sb.WriteString(generateTabPickerCSS(p))
117 121
118 return sb.String()122 return sb.String()
119 }123 }
@@ -596,6 +600,7 @@ progressbar.osd progress {
596 // generateSessionManagerCSS creates session manager modal styles.600 // generateSessionManagerCSS creates session manager modal styles.
597 // Uses em units for scalable UI, matches omnibox styling patterns.601 // Uses em units for scalable UI, matches omnibox styling patterns.
598 func generateSessionManagerCSS(p Palette) string {602 func generateSessionManagerCSS(p Palette) string {
603+ _ = p
599 return `/* ===== Session Manager Styling ===== */604 return `/* ===== Session Manager Styling ===== */
600 605
601 /* Session manager outer container - for positioning in overlay */606 /* Session manager outer container - for positioning in overlay */
A internal/ui/theme/tab_picker_css.go +81 -0
@@ -0,0 +1,81 @@
1+package theme
2+
3+// generateTabPickerCSS creates tab picker modal styles.
4+// Uses similar styling conventions as Omnibox/SessionManager.
5+func generateTabPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Tab Picker Styling ===== */
8+
9+.tab-picker-outer {
10+ /* Positioning handled in Go */
11+}
12+
13+.tab-picker-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 28em;
19+}
20+
21+.tab-picker-header {
22+ background-color: shade(var(--surface-variant), 1.1);
23+ border-bottom: 0.0625em solid var(--border);
24+ padding: 0.5em 0.75em;
25+}
26+
27+.tab-picker-title {
28+ font-size: 0.9375em;
29+ font-weight: 600;
30+ color: var(--text);
31+}
32+
33+.tab-picker-scrolled {
34+ background-color: shade(var(--surface-variant), 0.95);
35+}
36+
37+.tab-picker-list {
38+ background-color: transparent;
39+}
40+
41+.tab-picker-row {
42+ padding: 0.5em 0.75em;
43+ margin: 0;
44+ border-radius: 0;
45+ border-left: 0.1875em solid transparent;
46+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
47+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
48+ min-height: 2.75em;
49+}
50+
51+.tab-picker-row:last-child {
52+ border-bottom: none;
53+}
54+
55+.tab-picker-row:hover {
56+ background-color: alpha(var(--accent), 0.12);
57+ border-left: 0.1875em solid var(--accent);
58+}
59+
60+.tab-picker-row:selected,
61+row:selected .tab-picker-row {
62+ background-color: alpha(var(--accent), 0.2);
63+ border-left: 0.1875em solid var(--accent);
64+}
65+
66+.tab-picker-row-title {
67+ color: var(--text);
68+ font-size: 0.875em;
69+ font-weight: 400;
70+}
71+
72+.tab-picker-footer {
73+ background-color: shade(var(--surface-variant), 0.9);
74+ border-top: 0.0625em solid var(--border);
75+ padding: 0.375em 0.75em;
76+ font-size: 0.6875em;
77+ color: var(--muted);
78+ font-family: var(--font-mono);
79+}
80+`
81+}
@@ -0,0 +1,81 @@
1+package theme
2+
3+// generateTabPickerCSS creates tab picker modal styles.
4+// Uses similar styling conventions as Omnibox/SessionManager.
5+func generateTabPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Tab Picker Styling ===== */
8+
9+.tab-picker-outer {
10+ /* Positioning handled in Go */
11+}
12+
13+.tab-picker-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 28em;
19+}
20+
21+.tab-picker-header {
22+ background-color: shade(var(--surface-variant), 1.1);
23+ border-bottom: 0.0625em solid var(--border);
24+ padding: 0.5em 0.75em;
25+}
26+
27+.tab-picker-title {
28+ font-size: 0.9375em;
29+ font-weight: 600;
30+ color: var(--text);
31+}
32+
33+.tab-picker-scrolled {
34+ background-color: shade(var(--surface-variant), 0.95);
35+}
36+
37+.tab-picker-list {
38+ background-color: transparent;
39+}
40+
41+.tab-picker-row {
42+ padding: 0.5em 0.75em;
43+ margin: 0;
44+ border-radius: 0;
45+ border-left: 0.1875em solid transparent;
46+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
47+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
48+ min-height: 2.75em;
49+}
50+
51+.tab-picker-row:last-child {
52+ border-bottom: none;
53+}
54+
55+.tab-picker-row:hover {
56+ background-color: alpha(var(--accent), 0.12);
57+ border-left: 0.1875em solid var(--accent);
58+}
59+
60+.tab-picker-row:selected,
61+row:selected .tab-picker-row {
62+ background-color: alpha(var(--accent), 0.2);
63+ border-left: 0.1875em solid var(--accent);
64+}
65+
66+.tab-picker-row-title {
67+ color: var(--text);
68+ font-size: 0.875em;
69+ font-weight: 400;
70+}
71+
72+.tab-picker-footer {
73+ background-color: shade(var(--surface-variant), 0.9);
74+ border-top: 0.0625em solid var(--border);
75+ padding: 0.375em 0.75em;
76+ font-size: 0.6875em;
77+ color: var(--muted);
78+ font-family: var(--font-mono);
79+}
80+`
81+}

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