Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 4061deb

Browse files
feat(tui): pin tab + auto-follow new downloads (#438)
* feat(tui): add pin tab feature and auto-follow new downloads - Add pinnedTab field to RootModel to track which tab is pinned (-1 = none) - Press ctrl+tab to toggle pin on the active tab; a πŸ“Œ icon appears in the tab bar - When a tab is pinned, automatic tab switching is suppressed β€” the UI stays put - When no tab is pinned, the TUI now auto-follows new downloads started from the browser extension (sets SelectedDownloadID on DownloadStarted/Queued events) - Update Tab component to accept a Pinned field and render the pin icon - Add PinTab key binding to dashboard help view Closes #431 * fix(tui): use switch for tab name resolution (staticcheck) * style(tui): replace pin emoji with unicode β—† (U+25C6) * fix(tui): change pin tab key from ctrl+tab to 't' ctrl+tab is consumed by the terminal emulator and never reaches the TUI. Use 't' (toggle) as the pin key instead β€” consistent with single-key binding style used throughout the dashboard (q/w/e, p, r, x, etc.). * feat(tui): arrow key tab switching, remove j/k, fix pin override - Left/right arrows now switch tabs (prev/next); tab key retained for next - Remove vim j/k navigation bindings (closes #430) - Fix: pin tab now fully overrides ALL tab switches including: - Downloads started via 'a' key (startDownload in process.go) - Browser extension downloads (update_events.go) - Download completion follow-through (list.go) * feat(tui): block tab switching when pinned, log hint to unpin When a tab is pinned, pressing q/w/e, left/right, or tab now logs 'β—† Tab is pinned β€” press t to unpin' instead of switching tabs. * fix(tui): address auto-follow edge cases and add tests
1 parent c62ac7f commit 4061deb

11 files changed

Lines changed: 242 additions & 35 deletions

File tree

β€Žinternal/tui/components/tab_bar.goβ€Ž

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import (
88

99
// Tab represents a single tab item
1010
type Tab struct {
11-
Label string
12-
Count int // If >= 0, displays as "Label (Count)"; if < 0, displays just "Label"
11+
Label string
12+
Count int // If >= 0, displays as "Label (Count)"; if < 0, displays just "Label"
13+
Pinned bool // If true, displays with a pin icon
1314
}
1415

1516
// RenderTabBar renders a horizontal tab bar with the given tabs
@@ -25,6 +26,10 @@ func RenderTabBar(tabs []Tab, activeIndex int, activeStyle, inactiveStyle lipglo
2526
label = t.Label
2627
}
2728

29+
if t.Pinned {
30+
label = "\u25c6 " + label
31+
}
32+
2833
var tabStyle lipgloss.Style
2934
if i == activeIndex {
3035
tabStyle = lipgloss.NewStyle().
@@ -58,6 +63,10 @@ func RenderNumberedTabBar(tabs []Tab, activeIndex int, activeStyle, inactiveStyl
5863
label = fmt.Sprintf("[%d] %s", i+1, t.Label)
5964
}
6065

66+
if t.Pinned {
67+
label = "\u25c6 " + label
68+
}
69+
6170
var tabStyle lipgloss.Style
6271
if i == activeIndex {
6372
tabStyle = lipgloss.NewStyle().

β€Žinternal/tui/follow_test.goβ€Ž

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"charm.land/bubbles/v2/viewport"
7+
"github.com/SurgeDM/Surge/internal/engine/events"
8+
"github.com/SurgeDM/Surge/internal/engine/types"
9+
)
10+
11+
func TestAutoFollow_BrandNewDownload(t *testing.T) {
12+
m := RootModel{
13+
activeTab: TabDone,
14+
pinnedTab: -1,
15+
list: NewDownloadList(80, 20),
16+
logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)),
17+
}
18+
19+
msg := events.DownloadStartedMsg{
20+
DownloadID: "new-1",
21+
Filename: "new-file",
22+
Total: 100,
23+
State: types.NewProgressState("new-1", 100),
24+
}
25+
26+
updated, _ := m.Update(msg)
27+
m2 := updated.(RootModel)
28+
29+
if m2.activeTab != TabActive {
30+
t.Errorf("Expected activeTab to be TabActive (1), got %d", m2.activeTab)
31+
}
32+
if m2.SelectedDownloadID != "" {
33+
t.Errorf("Expected SelectedDownloadID to be cleared, got %q", m2.SelectedDownloadID)
34+
}
35+
}
36+
37+
func TestAutoFollow_ExistingDownloadRestart(t *testing.T) {
38+
dm := NewDownloadModel("existing-1", "http://example.com", "file", 100)
39+
dm.paused = true
40+
41+
m := RootModel{
42+
activeTab: TabQueued,
43+
pinnedTab: -1,
44+
downloads: []*DownloadModel{dm},
45+
list: NewDownloadList(80, 20),
46+
logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)),
47+
}
48+
49+
msg := events.DownloadStartedMsg{
50+
DownloadID: "existing-1",
51+
Filename: "file",
52+
Total: 100,
53+
State: types.NewProgressState("existing-1", 100),
54+
}
55+
56+
updated, _ := m.Update(msg)
57+
m2 := updated.(RootModel)
58+
59+
if m2.activeTab != TabActive {
60+
t.Errorf("Expected activeTab to be TabActive (1), got %d", m2.activeTab)
61+
}
62+
}
63+
64+
func TestAutoFollow_SuppressedByPin(t *testing.T) {
65+
m := RootModel{
66+
activeTab: TabDone,
67+
pinnedTab: TabDone,
68+
list: NewDownloadList(80, 20),
69+
logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)),
70+
}
71+
72+
msg := events.DownloadStartedMsg{
73+
DownloadID: "new-1",
74+
Filename: "new-file",
75+
Total: 100,
76+
State: types.NewProgressState("new-1", 100),
77+
}
78+
79+
updated, _ := m.Update(msg)
80+
m2 := updated.(RootModel)
81+
82+
if m2.activeTab != TabDone {
83+
t.Errorf("Expected activeTab to remain TabDone (2) because it is pinned, got %d", m2.activeTab)
84+
}
85+
}
86+
87+
func TestAutoFollow_QueuedToActiveTransition(t *testing.T) {
88+
// Test that transitioning from Queued to Active (via DownloadStartedMsg)
89+
// also triggers auto-follow if we are currently on Queued tab.
90+
dm := NewDownloadModel("id-1", "http://example.com", "file", 100)
91+
// Initially it's queued (done=false, paused=false, speed=0, connections=0)
92+
93+
m := RootModel{
94+
activeTab: TabQueued,
95+
pinnedTab: -1,
96+
downloads: []*DownloadModel{dm},
97+
list: NewDownloadList(80, 20),
98+
logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)),
99+
}
100+
101+
// Update list to reflect initial state
102+
m.UpdateListItems()
103+
104+
msg := events.DownloadStartedMsg{
105+
DownloadID: "id-1",
106+
Filename: "file",
107+
Total: 100,
108+
State: types.NewProgressState("id-1", 100),
109+
}
110+
111+
updated, _ := m.Update(msg)
112+
m2 := updated.(RootModel)
113+
114+
if m2.activeTab != TabActive {
115+
t.Errorf("Expected auto-follow to Active tab, got %d", m2.activeTab)
116+
}
117+
}

