diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 794dedc..22be58e 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -16,6 +16,7 @@ import ( "github.com/zaidejjo/zgit/pkg/core/github" "github.com/zaidejjo/zgit/pkg/core/models" "github.com/zaidejjo/zgit/pkg/tui/components" + "github.com/zaidejjo/zgit/pkg/tui/shell" "github.com/zaidejjo/zgit/pkg/tui/styles" "github.com/zaidejjo/zgit/pkg/tui/views" ) @@ -32,16 +33,8 @@ const ( panelCount ) -// Full-screen view identifiers (override panel layout). -// Values 3+ to avoid overlap with subscriber event IDs (0=status,1=log,2=branches). -const ( - fsNone = -1 // normal panel layout - fsPR = 3 // matches old ViewPR for subscriber compatibility - fsIssue = 4 // matches old ViewIssue - fsDashboard = 5 - fsActions = 6 - fsNotes = 7 -) +// Full-screen screens live in shell.ScreenID (see pkg/tui/shell/route.go). +// Values 3+ avoid overlap with subscriber event IDs (0=status,1=log,2=branches). // Application modes (overlays and dialogs). const ( @@ -62,8 +55,8 @@ type Model struct { msgs chan teaMsg // State - focusedPanel int // which panel has cursor focus (0-2) - fullScreenView int // -1 = panels, fsPR, fsIssue + focusedPanel int // which panel has cursor focus (0-2) + fullScreenView shell.ScreenID // Panels = panels, else fullscreen showHelp bool mode int // normal, diff, commit overlay ready bool @@ -139,7 +132,7 @@ func NewModel(gitExec *gitpkg.NativeExec) *Model { help: help.New(), mode: modeNormal, focusedPanel: PanelStatus, - fullScreenView: fsNone, + fullScreenView: shell.Panels, } // Try to initialize GitHub client from config @@ -292,53 +285,16 @@ func (m *Model) Init() tea.Cmd { // --- Layout calculations --- func (m *Model) calcLayout() { - statusBarH := 1 - headerH := 1 - m.contentHeight = m.height - statusBarH - headerH - if m.contentHeight < 4 { - m.contentHeight = 4 - } - - // Reserve sidebar width if active - m.sidebarWidth = 0 + l := shell.Compute(m.width, m.height, m.aiData.Sidebar.Active()) + m.contentHeight = l.ContentHeight + m.sidebarWidth = l.SidebarWidth + m.leftWidth = l.LeftWidth + m.rightWidth = l.RightWidth if m.aiData.Sidebar.Active() { - m.sidebarWidth = 42 // matches Width(42) in sidebar View (includes border+padding) m.aiData.Sidebar.Width = 42 m.aiData.Sidebar.Height = m.contentHeight } - availW := m.width - m.sidebarWidth - if availW < 54 { - availW = 54 // minimum viable panel width - m.sidebarWidth = m.width - availW - if m.sidebarWidth < 0 { - m.sidebarWidth = 0 - } - } - - m.leftWidth = int(float64(availW)*0.4 + 0.5) - m.rightWidth = availW - m.leftWidth - - // Minimum widths - if m.leftWidth < 24 { - m.leftWidth = 24 - m.rightWidth = m.width - m.leftWidth - } - if m.rightWidth < 30 { - m.rightWidth = 30 - m.leftWidth = m.width - m.rightWidth - } - if m.leftWidth < 24 { - m.leftWidth = 24 - m.rightWidth = m.width - m.leftWidth - } - if m.leftWidth < 4 { - m.leftWidth = 4 - } - if m.rightWidth < 4 { - m.rightWidth = 4 - } - // Panel heights for right side (log top, branches bottom) // Each panel loses 2 for border + 1 for title = 3 lines of overhead logPanelH := int(float64(m.contentHeight)*0.55 + 0.5) @@ -353,13 +309,11 @@ func (m *Model) calcLayout() { if m.branches.Height < 1 { m.branches.Height = 1 } - m.prs.Height = m.contentHeight - 4 - if m.prs.Height < 1 { - m.prs.Height = 1 - } - m.issues.Height = m.contentHeight - 4 - if m.issues.Height < 1 { - m.issues.Height = 1 + for _, h := range []*int{&m.prs.Height, &m.issues.Height, &m.dash.Height, &m.actions.Height, &m.notes.Height} { + *h = m.contentHeight - 4 + if *h < 1 { + *h = 1 + } } } @@ -496,24 +450,21 @@ func (m *Model) renderHeader() string { var tabs []string tabs = append(tabs, styles.HeaderLogoStyle.Render(" zgit ")) - panelActive := m.fullScreenView == fsNone + panelActive := m.fullScreenView == shell.Panels tabs = append(tabs, tab("Status", panelActive && m.focusedPanel == PanelStatus)) tabs = append(tabs, tab("Log", panelActive && m.focusedPanel == PanelLog)) tabs = append(tabs, tab("Branches", panelActive && m.focusedPanel == PanelBranch)) - tabs = append(tabs, tab("3 PRs", m.fullScreenView == fsPR)) - tabs = append(tabs, tab("4 Issues", m.fullScreenView == fsIssue)) - // Compact labels on narrow terminals so the strip never wraps. - reposLabel, actionsLabel := "5 Repos", "6 Actions" - if m.width > 0 && m.width < 110 { - reposLabel, actionsLabel = "5R", "6A" - } - tabs = append(tabs, tab(reposLabel, m.fullScreenView == fsDashboard)) - tabs = append(tabs, tab(actionsLabel, m.fullScreenView == fsActions)) - notesLabel := "7 Notes" - if m.notes.Loaded && m.notes.UnreadCount() > 0 { - notesLabel = fmt.Sprintf("7 Notes (%d)", m.notes.UnreadCount()) - } - tabs = append(tabs, tab(notesLabel, m.fullScreenView == fsNotes)) + compact := m.width > 0 && m.width < 110 + for _, t := range shell.Tabs { + label := t.Full() + if compact { + label = t.Short() + } + if t.ID == shell.Notes && m.notes.Loaded && m.notes.UnreadCount() > 0 { + label = fmt.Sprintf("%s (%d)", label, m.notes.UnreadCount()) + } + tabs = append(tabs, tab(label, m.fullScreenView == t.ID)) + } left := lipgloss.JoinHorizontal(lipgloss.Center, tabs...) var meta []string @@ -548,15 +499,15 @@ func (m *Model) renderContent() string { // Full-screen views (PRs, Issues) switch m.fullScreenView { - case fsPR: + case shell.PRs: return m.prs.View(m.width - 2) - case fsIssue: + case shell.Issues: return m.issues.View(m.width - 2) - case fsDashboard: + case shell.Dashboard: return m.dash.View(m.width - 2) - case fsActions: + case shell.Actions: return m.actions.View(m.width - 2) - case fsNotes: + case shell.Notes: return m.notes.View(m.width - 2) } @@ -670,6 +621,13 @@ func (m *Model) renderStatusBar() string { func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + // Full-screen screens route through the shell table (keys 3-7). + // Single lookup drives tabs, keys and palette dispatch. + if t := shell.ByKey(key); t != nil { + m.openScreen(t.ID) + return m, nil + } + // Global keys switch key { case "q", "ctrl+c": @@ -698,17 +656,17 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Tab cycles focus: Status → Log → Branches → Status case "tab": - if m.fullScreenView != fsNone { + if m.fullScreenView != shell.Panels { // In full-screen view, Tab goes back to panels - m.fullScreenView = fsNone + m.fullScreenView = shell.Panels } else { m.focusedPanel = (m.focusedPanel + 1) % panelCount } return m, nil case "shift+tab": - if m.fullScreenView != fsNone { - m.fullScreenView = fsNone + if m.fullScreenView != shell.Panels { + m.fullScreenView = shell.Panels } else { m.focusedPanel = (m.focusedPanel - 1 + panelCount) % panelCount } @@ -731,7 +689,7 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // AI: quick ask sidebar (Ctrl+G) case "ctrl+g": - if m.mode == modeNormal && m.fullScreenView == fsNone { + if m.mode == modeNormal && m.fullScreenView == shell.Panels { if m.aiData.Sidebar.Active() { m.aiData.Sidebar.Close() } else { @@ -743,7 +701,7 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // AI: agent sidebar (Ctrl+E) case "ctrl+e": - if m.mode == modeNormal && m.fullScreenView == fsNone { + if m.mode == modeNormal && m.fullScreenView == shell.Panels { if m.aiData.Sidebar.Active() { m.aiData.Sidebar.Close() } else { @@ -753,37 +711,15 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // Direct panel focus + full-screen view switches + // Direct panel focus switches case "1": - m.fullScreenView = fsNone + m.fullScreenView = shell.Panels m.focusedPanel = PanelStatus return m, nil case "2": - m.fullScreenView = fsNone + m.fullScreenView = shell.Panels m.focusedPanel = PanelLog return m, nil - case "3": - m.fullScreenView = fsPR - m.ensureGHDataLoaded() - return m, nil - case "4": - m.fullScreenView = fsIssue - m.ensureGHDataLoaded() - return m, nil - case "5": - m.fullScreenView = fsDashboard - if !m.dash.Loaded { - go m.fetchDashboard() - } - return m, nil - case "6": - m.fullScreenView = fsActions - m.ensureGHDataLoaded() - return m, nil - case "7": - m.fullScreenView = fsNotes - m.ensureGHDataLoaded() - return m, nil } // If help is open, route keys to help model for search @@ -800,19 +736,19 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // If in full-screen view (PR/Issue), delegate to those handlers - if m.fullScreenView == fsPR { + if m.fullScreenView == shell.PRs { return m.handlePRKeys(key) } - if m.fullScreenView == fsIssue { + if m.fullScreenView == shell.Issues { return m.handleIssueKeys(key) } - if m.fullScreenView == fsDashboard { + if m.fullScreenView == shell.Dashboard { return m.handleDashKeys(key) } - if m.fullScreenView == fsActions { + if m.fullScreenView == shell.Actions { return m.handleActionsKeys(key) } - if m.fullScreenView == fsNotes { + if m.fullScreenView == shell.Notes { return m.handleNotesKeys(key) } @@ -837,25 +773,39 @@ func (m *Model) ensureGHDataLoaded() { } switch m.fullScreenView { - case fsPR: // PRs + case shell.PRs: // PRs if m.prs.PRs == nil { go m.fetchPRs() } - case fsIssue: // Issues + case shell.Issues: // Issues if m.issues.Issues == nil { go m.fetchIssues() } - case fsActions: // Actions + case shell.Actions: // Actions if m.actions.Runs == nil { go m.fetchActions() } - case fsNotes: // Notifications + case shell.Notes: // Notifications if !m.notes.Loaded { go m.fetchNotes() } } } +// openScreen switches to a fullscreen screen and triggers its lazy load. +// Single entry for keys, palette and tabs. +func (m *Model) openScreen(id shell.ScreenID) { + m.fullScreenView = id + switch id { + case shell.Dashboard: + if !m.dash.Loaded { + go m.fetchDashboard() + } + default: + m.ensureGHDataLoaded() + } +} + func (m *Model) refreshGitHubData() { if !m.ghDetected || m.gh == nil { return @@ -998,7 +948,7 @@ func (m *Model) switchRepo(path string) { m.ghOwner, m.ghRepo, m.ghDetected = "", "", false m.tryDetectRepo() m.sub.Refresh() - m.fullScreenView = fsNone + m.fullScreenView = shell.Panels m.focusedPanel = PanelStatus go m.fetchDashboard() } diff --git a/pkg/tui/app_header_test.go b/pkg/tui/app_header_test.go index eff5488..4400f68 100644 --- a/pkg/tui/app_header_test.go +++ b/pkg/tui/app_header_test.go @@ -6,6 +6,7 @@ import ( gitpkg "github.com/zaidejjo/zgit/pkg/core/git" "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/shell" ) func TestRenderHeader(t *testing.T) { @@ -27,7 +28,7 @@ func TestRenderHeader(t *testing.T) { } // Fullscreen PR tab highlights. - m.fullScreenView = fsPR + m.fullScreenView = shell.PRs out = m.renderHeader() if !strings.Contains(out, "PRs") { t.Errorf("header missing PRs tab") diff --git a/pkg/tui/app_palette.go b/pkg/tui/app_palette.go index ffede37..7032b24 100644 --- a/pkg/tui/app_palette.go +++ b/pkg/tui/app_palette.go @@ -3,6 +3,7 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" "github.com/zaidejjo/zgit/pkg/core/config" + "github.com/zaidejjo/zgit/pkg/tui/shell" "github.com/zaidejjo/zgit/pkg/tui/views" ) @@ -93,22 +94,15 @@ func (m *Model) executePaletteCommand(id string) (tea.Model, tea.Cmd) { case "prev-panel": m.focusedPanel = (m.focusedPanel - 1 + panelCount) % panelCount case "dashboard": - m.fullScreenView = fsDashboard - if !m.dash.Loaded { - go m.fetchDashboard() - } + m.openScreen(shell.Dashboard) case "actions-view": - m.fullScreenView = fsActions - m.ensureGHDataLoaded() + m.openScreen(shell.Actions) case "notifications": - m.fullScreenView = fsNotes - m.ensureGHDataLoaded() + m.openScreen(shell.Notes) case "prs-view": - m.fullScreenView = fsPR - m.ensureGHDataLoaded() + m.openScreen(shell.PRs) case "issues-view": - m.fullScreenView = fsIssue - m.ensureGHDataLoaded() + m.openScreen(shell.Issues) case "help": m.showHelp = true m.helpView.Input = "" diff --git a/pkg/tui/shell/layout.go b/pkg/tui/shell/layout.go new file mode 100644 index 0000000..1e0ce5d --- /dev/null +++ b/pkg/tui/shell/layout.go @@ -0,0 +1,89 @@ +package shell + +// Breakpoint selects the layout density for a terminal width. +type Breakpoint int + +const ( + // Compact fits 80-column terminals: single focused panel. + Compact Breakpoint = iota + // Standard is today's two-column layout. + Standard + // Wide adds room for detail docks and wider panels. + Wide +) + +// BreakpointFor classifies a terminal width. +func BreakpointFor(width int) Breakpoint { + switch { + case width < 100: + return Compact + case width < 150: + return Standard + default: + return Wide + } +} + +// Layout carries every computed dimension. Arithmetic mirrors the +// legacy calcLayout exactly; breakpoints only label the result for +// now (consumers reflow in later phases). +type Layout struct { + Width int + Height int + BP Breakpoint + ContentHeight int + SidebarWidth int + LeftWidth int + RightWidth int +} + +// Compute derives panel geometry. sidebarActive mirrors +// aiData.Sidebar.Active(); width/height are terminal dimensions. +func Compute(width, height int, sidebarActive bool) Layout { + l := Layout{Width: width, Height: height, BP: BreakpointFor(width)} + + const statusBarH = 1 + const headerH = 1 + l.ContentHeight = height - statusBarH - headerH + if l.ContentHeight < 4 { + l.ContentHeight = 4 + } + + l.SidebarWidth = 0 + if sidebarActive { + l.SidebarWidth = 42 // matches Width(42) in sidebar View + } + + availW := width - l.SidebarWidth + if availW < 54 { + availW = 54 // minimum viable panel width + l.SidebarWidth = width - availW + if l.SidebarWidth < 0 { + l.SidebarWidth = 0 + } + } + + l.LeftWidth = int(float64(availW)*0.4 + 0.5) + l.RightWidth = availW - l.LeftWidth + + // Minimum widths + if l.LeftWidth < 24 { + l.LeftWidth = 24 + l.RightWidth = width - l.LeftWidth + } + if l.RightWidth < 30 { + l.RightWidth = 30 + l.LeftWidth = width - l.RightWidth + } + if l.LeftWidth < 24 { + l.LeftWidth = 24 + l.RightWidth = width - l.LeftWidth + } + if l.LeftWidth < 4 { + l.LeftWidth = 4 + } + if l.RightWidth < 4 { + l.RightWidth = 4 + } + return l +} diff --git a/pkg/tui/shell/route.go b/pkg/tui/shell/route.go new file mode 100644 index 0000000..08395ef --- /dev/null +++ b/pkg/tui/shell/route.go @@ -0,0 +1,68 @@ +// Package shell owns TUI navigation and layout: the screen router, the +// responsive layout computation, and the key→screen registry. Extracted +// from the root model with identical arithmetic (no visual change). +package shell + +// ScreenID identifies a top-level view. Negative Panels keeps the old +// fsNone convention; fullscreen IDs stay compatible with subscriber +// event view numbers. +type ScreenID int + +const ( + Panels ScreenID = -1 + PRs ScreenID = 3 + Issues ScreenID = 4 + Dashboard ScreenID = 5 + Actions ScreenID = 6 + Notes ScreenID = 7 +) + +// Tab binds a screen to its header label and hotkey. +type Tab struct { + ID ScreenID + Label string + Key string +} + +// Tabs is the single source of truth for the header strip, the 1–7 +// keys, and palette navigation dispatch. +var Tabs = []Tab{ + {ID: PRs, Label: "PRs", Key: "3"}, + {ID: Issues, Label: "Issues", Key: "4"}, + {ID: Dashboard, Label: "Repos", Key: "5"}, + {ID: Actions, Label: "Actions", Key: "6"}, + {ID: Notes, Label: "Notes", Key: "7"}, +} + +// Short returns the compact tab label for narrow terminals ("5R"). +func (t Tab) Short() string { + for _, r := range t.Label { + return t.Key + string(r) + } + return t.Key +} + +// Full returns the standard tab label ("5 Repos"). +func (t Tab) Full() string { + return t.Key + " " + t.Label +} + +// ByKey returns the tab for a hotkey, or nil. +func ByKey(key string) *Tab { + for i := range Tabs { + if Tabs[i].Key == key { + return &Tabs[i] + } + } + return nil +} + +// Known reports whether id is a fullscreen screen (not Panels). +func Known(id ScreenID) bool { + for _, t := range Tabs { + if t.ID == id { + return true + } + } + return false +} diff --git a/pkg/tui/shell/shell_test.go b/pkg/tui/shell/shell_test.go new file mode 100644 index 0000000..757c1ff --- /dev/null +++ b/pkg/tui/shell/shell_test.go @@ -0,0 +1,68 @@ +package shell + +import "testing" + +func TestRouteTable(t *testing.T) { + if len(Tabs) != 5 { + t.Fatalf("tabs = %d, want 5", len(Tabs)) + } + seenKeys := map[string]bool{} + seenIDs := map[ScreenID]bool{} + for _, tab := range Tabs { + if tab.Key == "" || tab.Label == "" { + t.Errorf("tab %+v needs key and label", tab) + } + if seenKeys[tab.Key] { + t.Errorf("duplicate key %q", tab.Key) + } + seenKeys[tab.Key] = true + if seenIDs[tab.ID] { + t.Errorf("duplicate id %d", tab.ID) + } + seenIDs[tab.ID] = true + if !Known(tab.ID) { + t.Errorf("Known(%d) = false", tab.ID) + } + if tab.Full() != tab.Key+" "+tab.Label { + t.Errorf("Full() = %q", tab.Full()) + } + } + if ByKey("5") == nil || ByKey("5").ID != Dashboard { + t.Errorf("ByKey(5) should be Dashboard") + } + if ByKey("9") != nil { + t.Errorf("ByKey(9) should be nil") + } + if Known(Panels) { + t.Errorf("Panels is not a fullscreen tab") + } +} + +func TestComputeLayout(t *testing.T) { + l := Compute(120, 30, false) + if l.ContentHeight != 28 { + t.Errorf("content = %d, want 28", l.ContentHeight) + } + if l.LeftWidth+l.RightWidth != 120 { + t.Errorf("widths %d+%d != 120", l.LeftWidth, l.RightWidth) + } + if l.LeftWidth != 48 || l.RightWidth != 72 { + t.Errorf("split = %d/%d, want 48/72", l.LeftWidth, l.RightWidth) + } + if l.BP != Standard { + t.Errorf("bp = %d, want Standard", l.BP) + } + + l = Compute(120, 30, true) + if l.SidebarWidth != 42 || l.LeftWidth+l.RightWidth != 78 { + t.Errorf("sidebar split wrong: %+v", l) + } + if BreakpointFor(80) != Compact || BreakpointFor(200) != Wide { + t.Errorf("breakpoints wrong") + } + // Tiny terminal never goes negative. + l = Compute(40, 10, false) + if l.LeftWidth < 4 || l.RightWidth < 4 || l.ContentHeight < 4 { + t.Errorf("floors violated: %+v", l) + } +}