Skip to content

Commit b0cba03

Browse files
authored
feat(browse): modern borderless TUI redesign with UX improvements (#43)
* feat(browse): redesign TUI with borderless minimal layout Replace bordered Miller columns with a modern borderless design: - Breadcrumb header (project › collection › doc) with muted pastel palette - Borderless columns separated by thin │ pipes - ▌ cursor accent bar on active column, · bullet prefixes for items - Merged status + keybinding hints into a single footer line - Centered OverlayInfo dialog for multi-line command output (:help, :marks) - Extracted shared itemViewHeight() to fix fragile height duplication - Fixed double separator subtraction in autoScroll width calculation * fix(browse): show tab completion suggestions without shifting command line Replace the separator line with suggestions instead of adding an extra footer line, so the command input stays in a fixed position. * fix(browse): show hint instead of quitting on Esc in normal mode Esc now flashes "Press q to quit" rather than exiting, preventing accidental session loss in this modal app. Esc still dismisses overlays and exits other modes as expected. * feat(browse): add :quit command * fix(browse): Ctrl+C in command mode returns to normal mode instead of quitting * fix(browse): dismiss status notification on any keypress * feat(browse): add :man command with comprehensive scrollable manual - Add :man command with full documentation of all keybindings, modes, commands, and features - Make info overlay scrollable via viewport (j/k, pgup/pgdn) to support long content like the manual - Add scroll percentage indicator in overlay footer - Update :help to reference :man for the full manual --------- Co-authored-by: Gustavo Hoirisch <355877+gugahoi@users.noreply.github.com>
1 parent d0c3c30 commit b0cba03

6 files changed

Lines changed: 612 additions & 524 deletions

File tree

pkg/cmd/browse/command.go

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ type pendingFetchCmd struct {
8282
func initCommandRegistry() *CommandRegistry {
8383
r := NewCommandRegistry()
8484

85+
r.Register(Command{
86+
Name: "quit",
87+
Description: "Quit the application",
88+
Usage: ":quit",
89+
Handler: cmdQuit,
90+
})
91+
92+
r.Register(Command{
93+
Name: "man",
94+
Description: "Show the full manual",
95+
Usage: ":man",
96+
Handler: cmdMan,
97+
})
98+
8599
r.Register(Command{
86100
Name: "help",
87101
Description: "List all available commands",
@@ -148,12 +162,127 @@ func initCommandRegistry() *CommandRegistry {
148162
return r
149163
}
150164

165+
func cmdMan(m *Model, args []string) (string, error) {
166+
return `FIRESTORE BROWSE — MANUAL
167+
168+
OVERVIEW
169+
A vim-style TUI for browsing Firestore databases.
170+
Navigate collections and documents using Miller columns.
171+
172+
MODES
173+
NORMAL Default mode. Navigate, view, and operate on data.
174+
VISUAL Select multiple items for bulk operations.
175+
COMMAND Enter commands with : prefix.
176+
177+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
178+
NAVIGATION
179+
180+
j / ↓ Move cursor down
181+
k / ↑ Move cursor up
182+
g Jump to top of column
183+
G Jump to bottom of column
184+
l / → / Enter Navigate forward (open collection/document)
185+
h / ← / Bksp Navigate back (close column / collapse node)
186+
Space Toggle expand/collapse on tree node
187+
188+
Ctrl+d / PgDn Scroll down (half page)
189+
Ctrl+u / PgUp Scroll up (half page)
190+
191+
Ctrl+g Go to path (opens path input dialog)
192+
/ Filter current column (substring match)
193+
194+
Ctrl+o Jump back in history
195+
Ctrl+i Jump forward in history
196+
197+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
198+
MARKS & BOOKMARKS
199+
200+
m + [a-z] Set a mark at current location
201+
' + [a-z] Jump to a mark
202+
203+
:marks List all set marks
204+
205+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
206+
TREE VIEW CONTROLS
207+
208+
zM Fold all (collapse entire tree)
209+
zR Unfold all (expand entire tree)
210+
z1 Fold to depth 1
211+
z2 Fold to depth 2
212+
z3 Fold to depth 3
213+
214+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
215+
DOCUMENT OPERATIONS
216+
217+
e Edit document (opens $EDITOR)
218+
d Delete document (with confirmation)
219+
r Refresh current column
220+
p Toggle preview pane
221+
222+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
223+
CLIPBOARD / COPY
224+
225+
yy Copy selected value (field value or path)
226+
ya Copy entire document as JSON
227+
Y Copy ID or field key
228+
229+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
230+
SORT & QUERY
231+
232+
s Open sort dialog
233+
S Clear sort for current collection
234+
235+
:sort <f> [dir] Sort by field (asc/desc)
236+
:query <f> <op> <v> Server-side Firestore query
237+
Operators: == != < > <= >=
238+
array-contains in
239+
240+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
241+
VISUAL MODE
242+
243+
v Toggle item selection / enter visual mode
244+
V Range select (anchor + move to extend)
245+
j / k Move and extend selection
246+
d Bulk delete selected documents
247+
Esc Exit visual mode
248+
249+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
250+
COMMANDS
251+
252+
:help List available commands
253+
:man Show this manual
254+
:quit Quit the application
255+
:goto <path> Navigate to a Firestore path
256+
:refresh Refresh current column
257+
:sort <field> [asc|desc] Sort collection by field
258+
:query <f> <op> <val> Filter with a server-side query
259+
:set limit <n> Set pagination limit (default: 50)
260+
:export json|ndjson [f] Export documents (to file or clipboard)
261+
:add [id] Create a new document
262+
:marks List all bookmarks
263+
264+
Tab in command mode autocompletes command names.
265+
266+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
267+
QUITTING
268+
269+
q Quit
270+
:quit Quit
271+
Ctrl+c Quit (except in command mode, where it cancels)
272+
Esc Shows "Press q to quit" hint`, nil
273+
}
274+
275+
func cmdQuit(m *Model, args []string) (string, error) {
276+
m.pendingQuit = true
277+
return "", nil
278+
}
279+
151280
func cmdHelp(m *Model, args []string) (string, error) {
152281
var lines []string
153282
for _, cmd := range m.commandRegistry.All() {
154283
lines = append(lines, fmt.Sprintf(" %-20s %s", cmd.Usage, cmd.Description))
155284
}
156-
return "Commands:\n" + strings.Join(lines, "\n"), nil
285+
return "Commands:\n" + strings.Join(lines, "\n") + "\n\nType :man for the full manual.", nil
157286
}
158287

159288
func cmdGoto(m *Model, args []string) (string, error) {

pkg/cmd/browse/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
OverlaySortDialog
2929
OverlayDeleteConfirm
3030
OverlayFilter
31+
OverlayInfo
3132
)
3233

3334
func (m Mode) String() string {
@@ -66,6 +67,7 @@ type Model struct {
6667
commandResult string
6768
pendingFetch *pendingFetchCmd
6869
pendingEditor tea.Cmd
70+
pendingQuit bool
6971

7072
// Sort dialog
7173
sortDialog sortDialogModel
@@ -109,6 +111,10 @@ type Model struct {
109111
filterInput textinput.Model
110112
filterActive bool
111113
filterPattern string
114+
115+
// Info overlay (for :help, :marks, etc.)
116+
infoContent string
117+
infoViewport viewport.Model
112118
}
113119

114120
// sortStateEntry stores sort configuration for a collection path

pkg/cmd/browse/navigation.go

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -135,23 +135,28 @@ func (m *Model) moveCursor(delta int) {
135135
}
136136
}
137137

138+
func (m *Model) itemViewHeight() int {
139+
h := m.height - 7 // outer chrome(5): header, blank, separator, status, footer; column header(2): title, underline
140+
if h < 1 {
141+
return 10
142+
}
143+
return h
144+
}
145+
138146
// autoScroll adjusts scroll offset to keep cursor visible
139147
func (m *Model) autoScroll() {
140148
if m.activeColumn >= len(m.columns) {
141149
return
142150
}
143151

144152
col := &m.columns[m.activeColumn]
145-
// Match height calculation in view.go: m.height - 5 (header/statusbar/footer/error/margin) - 2 (borders)
146-
viewHeight := m.height - 7
147-
if viewHeight < 1 {
148-
viewHeight = 10
149-
}
153+
viewHeight := m.itemViewHeight()
150154

151155
// Calculate column width and inner width (same as in renderColumn)
152156
visibleCols := getVisibleColumns(m.columns, m.activeColumn)
153-
colWidth := calculateColumnWidth(m.width, len(visibleCols))
154-
innerWidth := max(colWidth-4, 1)
157+
availWidth := m.width - 4 // 2 padding on each side
158+
colWidth := calculateColumnWidth(availWidth, len(visibleCols))
159+
innerWidth := max(colWidth-2, 1)
155160

156161
// Create wrapper for measuring wrapped line lengths
157162
wrapper := lipgloss.NewStyle().Width(innerWidth)
@@ -167,8 +172,10 @@ func (m *Model) autoScroll() {
167172
if section.hidden {
168173
continue
169174
}
170-
// Section header takes 1 line
171-
cursorLine++
175+
// Section header takes 1 line (but "Documents" is skipped for collection columns)
176+
if section.title != "Documents" || col.isDoc {
177+
cursorLine++
178+
}
172179

173180
if section.title == "Data" {
174181
// Tree view logic - we need to traverse the tree to find where the cursor lands
@@ -297,19 +304,14 @@ func (m *Model) scrollDown() {
297304
}
298305

299306
col := &m.columns[m.activeColumn]
300-
viewHeight := m.height - 7
301-
if viewHeight < 1 {
302-
viewHeight = 10
303-
}
307+
viewHeight := m.itemViewHeight()
304308

305-
pageSize := viewHeight / 2 // Half page scroll
309+
pageSize := viewHeight / 2
306310
if pageSize < 1 {
307311
pageSize = 1
308312
}
309313

310314
col.scrollOffset += pageSize
311-
// Upper bound will be checked in renderColumn, but we can't check it here
312-
// because we don't know the full content length until render time.
313315
}
314316

315317
// scrollUp scrolls the active column up by half a page
@@ -319,10 +321,7 @@ func (m *Model) scrollUp() {
319321
}
320322

321323
col := &m.columns[m.activeColumn]
322-
viewHeight := m.height - 7
323-
if viewHeight < 1 {
324-
viewHeight = 10
325-
}
324+
viewHeight := m.itemViewHeight()
326325

327326
pageSize := viewHeight / 2 // Half page scroll
328327
if pageSize < 1 {
@@ -370,7 +369,6 @@ func (m *Model) removeLastColumn() {
370369

371370
// calculateColumnWidth calculates the width for each column
372371
func calculateColumnWidth(termWidth int, numColumns int) int {
373-
// Show max 4 columns at a time
374372
visibleCols := numColumns
375373
if visibleCols > 4 {
376374
visibleCols = 4
@@ -380,10 +378,11 @@ func calculateColumnWidth(termWidth int, numColumns int) int {
380378
return termWidth
381379
}
382380

383-
// Account for borders and padding
384-
width := (termWidth - (visibleCols * 4)) / visibleCols
381+
// Separators between columns: " │ " = 3 chars each
382+
separatorWidth := (visibleCols - 1) * 3
383+
width := (termWidth - separatorWidth) / visibleCols
385384
if width < 10 {
386-
width = 10 // Minimum width
385+
width = 10
387386
}
388387
return width
389388
}

0 commit comments

Comments
 (0)