detached 3fb5902

fix(ui): polish session manager shortcuts, layout, and display

commit
3fb5902a227988b8313f09f8271518479ae78f31
author
brice · · 2025-12-25 07:33 UTC
parent
85924a1
signature
unsigned
2 files · +80 -36
M internal/ui/component/session_manager.go +61 -28
@@ -3,6 +3,7 @@ package component
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "sync"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -348,7 +349,7 @@ func (sm *SessionManager) initList() error {
348349 }
349350
350351 func (sm *SessionManager) initFooter() error {
351- footerText := "↑↓/jk navigate Tab expand Enter open x delete Esc close"
352+ footerText := "↑↓/jk navigate Enter expand r restore x delete Esc close"
352353 sm.footerLabel = gtk.NewLabel(&footerText)
353354 if sm.footerLabel == nil {
354355 return errNilWidget("sessionFooterLabel")
@@ -387,15 +388,17 @@ func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
387388 sm.selectNext()
388389 return true
389390
390- case uint(gdk.KEY_Tab), uint(gdk.KEY_space):
391+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter), uint(gdk.KEY_space):
392+ // Enter/Space expands/collapses to preview session contents
391393 sm.toggleExpanded()
392394 return true
393395
394- case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
396+ case uint(gdk.KEY_r), uint(gdk.KEY_o):
397+ // r/o restores the selected session
395398 sm.openSelected()
396399 return true
397400
398- case uint(gdk.KEY_x), uint(gdk.KEY_Delete):
401+ case uint(gdk.KEY_x), uint(gdk.KEY_d), uint(gdk.KEY_Delete):
399402 sm.deleteSelected()
400403 return true
401404 }
@@ -539,14 +542,15 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
539542 return
540543 }
541544
542- // Calculate max visible height (6 session rows max visible, accounting for divider)
543- maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
544-
545545 // Calculate actual content height
546546 contentHeight := sessionRowCount*sessionRowHeight +
547547 dividerCount*sessionDividerRowHeight +
548548 treeRowCount*sessionTreeRowHeight
549549
550+ // Calculate max visible height - limit to prevent oversized modal
551+ // Allow up to sessionMaxVisibleRows session rows plus one divider
552+ maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
553+
550554 // Cap at max visible height - rest will be scrollable
551555 if contentHeight > maxVisibleHeight {
552556 contentHeight = maxVisibleHeight
@@ -557,10 +561,12 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
557561 contentHeight = sessionRowHeight
558562 }
559563
560- // Schedule resize
564+ // Schedule resize on main thread
565+ // Use the same pattern as omnibox: reset min to -1 first to avoid GTK assertion
561566 cb := glib.SourceFunc(func(_ uintptr) bool {
562- sm.scrolledWindow.SetMinContentHeight(contentHeight)
567+ sm.scrolledWindow.SetMinContentHeight(-1)
563568 sm.scrolledWindow.SetMaxContentHeight(contentHeight)
569+ sm.scrolledWindow.SetMinContentHeight(contentHeight)
564570 sm.outerBox.QueueResize()
565571 return false
566572 })
@@ -668,9 +674,9 @@ func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool,
668674
669675 // Build indent and branch prefix
670676 var prefix string
671- indent := " " // Base indent to align with session row content
677+ indent := " " // Base indent (minimal, tree branches provide visual alignment)
672678 for i := 0; i < depth; i++ {
673- indent += " " // 4 spaces per depth level
679+ indent += " " // 3 spaces per depth level
674680 }
675681
676682 // Branch character
@@ -753,13 +759,13 @@ func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionI
753759 var statusText string
754760 switch {
755761 case info.IsCurrent:
756- statusText = "●"
762+ statusText = "●" // Filled dot for current
757763 hbox.AddCssClass("session-current")
758764 case info.IsActive:
759- statusText = "○"
765+ statusText = "○" // Empty dot for other active instance
760766 hbox.AddCssClass("session-active")
761767 default:
762- statusText = " "
768+ statusText = "◼" // Small square for exited (like stop icon)
763769 hbox.AddCssClass("session-exited")
764770 }
765771 statusLabel := gtk.NewLabel(&statusText)
@@ -777,20 +783,47 @@ func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo)
777783 }
778784 infoBox.SetHexpand(true)
779785
780- // Session ID (short)
781- idText := string(info.Session.ID)
782- if len(idText) > sessionIDMaxDisplay {
783- idText = idText[:sessionIDMaxDisplay]
784- }
785- if info.IsCurrent {
786- idText += " (current)"
787- }
788- idLabel := gtk.NewLabel(&idText)
789- if idLabel != nil {
790- idLabel.AddCssClass("session-id")
791- idLabel.SetHalign(gtk.AlignStartValue)
792- idLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
793- infoBox.Append(&idLabel.Widget)
786+ // Session ID row: shortID (accent) + rest of ID (dimmed) + optional (current) badge
787+ idRowBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
788+ if idRowBox != nil {
789+ idRowBox.SetHalign(gtk.AlignStartValue)
790+
791+ fullID := string(info.Session.ID)
792+ shortID := info.Session.ShortID() // Last 4 chars
793+
794+ // Short ID with accent color (displayed first)
795+ shortLabel := gtk.NewLabel(&shortID)
796+ if shortLabel != nil {
797+ shortLabel.AddCssClass("session-id-short")
798+ idRowBox.Append(&shortLabel.Widget)
799+ }
800+
801+ // Rest of the ID (prefix before the short suffix), dimmed
802+ // Format: "20251225_083026_7566" -> shortID="7566", prefix="20251225_083026"
803+ // Remove trailing underscore for cleaner display
804+ if len(fullID) > 4 {
805+ prefix := fullID[:len(fullID)-4]
806+ // Trim trailing underscore if present
807+ prefix = strings.TrimSuffix(prefix, "_")
808+ prefix = " " + prefix
809+ prefixLabel := gtk.NewLabel(&prefix)
810+ if prefixLabel != nil {
811+ prefixLabel.AddCssClass("session-id-prefix")
812+ idRowBox.Append(&prefixLabel.Widget)
813+ }
814+ }
815+
816+ // Current badge
817+ if info.IsCurrent {
818+ badge := " (current)"
819+ badgeLabel := gtk.NewLabel(&badge)
820+ if badgeLabel != nil {
821+ badgeLabel.AddCssClass("session-id-badge")
822+ idRowBox.Append(&badgeLabel.Widget)
823+ }
824+ }
825+
826+ infoBox.Append(&idRowBox.Widget)
794827 }
795828
796829 // Tab/pane count
@@ -3,6 +3,7 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 "sync"7 "sync"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -348,7 +349,7 @@ func (sm *SessionManager) initList() error {
348 }349 }
349 350
350 func (sm *SessionManager) initFooter() error {351 func (sm *SessionManager) initFooter() error {
351- footerText := "↑↓/jk navigate Tab expand Enter open x delete Esc close"352+ footerText := "↑↓/jk navigate Enter expand r restore x delete Esc close"
352 sm.footerLabel = gtk.NewLabel(&footerText)353 sm.footerLabel = gtk.NewLabel(&footerText)
353 if sm.footerLabel == nil {354 if sm.footerLabel == nil {
354 return errNilWidget("sessionFooterLabel")355 return errNilWidget("sessionFooterLabel")
@@ -387,15 +388,17 @@ func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
387 sm.selectNext()388 sm.selectNext()
388 return true389 return true
389 390
390- case uint(gdk.KEY_Tab), uint(gdk.KEY_space):391+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter), uint(gdk.KEY_space):
392+ // Enter/Space expands/collapses to preview session contents
391 sm.toggleExpanded()393 sm.toggleExpanded()
392 return true394 return true
393 395
394- case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):396+ case uint(gdk.KEY_r), uint(gdk.KEY_o):
397+ // r/o restores the selected session
395 sm.openSelected()398 sm.openSelected()
396 return true399 return true
397 400
398- case uint(gdk.KEY_x), uint(gdk.KEY_Delete):401+ case uint(gdk.KEY_x), uint(gdk.KEY_d), uint(gdk.KEY_Delete):
399 sm.deleteSelected()402 sm.deleteSelected()
400 return true403 return true
401 }404 }
@@ -539,14 +542,15 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
539 return542 return
540 }543 }
541 544
542- // Calculate max visible height (6 session rows max visible, accounting for divider)
543- maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
544-
545 // Calculate actual content height545 // Calculate actual content height
546 contentHeight := sessionRowCount*sessionRowHeight +546 contentHeight := sessionRowCount*sessionRowHeight +
547 dividerCount*sessionDividerRowHeight +547 dividerCount*sessionDividerRowHeight +
548 treeRowCount*sessionTreeRowHeight548 treeRowCount*sessionTreeRowHeight
549 549
550+ // Calculate max visible height - limit to prevent oversized modal
551+ // Allow up to sessionMaxVisibleRows session rows plus one divider
552+ maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
553+
550 // Cap at max visible height - rest will be scrollable554 // Cap at max visible height - rest will be scrollable
551 if contentHeight > maxVisibleHeight {555 if contentHeight > maxVisibleHeight {
552 contentHeight = maxVisibleHeight556 contentHeight = maxVisibleHeight
@@ -557,10 +561,12 @@ func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRo
557 contentHeight = sessionRowHeight561 contentHeight = sessionRowHeight
558 }562 }
559 563
560- // Schedule resize564+ // Schedule resize on main thread
565+ // Use the same pattern as omnibox: reset min to -1 first to avoid GTK assertion
561 cb := glib.SourceFunc(func(_ uintptr) bool {566 cb := glib.SourceFunc(func(_ uintptr) bool {
562- sm.scrolledWindow.SetMinContentHeight(contentHeight)567+ sm.scrolledWindow.SetMinContentHeight(-1)
563 sm.scrolledWindow.SetMaxContentHeight(contentHeight)568 sm.scrolledWindow.SetMaxContentHeight(contentHeight)
569+ sm.scrolledWindow.SetMinContentHeight(contentHeight)
564 sm.outerBox.QueueResize()570 sm.outerBox.QueueResize()
565 return false571 return false
566 })572 })
@@ -668,9 +674,9 @@ func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool,
668 674
669 // Build indent and branch prefix675 // Build indent and branch prefix
670 var prefix string676 var prefix string
671- indent := " " // Base indent to align with session row content677+ indent := " " // Base indent (minimal, tree branches provide visual alignment)
672 for i := 0; i < depth; i++ {678 for i := 0; i < depth; i++ {
673- indent += " " // 4 spaces per depth level679+ indent += " " // 3 spaces per depth level
674 }680 }
675 681
676 // Branch character682 // Branch character
@@ -753,13 +759,13 @@ func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionI
753 var statusText string759 var statusText string
754 switch {760 switch {
755 case info.IsCurrent:761 case info.IsCurrent:
756- statusText = "●"762+ statusText = "●" // Filled dot for current
757 hbox.AddCssClass("session-current")763 hbox.AddCssClass("session-current")
758 case info.IsActive:764 case info.IsActive:
759- statusText = "○"765+ statusText = "○" // Empty dot for other active instance
760 hbox.AddCssClass("session-active")766 hbox.AddCssClass("session-active")
761 default:767 default:
762- statusText = " "768+ statusText = "◼" // Small square for exited (like stop icon)
763 hbox.AddCssClass("session-exited")769 hbox.AddCssClass("session-exited")
764 }770 }
765 statusLabel := gtk.NewLabel(&statusText)771 statusLabel := gtk.NewLabel(&statusText)
@@ -777,20 +783,47 @@ func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo)
777 }783 }
778 infoBox.SetHexpand(true)784 infoBox.SetHexpand(true)
779 785
780- // Session ID (short)786+ // Session ID row: shortID (accent) + rest of ID (dimmed) + optional (current) badge
781- idText := string(info.Session.ID)787+ idRowBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
782- if len(idText) > sessionIDMaxDisplay {788+ if idRowBox != nil {
783- idText = idText[:sessionIDMaxDisplay]789+ idRowBox.SetHalign(gtk.AlignStartValue)
784- }790+
785- if info.IsCurrent {791+ fullID := string(info.Session.ID)
786- idText += " (current)"792+ shortID := info.Session.ShortID() // Last 4 chars
787- }793+
788- idLabel := gtk.NewLabel(&idText)794+ // Short ID with accent color (displayed first)
789- if idLabel != nil {795+ shortLabel := gtk.NewLabel(&shortID)
790- idLabel.AddCssClass("session-id")796+ if shortLabel != nil {
791- idLabel.SetHalign(gtk.AlignStartValue)797+ shortLabel.AddCssClass("session-id-short")
792- idLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END798+ idRowBox.Append(&shortLabel.Widget)
793- infoBox.Append(&idLabel.Widget)799+ }
800+
801+ // Rest of the ID (prefix before the short suffix), dimmed
802+ // Format: "20251225_083026_7566" -> shortID="7566", prefix="20251225_083026"
803+ // Remove trailing underscore for cleaner display
804+ if len(fullID) > 4 {
805+ prefix := fullID[:len(fullID)-4]
806+ // Trim trailing underscore if present
807+ prefix = strings.TrimSuffix(prefix, "_")
808+ prefix = " " + prefix
809+ prefixLabel := gtk.NewLabel(&prefix)
810+ if prefixLabel != nil {
811+ prefixLabel.AddCssClass("session-id-prefix")
812+ idRowBox.Append(&prefixLabel.Widget)
813+ }
814+ }
815+
816+ // Current badge
817+ if info.IsCurrent {
818+ badge := " (current)"
819+ badgeLabel := gtk.NewLabel(&badge)
820+ if badgeLabel != nil {
821+ badgeLabel.AddCssClass("session-id-badge")
822+ idRowBox.Append(&badgeLabel.Widget)
823+ }
824+ }
825+
826+ infoBox.Append(&idRowBox.Widget)
794 }827 }
795 828
796 // Tab/pane count829 // Tab/pane count
M internal/ui/theme/css.go +19 -8
@@ -643,25 +643,36 @@ row:selected .session-manager-row {
643643 color: var(--muted);
644644 }
645645
646-/* Exited session - no dot, dimmed */
646+/* Exited session - dimmed appearance with subtle stop icon */
647647 .session-exited {
648- opacity: 0.7;
648+ opacity: 0.75;
649649 }
650650
651651 .session-exited .session-status {
652- color: transparent;
652+ color: var(--error);
653+ opacity: 0.5;
653654 }
654655
655-/* Session ID label */
656-.session-id {
656+/* Session ID labels */
657+.session-id-short {
657658 font-size: 0.875em;
658- font-weight: 500;
659- color: var(--text);
659+ font-weight: 600;
660+ color: var(--accent);
660661 font-family: var(--font-mono);
661662 }
662663
663-.session-current .session-id {
664+.session-id-prefix {
665+ font-size: 0.875em;
666+ font-weight: 400;
667+ color: var(--muted);
668+ font-family: var(--font-mono);
669+}
670+
671+.session-id-badge {
672+ font-size: 0.75em;
673+ font-weight: 500;
664674 color: var(--accent);
675+ margin-left: 0.5em;
665676 }
666677
667678 /* Tab/pane count label */
@@ -643,25 +643,36 @@ row:selected .session-manager-row {
643 color: var(--muted);643 color: var(--muted);
644 }644 }
645 645
646-/* Exited session - no dot, dimmed */646+/* Exited session - dimmed appearance with subtle stop icon */
647 .session-exited {647 .session-exited {
648- opacity: 0.7;648+ opacity: 0.75;
649 }649 }
650 650
651 .session-exited .session-status {651 .session-exited .session-status {
652- color: transparent;652+ color: var(--error);
653+ opacity: 0.5;
653 }654 }
654 655
655-/* Session ID label */656+/* Session ID labels */
656-.session-id {657+.session-id-short {
657 font-size: 0.875em;658 font-size: 0.875em;
658- font-weight: 500;659+ font-weight: 600;
659- color: var(--text);660+ color: var(--accent);
660 font-family: var(--font-mono);661 font-family: var(--font-mono);
661 }662 }
662 663
663-.session-current .session-id {664+.session-id-prefix {
665+ font-size: 0.875em;
666+ font-weight: 400;
667+ color: var(--muted);
668+ font-family: var(--font-mono);
669+}
670+
671+.session-id-badge {
672+ font-size: 0.75em;
673+ font-weight: 500;
664 color: var(--accent);674 color: var(--accent);
675+ margin-left: 0.5em;
665 }676 }
666 677
667 /* Tab/pane count label */678 /* Tab/pane count label */

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