Skip to content

Commit a0ff3c9

Browse files
committed
feat: collapse/expand right pane & persist
1 parent 6916b4d commit a0ff3c9

6 files changed

Lines changed: 312 additions & 30 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,10 @@ Change your mode anytime with `dssh config`. View current settings with `dssh co
169169
| `Ctrl+R` | Rename the selected group |
170170
| `Ctrl+D` | Delete the selected group (connections are kept) |
171171
| `Ctrl+L` | Toggle SQLite / ssh_config list (both mode) |
172+
| `Ctrl+P` | Hide / show the right pane |
172173
| `Ctrl+T` | Toggle key / password auth (create/edit) |
173174
| `Ctrl+S` | Save a new or edited connection from either pane |
175+
| `?` | Hide / show key hints |
174176
| `ESC` / `Ctrl+C` | Quit |
175177

176178
The right pane filters the connection list as soon as you select a group. Selecting `(No Groups)` shows all connections.

internal/tui/app.go

Lines changed: 115 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ const (
4141
PaneRight
4242
)
4343

44-
const settingShowHints = "tui_show_hints"
44+
const (
45+
settingShowHints = "tui_show_hints"
46+
settingShowRightPane = "tui_show_right_pane"
47+
)
4548

4649
// AppModel is the top-level Bubble Tea model.
4750
type AppModel struct {
@@ -61,6 +64,7 @@ type AppModel struct {
6164
height int
6265
activePane Pane
6366
showHints bool
67+
showRightPane bool
6468

6569
// Session-level navigation shared by the connection tabs.
6670
connectionQuery string
@@ -129,16 +133,20 @@ func newAppModel(connections []model.Connection, d *sql.DB, initialTab Tab, cfg
129133
activeSource: model.SourceSQLite,
130134
activePane: PaneLeft,
131135
showHints: true,
136+
showRightPane: true,
132137
groupPane: newGroupPaneModel(nil, 34, 12),
133138
createAssignment: newGroupAssignmentModel(nil, nil, 0, 34, 12),
134139
editAssignment: newGroupAssignmentModel(nil, nil, 0, 34, 12),
135140
createAssignmentInitialized: initialTab == TabCreate,
136141
}
137-
142+
138143
if d != nil {
139144
if value, err := db.GetSetting(d, settingShowHints); err == nil && string(value) == "false" {
140145
m.showHints = false
141146
}
147+
if value, err := db.GetSetting(d, settingShowRightPane); err == nil && string(value) == "false" {
148+
m.showRightPane = false
149+
}
142150
}
143151

144152
// Respect persisted view mode.
@@ -209,19 +217,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
209217
case tea.WindowSizeMsg:
210218
m.width = msg.Width
211219
m.height = msg.Height
212-
separator := m.width / 2
213-
leftContentWidth := max(1, separator-5)
214-
rightContentWidth := max(1, m.width-separator-6)
215-
paneHeight := max(1, m.height-6)
216-
paneBodyHeight := max(1, paneHeight-2)
217-
m.connectModel.SetSize(leftContentWidth, paneBodyHeight)
218-
m.createModel.SetSize(leftContentWidth, paneBodyHeight)
219-
m.editModel.SetSize(leftContentWidth, paneBodyHeight)
220-
m.deleteModel.SetSize(leftContentWidth, paneBodyHeight)
221-
m.groupPane.SetSize(rightContentWidth, paneBodyHeight)
222-
m.createAssignment.SetSize(rightContentWidth, paneBodyHeight)
223-
m.editAssignment.SetSize(rightContentWidth, paneBodyHeight)
224-
m.modal.SetSize(m.width, m.height)
220+
m.resizePanes()
225221
return m, nil
226222

227223
case tea.KeyMsg:
@@ -287,6 +283,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
287283
case "?":
288284
m = m.toggleHints()
289285
return m, nil
286+
case "ctrl+p":
287+
m = m.toggleRightPane()
288+
return m, nil
290289
case "ctrl+s":
291290
switch {
292291
case m.activeTab == TabCreate:
@@ -326,6 +325,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
326325
return m, nil
327326
}
328327
case "tab", "shift+tab":
328+
if !m.showRightPane {
329+
return m, nil
330+
}
329331
if m.activePane == PaneLeft {
330332
m.activePane = PaneRight
331333
} else {
@@ -903,18 +905,31 @@ func (m AppModel) View() string {
903905
leftStatus = m.deleteModel.statusStyle.Render(m.deleteModel.statusMsg)
904906
}
905907

906-
base := renderSplitScreen(
907-
tabBar,
908-
leftContent,
909-
rightContent,
910-
leftStatus,
911-
rightStatus,
912-
m.leftPaneHints(),
913-
m.rightPaneHints(),
914-
m.width,
915-
m.height,
916-
accentColor,
917-
)
908+
var base string
909+
if m.showRightPane {
910+
base = renderSplitScreen(
911+
tabBar,
912+
leftContent,
913+
rightContent,
914+
leftStatus,
915+
rightStatus,
916+
m.leftPaneHints(),
917+
m.rightPaneHints(),
918+
m.width,
919+
m.height,
920+
accentColor,
921+
)
922+
} else {
923+
base = renderSinglePaneScreen(
924+
tabBar,
925+
leftContent,
926+
leftStatus,
927+
m.leftPaneHints(),
928+
m.width,
929+
m.height,
930+
accentColor,
931+
)
932+
}
918933
if m.showModal {
919934
return compositePopover(base, m.modal.BoxView(), m.width, m.height)
920935
}
@@ -931,6 +946,12 @@ func (m AppModel) leftPaneHints() string {
931946
if !m.showHints {
932947
return statusStyle.Render("? help")
933948
}
949+
if m.width <= minimumTerminalWidth {
950+
if m.showRightPane {
951+
return statusStyle.Render("CTRL+P hide panel • ? hide help")
952+
}
953+
return statusStyle.Render("CTRL+P show panel • ? hide help")
954+
}
934955

935956
var hints string
936957
switch m.activeTab {
@@ -947,18 +968,26 @@ func (m AppModel) leftPaneHints() string {
947968
case TabDelete:
948969
hints = "TAB pane • ↑/↓ navigate • ENTER ×3 delete • ←/→ tabs • ESC exit"
949970
}
950-
return statusStyle.Render(hints)
971+
if m.showRightPane {
972+
hints += " • CTRL+P hide panel"
973+
} else {
974+
hints += " • CTRL+P show panel"
975+
}
976+
return statusStyle.Render(hints + " • ? hide help")
951977
}
952978

953979
func (m AppModel) rightPaneHints() string {
954980
if !m.showHints {
955981
return ""
956982
}
983+
if m.width <= minimumTerminalWidth {
984+
return statusStyle.Render("CTRL+N new • CTRL+P hide panel • ? hide help")
985+
}
957986

958987
if m.assignmentMode() {
959-
return statusStyle.Render("TAB pane • ↑/↓ navigate • SPACE assign • CTRL+N new • CTRL+S save")
988+
return statusStyle.Render("TAB pane • ↑/↓ navigate • SPACE assign • CTRL+N new • CTRL+S save • CTRL+P hide panel • ? hide help")
960989
}
961-
return statusStyle.Render("TAB pane • ↑/↓ navigate • CTRL+N new • CTRL+R rename • CTRL+D delete")
990+
return statusStyle.Render("TAB pane • ↑/↓ navigate • CTRL+N new • CTRL+R rename • CTRL+D delete • CTRL+P hide panel • ? hide help")
962991
}
963992

964993
func (m AppModel) toggleHints() AppModel {
@@ -973,6 +1002,62 @@ func (m AppModel) toggleHints() AppModel {
9731002
return m
9741003
}
9751004

1005+
func (m AppModel) toggleRightPane() AppModel {
1006+
showRightPane := !m.showRightPane
1007+
if m.database != nil {
1008+
if err := db.SetSetting(m.database, settingShowRightPane, []byte(strconv.FormatBool(showRightPane))); err != nil {
1009+
m.setError("save right pane visibility: %s", err)
1010+
return m
1011+
}
1012+
}
1013+
m.showRightPane = showRightPane
1014+
if !m.showRightPane {
1015+
m.activePane = PaneLeft
1016+
m.resetHiddenPaneState()
1017+
}
1018+
m.resizePanes()
1019+
m.applyPaneFocus()
1020+
return m
1021+
}
1022+
1023+
func (m *AppModel) resetHiddenPaneState() {
1024+
m.groupPane.SelectGroup(0)
1025+
m.applyGroupFilter()
1026+
m.createAssignment.Begin(m.groups, nil, 0)
1027+
1028+
var editGroupIDs []int64
1029+
if m.activeTab == TabEdit && m.editModel.editing && m.database != nil {
1030+
ids, err := db.GroupIDsForConnection(m.database, m.connectionRef(m.editModel.origConn))
1031+
if err != nil {
1032+
m.setError("load connection groups: %s", err)
1033+
} else {
1034+
editGroupIDs = ids
1035+
}
1036+
}
1037+
m.editAssignment.Begin(m.groups, editGroupIDs, 0)
1038+
}
1039+
1040+
func (m *AppModel) resizePanes() {
1041+
leftContentWidth, rightContentWidth := singlePaneContentWidth(m.width), 0
1042+
if m.showRightPane {
1043+
separator := m.width / 2
1044+
leftContentWidth = max(1, separator-5)
1045+
rightContentWidth = max(1, m.width-separator-6)
1046+
}
1047+
paneHeight := max(1, m.height-6)
1048+
paneBodyHeight := max(1, paneHeight-2)
1049+
m.connectModel.SetSize(leftContentWidth, paneBodyHeight)
1050+
m.createModel.SetSize(leftContentWidth, paneBodyHeight)
1051+
m.editModel.SetSize(leftContentWidth, paneBodyHeight)
1052+
m.deleteModel.SetSize(leftContentWidth, paneBodyHeight)
1053+
if rightContentWidth > 0 {
1054+
m.groupPane.SetSize(rightContentWidth, paneBodyHeight)
1055+
m.createAssignment.SetSize(rightContentWidth, paneBodyHeight)
1056+
m.editAssignment.SetSize(rightContentWidth, paneBodyHeight)
1057+
}
1058+
m.modal.SetSize(m.width, m.height)
1059+
}
1060+
9761061
// onConnectionEdited syncs all tab lists after an edit.
9771062
func (m *AppModel) onConnectionEdited(oldName, newName string, source model.Source) {
9781063
m.connectModel.RemoveByName(oldName)

internal/tui/app_panes_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,92 @@ func TestHintsTogglePersistsAndRestores(t *testing.T) {
217217
}
218218
}
219219

220+
func TestRightPaneTogglePersistsAndExpandsLeftPane(t *testing.T) {
221+
d := newTUITestDB(t)
222+
app := newAppModel(nil, d, TabCreate, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
223+
app = updateApp(t, app, tea.WindowSizeMsg{Width: 100, Height: 24})
224+
if !app.showRightPane || !strings.Contains(ansi.Strip(app.View()), "Assign Groups") {
225+
t.Fatalf("new app did not show the right pane:\n%s", ansi.Strip(app.View()))
226+
}
227+
228+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyCtrlP})
229+
plain := ansi.Strip(app.View())
230+
if app.showRightPane || app.activePane != PaneLeft || strings.Contains(plain, "Assign Groups") {
231+
t.Fatalf("collapsed pane rendered incorrectly:\n%s", plain)
232+
}
233+
for i, line := range strings.Split(plain, "\n") {
234+
if got := lipgloss.Width(line); got != 100 {
235+
t.Fatalf("collapsed line %d width = %d, want 100", i, got)
236+
}
237+
}
238+
if got := []rune(strings.Split(plain, "\n")[3])[50]; got == '│' {
239+
t.Fatalf("collapsed layout retained center separator")
240+
}
241+
value, err := db.GetSetting(d, settingShowRightPane)
242+
if err != nil || string(value) != "false" {
243+
t.Fatalf("saved collapsed-pane setting = %q, err %v", value, err)
244+
}
245+
246+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyTab})
247+
if app.activePane != PaneLeft {
248+
t.Fatalf("tab moved focus into hidden pane: %v", app.activePane)
249+
}
250+
251+
restored := newAppModel(nil, d, TabCreate, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
252+
restored = updateApp(t, restored, tea.WindowSizeMsg{Width: 100, Height: 24})
253+
if restored.showRightPane || strings.Contains(ansi.Strip(restored.View()), "Assign Groups") {
254+
t.Fatalf("new app did not restore collapsed pane:\n%s", ansi.Strip(restored.View()))
255+
}
256+
restored = updateApp(t, restored, tea.KeyMsg{Type: tea.KeyCtrlP})
257+
if !restored.showRightPane || !strings.Contains(ansi.Strip(restored.View()), "Assign Groups") {
258+
t.Fatalf("right pane did not restore:\n%s", ansi.Strip(restored.View()))
259+
}
260+
value, err = db.GetSetting(d, settingShowRightPane)
261+
if err != nil || string(value) != "true" {
262+
t.Fatalf("saved expanded-pane setting = %q, err %v", value, err)
263+
}
264+
}
265+
266+
func TestHidingRightPaneResetsTheActiveGroupFilter(t *testing.T) {
267+
d := newTUITestDB(t)
268+
group, err := db.CreateGroup(d, "Production")
269+
if err != nil {
270+
t.Fatal(err)
271+
}
272+
if err := db.SetConnectionGroups(d, model.ConnectionRef{Source: model.SourceSQLite, Name: "beta"}, []int64{group.ID}); err != nil {
273+
t.Fatal(err)
274+
}
275+
connections := []model.Connection{
276+
{Name: "alpha", Source: model.SourceSQLite},
277+
{Name: "beta", Source: model.SourceSQLite},
278+
}
279+
app := newAppModel(connections, d, TabConnect, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
280+
app = updateApp(t, app, tea.WindowSizeMsg{Width: 100, Height: 24})
281+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyTab})
282+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyDown})
283+
if app.groupPane.SelectedGroupID() != group.ID || app.connectModel.SelectedName() != "beta" {
284+
t.Fatalf("group filter was not active before collapse")
285+
}
286+
287+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyCtrlP})
288+
if app.groupPane.SelectedGroupID() != 0 {
289+
t.Fatalf("hidden pane kept group filter %d", app.groupPane.SelectedGroupID())
290+
}
291+
plain := ansi.Strip(app.View())
292+
if !strings.Contains(plain, "alpha") || !strings.Contains(plain, "beta") {
293+
t.Fatalf("collapsed pane kept filtered connection list:\n%s", plain)
294+
}
295+
}
296+
297+
func TestVisiblePaneHintsIncludePanelAndHelpControls(t *testing.T) {
298+
app := newAppModel(nil, nil, TabConnect, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
299+
app = updateApp(t, app, tea.WindowSizeMsg{Width: 120, Height: 24})
300+
plain := ansi.Strip(app.View())
301+
if strings.Count(plain, "CTRL+P hide panel") != 2 || strings.Count(plain, "? hide help") != 2 {
302+
t.Fatalf("pane controls missing from visible hints:\n%s", plain)
303+
}
304+
}
305+
220306
func TestPassphraseDialogIsCompositedOverCurrentPanes(t *testing.T) {
221307
app := newAppModel(nil, nil, TabCreate, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
222308
app = updateApp(t, app, tea.WindowSizeMsg{Width: 100, Height: 24})

internal/tui/group_save_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,53 @@ func TestEscapeFromEditAssignmentDiscardsEntireDraft(t *testing.T) {
103103
t.Fatalf("cancel changed durable assignments: %v, err %v", ids, err)
104104
}
105105
}
106+
107+
func TestHidingRightPaneDiscardsCreateAssignmentDraft(t *testing.T) {
108+
d := newTUITestDB(t)
109+
group, _ := db.CreateGroup(d, "Production")
110+
app := newAppModel(nil, d, TabCreate, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
111+
app = updateApp(t, app, tea.WindowSizeMsg{Width: 100, Height: 24})
112+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyTab})
113+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyDown})
114+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeySpace})
115+
if ids := app.createAssignment.SelectedGroupIDs(); len(ids) != 1 || ids[0] != group.ID {
116+
t.Fatalf("create draft IDs = %v, want [%d]", ids, group.ID)
117+
}
118+
119+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyCtrlP})
120+
if ids := app.createAssignment.SelectedGroupIDs(); len(ids) != 0 {
121+
t.Fatalf("hidden pane kept create assignment draft: %v", ids)
122+
}
123+
}
124+
125+
func TestHidingRightPaneRestoresPersistedEditAssignments(t *testing.T) {
126+
d := newTUITestDB(t)
127+
group, _ := db.CreateGroup(d, "Production")
128+
connection := model.Connection{
129+
Name: "api", User: "root", Host: "api.example", Port: 22,
130+
AuthType: model.AuthKey, Source: model.SourceSQLite,
131+
}
132+
if err := db.InsertWithGroups(d, &connection, model.ConnectionRef{Source: model.SourceSQLite, Name: "api"}, []int64{group.ID}); err != nil {
133+
t.Fatal(err)
134+
}
135+
136+
app := newAppModel([]model.Connection{connection}, d, TabEdit, &model.RuntimeConfig{ParseMode: model.ParseModeSQLiteOnly})
137+
app = updateApp(t, app, tea.WindowSizeMsg{Width: 100, Height: 24})
138+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyEnter})
139+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyTab})
140+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeySpace})
141+
if ids := app.editAssignment.SelectedGroupIDs(); len(ids) != 0 {
142+
t.Fatalf("edit draft IDs = %v, want no groups", ids)
143+
}
144+
145+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyCtrlP})
146+
if ids := app.editAssignment.SelectedGroupIDs(); len(ids) != 1 || ids[0] != group.ID {
147+
t.Fatalf("hidden pane did not restore persisted assignments: %v", ids)
148+
}
149+
app = updateApp(t, app, tea.KeyMsg{Type: tea.KeyCtrlS})
150+
151+
ids, err := db.GroupIDsForConnection(d, model.ConnectionRef{Source: model.SourceSQLite, Name: "api"})
152+
if err != nil || len(ids) != 1 || ids[0] != group.ID {
153+
t.Fatalf("save after hiding pane changed durable assignments: %v, err %v", ids, err)
154+
}
155+
}

0 commit comments

Comments
 (0)