β€Žinternal/tui/keys.goβ€Ž

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type DashboardKeyMap struct {
2424
TabActive key.Binding
2525
TabDone key.Binding
2626
NextTab key.Binding
27+
PrevTab key.Binding
2728
Add key.Binding
2829
BatchImport key.Binding
2930
Search key.Binding
@@ -38,6 +39,7 @@ type DashboardKeyMap struct {
3839
Quit key.Binding
3940
ForceQuit key.Binding
4041
CategoryFilter key.Binding
42+
PinTab key.Binding
4143
// Navigation
4244
Up key.Binding
4345
Down key.Binding
@@ -166,8 +168,12 @@ var Keys = KeyMap{
166168
key.WithHelp("e", "done tab"),
167169
),
168170
NextTab: key.NewBinding(
169-
key.WithKeys("tab"),
170-
key.WithHelp("tab", "next tab"),
171+
key.WithKeys("tab", "right"),
172+
key.WithHelp("tab/β†’", "next tab"),
173+
),
174+
PrevTab: key.NewBinding(
175+
key.WithKeys("left"),
176+
key.WithHelp("←", "prev tab"),
171177
),
172178
Add: key.NewBinding(
173179
key.WithKeys("a"),
@@ -225,21 +231,25 @@ var Keys = KeyMap{
225231
key.WithKeys("c"),
226232
key.WithHelp("c", "category"),
227233
),
234+
PinTab: key.NewBinding(
235+
key.WithKeys("t"),
236+
key.WithHelp("t", "pin tab"),
237+
),
228238
Up: key.NewBinding(
229-
key.WithKeys("up", "k"),
230-
key.WithHelp("\u2191/k", "up"),
239+
key.WithKeys("up"),
240+
key.WithHelp("\u2191", "up"),
231241
),
232242
Down: key.NewBinding(
233-
key.WithKeys("down", "j"),
234-
key.WithHelp("\u2193/j", "down"),
243+
key.WithKeys("down"),
244+
key.WithHelp("\u2193", "down"),
235245
),
236246
LogUp: key.NewBinding(
237-
key.WithKeys("up", "k"),
238-
key.WithHelp("\u2191/k", "scroll up"),
247+
key.WithKeys("up"),
248+
key.WithHelp("↑", "scroll up"),
239249
),
240250
LogDown: key.NewBinding(
241-
key.WithKeys("down", "j"),
242-
key.WithHelp("\u2193/j", "scroll down"),
251+
key.WithKeys("down"),
252+
key.WithHelp("↓", "scroll down"),
243253
),
244254
LogTop: key.NewBinding(
245255
key.WithKeys("g"),
@@ -380,12 +390,12 @@ var Keys = KeyMap{
380390
key.WithHelp("enter", "edit"),
381391
),
382392
Up: key.NewBinding(
383-
key.WithKeys("up", "k"),
384-
key.WithHelp("\u2191/k", "up"),
393+
key.WithKeys("up"),
394+
key.WithHelp("\u2191", "up"),
385395
),
386396
Down: key.NewBinding(
387-
key.WithKeys("down", "j"),
388-
key.WithHelp("\u2193/j", "down"),
397+
key.WithKeys("down"),
398+
key.WithHelp("\u2193", "down"),
389399
),
390400
Reset: key.NewBinding(
391401
key.WithKeys("r", "R"),
@@ -445,8 +455,8 @@ var Keys = KeyMap{
445455
),
446456
},
447457
CategoryMgr: CategoryManagerKeyMap{
448-
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("\u2191/k", "up")),
449-
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("\u2193/j", "down")),
458+
Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
459+
Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
450460
Edit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "edit")),
451461
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
452462
Delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete")),
@@ -486,8 +496,8 @@ func (k DashboardKeyMap) ShortHelp() []key.Binding {
486496
// FullHelp returns keybindings for the expanded help view
487497
func (k DashboardKeyMap) FullHelp() [][]key.Binding {
488498
return [][]key.Binding{
489-
{k.TabQueued, k.TabActive, k.TabDone, k.NextTab},
490-
{k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings},
499+
{k.TabQueued, k.TabActive, k.TabDone, k.NextTab, k.PrevTab},
500+
{k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings, k.PinTab},
491501
{k.Log, k.OpenFile, k.ReportBug, k.Quit},
492502
}
493503
}

β€Žinternal/tui/list.goβ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,14 +272,14 @@ func (m *RootModel) UpdateListItems() {
272272
var newTab int
273273
if d.done {
274274
newTab = TabDone
275-
} else if !d.paused && !d.pausing && (d.Speed > 0 || d.Connections > 0 || d.resuming) {
275+
} else if !d.paused && !d.pausing && (d.Speed > 0 || d.Connections > 0 || d.resuming || d.started) {
276276
newTab = TabActive
277277
} else {
278278
newTab = TabQueued
279279
}
280280

281-
// If it belongs to a different tab, switch to it
282-
if newTab != -1 && newTab != m.activeTab {
281+
// If it belongs to a different tab, switch to it (unless current tab is pinned)
282+
if m.pinnedTab == -1 && newTab != -1 && newTab != m.activeTab {
283283
m.activeTab = newTab
284284

285285
// Force selection for the recursive call

β€Žinternal/tui/model.goβ€Ž

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ type DownloadModel struct {
9898
state *types.ProgressState // Keep for now if needed for details view, but mostly passive
9999

100100
done bool
101+
started bool // Engine has confirmed start
101102
err error
102103
paused bool
103104
pausing bool // UI state: transitioning to pause
@@ -110,6 +111,7 @@ type RootModel struct {
110111
height int
111112
state UIState
112113
activeTab int // 0=Queued, 1=Active, 2=Done
114+
pinnedTab int // -1=None, 0=Queued, 1=Active, 2=Done
113115
inputs []textinput.Model
114116
focusedInput int
115117
// Service Interface
@@ -319,22 +321,29 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo
319321
switch s.Status {
320322
case "completed":
321323
dm.done = true
324+
dm.started = true
322325
dm.progress.SetPercent(1.0)
323326
case "error":
324327
dm.done = true
328+
dm.started = true
325329
case "pausing":
326330
dm.pausing = true
331+
dm.started = true
327332
case "paused":
328333
if settings.General.AutoResume {
329334
dm.resuming = true
330335
dm.paused = true // Will update when resume event received
331336
} else {
332337
dm.paused = true
333338
}
339+
dm.started = true
334340
case "queued":
335341
// Always resume queued items
336342
dm.resuming = true
337343
dm.paused = true // Will update when resume event received
344+
dm.started = false
345+
case "downloading":
346+
dm.started = true
338347
}
339348

340349
if s.TotalSize > 0 {
@@ -412,6 +421,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo
412421

413422
m := RootModel{
414423
downloads: downloads,
424+
pinnedTab: -1,
415425
inputs: []textinput.Model{urlInput, mirrorsInput, pathInput, filenameInput},
416426
state: DashboardState,
417427
filepicker: fp,
@@ -526,12 +536,12 @@ func (m RootModel) getFilteredDownloads() []*DownloadModel {
526536
switch m.activeTab {
527537
case TabQueued:
528538
// Queued includes paused downloads and anything not currently active or done
529-
if d.done || (!d.paused && !d.pausing && (d.Speed > 0 || d.Connections > 0 || d.resuming)) {
539+
if d.done || (!d.paused && !d.pausing && (d.Speed > 0 || d.Connections > 0 || d.resuming || d.started)) {
530540
continue
531541
}
532542
case TabActive:
533543
// Active excludes paused downloads and anything without current activity
534-
if d.done || d.paused || d.pausing || (d.Speed == 0 && d.Connections == 0 && !d.resuming) {
544+
if d.done || d.paused || d.pausing || (d.Speed == 0 && d.Connections == 0 && !d.resuming && !d.started) {
535545
continue
536546
}
537547
case TabDone:

β€Žinternal/tui/process.goβ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin
135135
}
136136
m.downloads = append(m.downloads, newDownload)
137137
m.SelectedDownloadID = optimisticID
138-
m.activeTab = TabQueued
138+
if m.pinnedTab == -1 {
139+
m.activeTab = TabQueued
140+
}
139141
m.UpdateListItems()
140142

141143
// Legacy path for tests or startup wiring where processing is not injected yet.

0 commit comments

Comments
Β (0)