v0.31.0 bf0756b

tab.go RawHistoryFolder

1package entity
2
3import (
4	"fmt"
5	"sync"
6	"time"
7)
8
9// TabID uniquely identifies a tab.
10type TabID string
11
12// Tab represents a browser tab containing a workspace.
13// Tabs are the top-level container in the browser's tab bar.
14type Tab struct {
15	ID        TabID
16	Name      string     // Display name (often derived from active pane title)
17	Workspace *Workspace // The workspace this tab contains
18	Position  int        // Position in the tab bar (0-indexed)
19	IsPinned  bool       // Pinned tabs stay at the left
20	CreatedAt time.Time
21}
22
23// NewTab creates a new tab with an initial pane.
24func NewTab(tabID TabID, workspaceID WorkspaceID, initialPane *Pane) *Tab {
25	return &Tab{
26		ID:        tabID,
27		Workspace: NewWorkspace(workspaceID, initialPane),
28		Position:  0,
29		CreatedAt: time.Now(),
30	}
31}
32
33// Title returns the display title for the tab.
34// Uses the tab's Name if set, otherwise returns "Tab N" based on position.
35func (t *Tab) Title() string {
36	if t.Name != "" {
37		return t.Name
38	}
39	return fmt.Sprintf("Tab %d", t.Position+1)
40}
41
42// PaneCount returns the number of panes in this tab's workspace.
43func (t *Tab) PaneCount() int {
44	if t.Workspace == nil {
45		return 0
46	}
47	return t.Workspace.PaneCount()
48}
49
50// TabList manages an ordered collection of tabs.
51type TabList struct {
52	Tabs                []*Tab
53	ActiveTabID         TabID
54	PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching
55
56	mu sync.RWMutex
57}
58
59// NewTabList creates an empty tab list.
60func NewTabList() *TabList {
61	return &TabList{
62		Tabs: make([]*Tab, 0),
63	}
64}
65
66// Add appends a tab to the list.
67func (tl *TabList) Add(tab *Tab) {
68	tl.mu.Lock()
69	defer tl.mu.Unlock()
70
71	tab.Position = len(tl.Tabs)
72	tl.Tabs = append(tl.Tabs, tab)
73	if tl.ActiveTabID == "" {
74		tl.ActiveTabID = tab.ID
75	}
76}
77
78// Remove removes a tab by ID and reindexes positions.
79func (tl *TabList) Remove(id TabID) bool {
80	tl.mu.Lock()
81	defer tl.mu.Unlock()
82
83	for i, tab := range tl.Tabs {
84		if tab.ID == id {
85			tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...)
86			// Reindex positions
87			for j := i; j < len(tl.Tabs); j++ {
88				tl.Tabs[j].Position = j
89			}
90			// Update active tab if needed
91			if tl.ActiveTabID == id {
92				if len(tl.Tabs) > 0 {
93					if i < len(tl.Tabs) {
94						tl.ActiveTabID = tl.Tabs[i].ID
95					} else {
96						tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
97					}
98				} else {
99					tl.ActiveTabID = ""
100					tl.PreviousActiveTabID = ""
101				}
102			}
103			return true
104		}
105	}
106	return false
107}
108
109// Find returns a tab by ID.
110func (tl *TabList) Find(id TabID) *Tab {
111	tl.mu.RLock()
112	defer tl.mu.RUnlock()
113
114	return tl.findNoLock(id)
115}
116
117func (tl *TabList) findNoLock(id TabID) *Tab {
118	for _, tab := range tl.Tabs {
119		if tab.ID == id {
120			return tab
121		}
122	}
123	return nil
124}
125
126// ActiveTab returns the currently active tab.
127func (tl *TabList) ActiveTab() *Tab {
128	tl.mu.RLock()
129	defer tl.mu.RUnlock()
130
131	return tl.findNoLock(tl.ActiveTabID)
132}
133
134// Count returns the number of tabs.
135func (tl *TabList) Count() int {
136	tl.mu.RLock()
137	defer tl.mu.RUnlock()
138
139	return len(tl.Tabs)
140}
141
142// SetActive sets the active tab and updates the previous active tab.
143func (tl *TabList) SetActive(id TabID) {
144	tl.mu.Lock()
145	defer tl.mu.Unlock()
146
147	if id != tl.ActiveTabID && tl.ActiveTabID != "" {
148		tl.PreviousActiveTabID = tl.ActiveTabID
149	}
150	tl.ActiveTabID = id
151}
152
153// TabAt returns the tab at the given 0-based index.
154func (tl *TabList) TabAt(index int) *Tab {
155	tl.mu.RLock()
156	defer tl.mu.RUnlock()
157
158	if index < 0 || index >= len(tl.Tabs) {
159		return nil
160	}
161	return tl.Tabs[index]
162}
163
164// Move moves a tab to a new position.
165func (tl *TabList) Move(id TabID, newPos int) bool {
166	tl.mu.Lock()
167	defer tl.mu.Unlock()
168
169	if newPos < 0 || newPos >= len(tl.Tabs) {
170		return false
171	}
172	var tab *Tab
173	var oldPos int
174	for i, t := range tl.Tabs {
175		if t.ID == id {
176			tab = t
177			oldPos = i
178			break
179		}
180	}
181	if tab == nil {
182		return false
183	}
184	// Remove from old position
185	tl.Tabs = append(tl.Tabs[:oldPos], tl.Tabs[oldPos+1:]...)
186	// Insert at new position
187	tl.Tabs = append(tl.Tabs[:newPos], append([]*Tab{tab}, tl.Tabs[newPos:]...)...)
188	// Reindex all
189	for i := range tl.Tabs {
190		tl.Tabs[i].Position = i
191	}
192	return true
193}
194
195// Snapshot returns an isolated copy of the TabList safe for concurrent read.
196// The returned TabList has its own slice, Tab values, and workspace tree.
197func (tl *TabList) Snapshot() *TabList {
198	if tl == nil {
199		return nil
200	}
201	tl.mu.RLock()
202	defer tl.mu.RUnlock()
203
204	tabs := make([]*Tab, 0, len(tl.Tabs))
205	for _, tab := range tl.Tabs {
206		tabs = append(tabs, cloneTab(tab))
207	}
208	return &TabList{
209		Tabs:                tabs,
210		ActiveTabID:         tl.ActiveTabID,
211		PreviousActiveTabID: tl.PreviousActiveTabID,
212	}
213}
214
215func cloneTab(tab *Tab) *Tab {
216	if tab == nil {
217		return nil
218	}
219	cloned := *tab
220	cloned.Workspace = cloneWorkspace(tab.Workspace)
221	return &cloned
222}
223
224func cloneWorkspace(workspace *Workspace) *Workspace {
225	if workspace == nil {
226		return nil
227	}
228	cloned := *workspace
229	cloned.Root = clonePaneNode(workspace.Root, nil)
230	return &cloned
231}
232
233func clonePaneNode(node, parent *PaneNode) *PaneNode {
234	if node == nil {
235		return nil
236	}
237	cloned := *node
238	cloned.Parent = parent
239	cloned.Pane = clonePane(node.Pane)
240	if len(node.Children) > 0 {
241		cloned.Children = make([]*PaneNode, 0, len(node.Children))
242		for _, child := range node.Children {
243			cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned))
244		}
245	} else {
246		cloned.Children = nil
247	}
248	return &cloned
249}
250
251func clonePane(pane *Pane) *Pane {
252	if pane == nil {
253		return nil
254	}
255	cloned := *pane
256	if pane.ParentPaneID != nil {
257		parentPaneID := *pane.ParentPaneID
258		cloned.ParentPaneID = &parentPaneID
259	}
260	return &cloned
261}
262
263// ReplaceFrom replaces this TabList's contents with those from another TabList.
264// This modifies in-place so existing references to this TabList remain valid.
265func (tl *TabList) ReplaceFrom(other *TabList) {
266	snapshot := other.Snapshot()
267
268	tl.mu.Lock()
269	defer tl.mu.Unlock()
270
271	if snapshot == nil {
272		tl.Tabs = make([]*Tab, 0)
273		tl.ActiveTabID = ""
274		tl.PreviousActiveTabID = ""
275		return
276	}
277	tl.Tabs = snapshot.Tabs
278	tl.ActiveTabID = snapshot.ActiveTabID
279	tl.PreviousActiveTabID = snapshot.PreviousActiveTabID
280}

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