diff --git a/pkg/tui/keymap.go b/pkg/tui/keymap.go index 3e4f988..acc986f 100644 --- a/pkg/tui/keymap.go +++ b/pkg/tui/keymap.go @@ -7,14 +7,18 @@ func (k keymap) ShortHelp() []key.Binding { } func (k keymap) FullHelp() [][]key.Binding { - // TODO: Return a pop-over window here instead + // Column layout: + // Col 1: Navigation + Help + // Col 2: Primary incident actions + // Col 3: Settings & toggles, Quit at bottom + return [][]key.Binding{ - // Each slice here is a column in the help window - {k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}, - {k.Ack, k.Login, k.Open, k.Note}, - {k.UnAck, k.Silence}, - {k.Team, k.Refresh}, - {k.AutoRefresh, k.AutoAck, k.ToggleActionLog, k.Quit, k.Help}, + // Column 1: Help at top, navigation + {k.Help, k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}, + // Column 2: Primary incident actions + {k.Ack, k.Note, k.Login, k.Open, k.UnAck, k.Silence}, + // Column 3: Settings & toggles, Quit at bottom + {k.Team, k.Refresh, k.AutoRefresh, k.AutoAck, k.ToggleActionLog, k.Quit}, } } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index ef4c568..67565b2 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -169,25 +169,101 @@ func (m *model) clearSelectedIncident(reason interface{}) { log.Debug("clearSelectedIncident", "selectedIncident", m.selectedIncident, "cleared", true, "reason", reason) } -// getHighlightedIncident returns the incident object for the currently highlighted table row -// by looking it up in m.incidentList. Returns nil if no row is highlighted or incident not found. -func (m *model) getHighlightedIncident() *pagerduty.Incident { +// syncSelectedIncidentToHighlightedRow updates m.selectedIncident to match the currently +// highlighted table row. Sets to nil if no row is highlighted. Uses cached data if available, +// otherwise uses stub data from m.incidentList. +func (m *model) syncSelectedIncidentToHighlightedRow() { row := m.table.SelectedRow() if row == nil { - return nil + // Clear selection regardless of viewing state + // If user scrolls table out of bounds, selection should be nil + m.selectedIncident = nil + m.incidentDataLoaded = false + m.incidentNotesLoaded = false + m.incidentAlertsLoaded = false + log.Debug("syncSelectedIncidentToHighlightedRow", "no row highlighted", "cleared selection") + return } incidentID := row[1] // Column [1] is the incident ID - // Look up the incident in the incident list + // If already viewing this incident, don't change anything + if m.selectedIncident != nil && m.selectedIncident.ID == incidentID { + return + } + + // Find incident in list for i := range m.incidentList { if m.incidentList[i].ID == incidentID { - return &m.incidentList[i] + // Check if we have cached full data + if cached, exists := m.incidentCache[incidentID]; exists && cached.incident != nil { + // Check if cache is fresh relative to incident list data + listIncident := &m.incidentList[i] + if cached.incident.LastStatusChangeAt != listIncident.LastStatusChangeAt { + // Cache is stale relative to list - use list data but keep cached notes/alerts + log.Debug("syncSelectedIncidentToHighlightedRow", "cache stale, using updated list data", "incident", incidentID) + incidentCopy := m.incidentList[i] + m.selectedIncident = &incidentCopy + m.incidentDataLoaded = false + // Keep cached notes/alerts if available + if cached.notesLoaded { + m.selectedIncidentNotes = cached.notes + m.incidentNotesLoaded = true + } else { + m.selectedIncidentNotes = nil + m.incidentNotesLoaded = false + } + if cached.alertsLoaded { + m.selectedIncidentAlerts = cached.alerts + m.incidentAlertsLoaded = true + } else { + m.selectedIncidentAlerts = nil + m.incidentAlertsLoaded = false + } + } else { + // Cache is fresh - use it + log.Debug("syncSelectedIncidentToHighlightedRow", "using cached data", "incident", incidentID) + // Create copy to avoid pointer issues + incidentCopy := *cached.incident + m.selectedIncident = &incidentCopy + m.incidentDataLoaded = cached.dataLoaded + m.incidentNotesLoaded = cached.notesLoaded + m.incidentAlertsLoaded = cached.alertsLoaded + if cached.notes != nil { + m.selectedIncidentNotes = cached.notes + } else { + m.selectedIncidentNotes = nil + } + if cached.alerts != nil { + m.selectedIncidentAlerts = cached.alerts + } else { + m.selectedIncidentAlerts = nil + } + } + } else { + // Use stub data from incidentList + log.Debug("syncSelectedIncidentToHighlightedRow", "using stub data", "incident", incidentID) + // Create a copy to avoid pointer aliasing when slice is reallocated + incidentCopy := m.incidentList[i] + m.selectedIncident = &incidentCopy + m.incidentDataLoaded = false + m.incidentNotesLoaded = false + m.incidentAlertsLoaded = false + m.selectedIncidentNotes = nil + m.selectedIncidentAlerts = nil + } + return } } - log.Debug("getHighlightedIncident", "incident not found in list", incidentID) - return nil + // Incident not found in list + log.Debug("syncSelectedIncidentToHighlightedRow", "incident not found in list", incidentID) + if !m.viewingIncident { + m.selectedIncident = nil + m.incidentDataLoaded = false + m.incidentNotesLoaded = false + m.incidentAlertsLoaded = false + } } func (m *model) setStatus(msg string) { @@ -201,6 +277,7 @@ func (m *model) toggleHelp() { // addActionLogEntry adds an action to the action log, maintaining only the last 5 entries func (m *model) addActionLogEntry(key, id, summary, action string) { + log.Debug("addActionLogEntry", "key", key, "id", id, "summary", summary, "action", action) entry := actionLogEntry{ key: key, id: id, diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 6d4ea7b..fa6fa04 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -688,7 +688,7 @@ func TestResolvedIncidentsAddedToActionLog(t *testing.T) { {Title: "", Width: 2}, {Title: "", Width: 15}, {Title: "", Width: 30}, - {Title: "", Width: 20}, + {Title: "", Width: 29}, }) m.config = &pd.Config{ CurrentUser: &pagerduty.User{ @@ -698,8 +698,18 @@ func TestResolvedIncidentsAddedToActionLog(t *testing.T) { // Set initial incident list m.incidentList = []pagerduty.Incident{ - {APIObject: pagerduty.APIObject{ID: "Q123"}, Title: "Incident 1", LastStatusChangeAt: time.Now().Format(time.RFC3339)}, - {APIObject: pagerduty.APIObject{ID: "Q456"}, Title: "Incident 2", LastStatusChangeAt: time.Now().Format(time.RFC3339)}, + { + APIObject: pagerduty.APIObject{ID: "Q123"}, + Title: "Incident 1", + Service: pagerduty.APIObject{Summary: "test-service-1"}, + LastStatusChangeAt: time.Now().Format(time.RFC3339), + }, + { + APIObject: pagerduty.APIObject{ID: "Q456"}, + Title: "Incident 2", + Service: pagerduty.APIObject{Summary: "test-service-2"}, + LastStatusChangeAt: time.Now().Format(time.RFC3339), + }, } // Update with a list that's missing Q123 (it resolved) @@ -718,7 +728,7 @@ func TestResolvedIncidentsAddedToActionLog(t *testing.T) { assert.Equal(t, "%R", m.actionLog[0].key, "Resolved incident should have %R key") assert.Equal(t, "Q123", m.actionLog[0].id, "Should log the resolved incident ID") assert.Equal(t, "Incident 1", m.actionLog[0].summary, "Should log the incident title") - assert.Equal(t, "resolved", m.actionLog[0].action, "Action should be 'resolved'") + assert.Equal(t, "test-service-1", m.actionLog[0].action, "Action should contain service summary") }) t.Run("Does not add duplicate resolved incidents to action log", func(t *testing.T) { @@ -729,7 +739,7 @@ func TestResolvedIncidentsAddedToActionLog(t *testing.T) { {Title: "", Width: 2}, {Title: "", Width: 15}, {Title: "", Width: 30}, - {Title: "", Width: 20}, + {Title: "", Width: 29}, }) m.config = &pd.Config{ CurrentUser: &pagerduty.User{ @@ -744,7 +754,12 @@ func TestResolvedIncidentsAddedToActionLog(t *testing.T) { // Set initial incident list m.incidentList = []pagerduty.Incident{ - {APIObject: pagerduty.APIObject{ID: "Q123"}, Title: "Incident 1", LastStatusChangeAt: time.Now().Format(time.RFC3339)}, + { + APIObject: pagerduty.APIObject{ID: "Q123"}, + Title: "Incident 1", + Service: pagerduty.APIObject{Summary: "test-service-1"}, + LastStatusChangeAt: time.Now().Format(time.RFC3339), + }, } // Update with empty list (Q123 resolved again) @@ -783,3 +798,121 @@ func TestToggleActionLog(t *testing.T) { assert.False(t, m.showActionLog, "showActionLog should be false after second toggle") }) } + +// TestEscapeKeySyncsToHighlightedRow verifies that pressing Escape re-syncs +// the selectedIncident to whatever row is currently highlighted +func TestEscapeKeySyncsToHighlightedRow(t *testing.T) { + m := createTestModel() + m.config = &pd.Config{} + + // Create incident list + m.incidentList = []pagerduty.Incident{ + { + APIObject: pagerduty.APIObject{ID: "Q123"}, + Title: "Incident 1", + Service: pagerduty.APIObject{Summary: "service-1"}, + LastStatusChangeAt: time.Now().Format(time.RFC3339), + }, + { + APIObject: pagerduty.APIObject{ID: "Q456"}, + Title: "Incident 2", + Service: pagerduty.APIObject{Summary: "service-2"}, + LastStatusChangeAt: time.Now().Format(time.RFC3339), + }, + } + + // Create table with the incidents + cols := []table.Column{ + {Title: "Status", Width: 10}, + {Title: "ID", Width: 10}, + {Title: "Title", Width: 20}, + {Title: "Service", Width: 15}, + {Title: "Since", Width: 10}, + {Title: "User", Width: 10}, + } + rows := []table.Row{ + {"triggered", "Q123", "Incident 1", "service-1", "2024-01-01", "user1"}, + {"triggered", "Q456", "Incident 2", "service-2", "2024-01-01", "user2"}, + } + m.table = table.New( + table.WithColumns(cols), + table.WithRows(rows), + table.WithFocused(true), + ) + + // Select first incident and view it + m.selectedIncident = &m.incidentList[0] + m.viewingIncident = true + + // Simulate Escape key - should clear and re-sync to highlighted row + // Note: In real usage, the table cursor position would determine which incident gets selected + // For this test, we're verifying the sync happens after Escape + m.clearSelectedIncident("test escape") + m.syncSelectedIncidentToHighlightedRow() + + // After sync, selectedIncident should match the highlighted row + // Since we can't easily control table selection in unit tests, we verify the sync was attempted + assert.NotNil(t, m.selectedIncident, "selectedIncident should be re-synced after Escape") +} + +// TestSelectedIncidentSurvivesListUpdate verifies that copying incident data +// prevents pointer aliasing issues when the incident list is reallocated +func TestSelectedIncidentSurvivesListUpdate(t *testing.T) { + m := createTestModel() + + // Create initial incident list + m.incidentList = []pagerduty.Incident{ + { + APIObject: pagerduty.APIObject{ID: "Q123"}, + Title: "Original Title", + Service: pagerduty.APIObject{Summary: "service-1"}, + LastStatusChangeAt: "2024-01-01T00:00:00Z", + }, + } + + // Create table with the incident + cols := []table.Column{ + {Title: "Status", Width: 10}, + {Title: "ID", Width: 10}, + {Title: "Title", Width: 20}, + {Title: "Service", Width: 15}, + {Title: "Since", Width: 10}, + {Title: "User", Width: 10}, + } + rows := []table.Row{ + {"triggered", "Q123", "Original Title", "service-1", "2024-01-01", "user1"}, + } + m.table = table.New( + table.WithColumns(cols), + table.WithRows(rows), + table.WithFocused(true), + ) + + // Sync to select the incident (creates a copy) + m.syncSelectedIncidentToHighlightedRow() + + assert.NotNil(t, m.selectedIncident, "selectedIncident should be set") + originalTitle := m.selectedIncident.Title + assert.Equal(t, "Original Title", originalTitle) + + // Update the incident list (reallocate the slice) + m.incidentList = []pagerduty.Incident{ + { + APIObject: pagerduty.APIObject{ID: "Q123"}, + Title: "Updated Title", + Service: pagerduty.APIObject{Summary: "service-1"}, + LastStatusChangeAt: "2024-01-01T00:00:00Z", + }, + { + APIObject: pagerduty.APIObject{ID: "Q456"}, + Title: "New Incident", + Service: pagerduty.APIObject{Summary: "service-2"}, + LastStatusChangeAt: "2024-01-01T00:00:00Z", + }, + } + + // Verify selectedIncident still has the original title (not affected by list update) + // This proves we copied the data instead of storing a pointer to the slice element + assert.Equal(t, "Original Title", m.selectedIncident.Title, + "selectedIncident should retain original data after list reallocation") +} diff --git a/pkg/tui/msgHandlers.go b/pkg/tui/msgHandlers.go index 632cc6d..7b0e764 100644 --- a/pkg/tui/msgHandlers.go +++ b/pkg/tui/msgHandlers.go @@ -73,14 +73,18 @@ func (m model) windowSizeMsgHandler(msg tea.Msg) (tea.Model, tea.Cmd) { tableWidth := windowSize.Width - horizontalScratchWidth - tableHorizontalScratchWidth // Reserve lines for input field (1 line) and action log when visible (11 lines for 5-row table + spacing) - // Additional 10 lines for general spacing + // Additional spacing for help (needs ~12 lines when expanded with 10-item columns) inputReservedLines := 1 - additionalSpacing := 10 + additionalSpacing := 15 actionLogReservedLines := 0 if m.showActionLog { actionLogReservedLines = 11 } tableHeight := windowSize.Height - verticalScratchWidth - tableVerticalScratchWidth - rowCount - estimatedExtraLinesFromComponents - actionLogReservedLines - inputReservedLines - additionalSpacing + // Ensure table height is never negative (can happen with very small terminal windows) + if tableHeight < 1 { + tableHeight = 1 + } m.table.SetHeight(tableHeight) @@ -112,16 +116,18 @@ func (m model) windowSizeMsgHandler(msg tea.Msg) (tea.Model, tea.Cmd) { {Title: "Service", Width: columnWidth}, }) - // Action log table columns: mirror main table layout exactly - // Same 4 columns as main table, but no headers and keypress in first column instead of dot - // Keypress needs 2 chars (for "^e" etc), so take 1 char from the action column - keyPressWidth := 2 + // Action log table columns: total width must match main table + // First column needs width 2 to accommodate 2-char sequences like "^e", "%R" + // We compensate by taking 1 from the last column + // Total: 2 + 15 + columnWidth + (columnWidth-1) = 16 + 2*columnWidth = same as main table m.actionLogTable.SetColumns([]table.Column{ - {Title: "", Width: keyPressWidth}, // Keypress column (2 chars for "^e" etc) - {Title: "", Width: idWidth - dotWidth}, // ID column (same as main table) - {Title: "", Width: columnWidth}, // Summary column (same as main table) - {Title: "", Width: columnWidth - 1}, // Action column (1 char less to compensate for wider keypress) + {Title: " " + dot, Width: 2}, // Keypress column - space + dot like main table + {Title: "ID", Width: idWidth - dotWidth}, // ID column (same as main table) + {Title: "Summary", Width: columnWidth}, // Summary column (same as main table) + {Title: "Service", Width: columnWidth - 1}, // Action column (1 less to compensate) }) + // Set explicit width to force table to respect column widths and not auto-size + m.actionLogTable.SetWidth(tableWidth) m.incidentViewer.Width = windowSize.Width - horizontalScratchWidth - incidentHorizontalScratchWidth // Account for header (2 lines), footer (1 line), help (~2 lines), bottom status (1 line), and spacing @@ -203,15 +209,19 @@ func switchTableFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, defaultKeyMap.Up): m.table.MoveUp(1) + m.syncSelectedIncidentToHighlightedRow() case key.Matches(msg, defaultKeyMap.Down): m.table.MoveDown(1) + m.syncSelectedIncidentToHighlightedRow() case key.Matches(msg, defaultKeyMap.Top): m.table.GotoTop() + m.syncSelectedIncidentToHighlightedRow() case key.Matches(msg, defaultKeyMap.Bottom): m.table.GotoBottom() + m.syncSelectedIncidentToHighlightedRow() case key.Matches(msg, defaultKeyMap.Team): m.teamMode = !m.teamMode @@ -302,16 +312,11 @@ func switchTableFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return unAcknowledgeIncidentsMsg{} } case key.Matches(msg, defaultKeyMap.Note): - if m.table.SelectedRow() == nil { - m.setStatus("no incident highlighted") - return m, nil - } - incident := m.getHighlightedIncident() - if incident == nil { - m.setStatus("failed to find incident") + if m.selectedIncident == nil { + m.setStatus("no incident selected") return m, nil } - return m, parseTemplateForNote(incident) + return m, parseTemplateForNote(m.selectedIncident) case key.Matches(msg, defaultKeyMap.Login): return m, doIfIncidentSelected(&m, func() tea.Msg { @@ -319,20 +324,24 @@ func switchTableFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { }) case key.Matches(msg, defaultKeyMap.Open): - if m.table.SelectedRow() == nil { - m.setStatus("no incident highlighted") + if m.selectedIncident == nil { + m.setStatus("no incident selected") return m, nil } - incident := m.getHighlightedIncident() - if incident == nil { - m.setStatus("failed to find incident") + // HTMLURL should be in stub data from incident list, but check to be safe + if m.selectedIncident.HTMLURL == "" { + m.setStatus("incident URL not available") return m, nil } if defaultBrowserOpenCommand == "" { return m, func() tea.Msg { return errMsg{fmt.Errorf("unsupported OS: no browser open command available")} } } + + // TEMPORARY: Log open action to action log for testing + m.addActionLogEntry("o", m.selectedIncident.ID, m.selectedIncident.Title, m.selectedIncident.Service.Summary) + c := []string{defaultBrowserOpenCommand} - return m, openBrowserCmd(c, incident.HTMLURL) + return m, openBrowserCmd(c, m.selectedIncident.HTMLURL) } } @@ -388,6 +397,7 @@ func switchIncidentFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, defaultKeyMap.Back): m.clearSelectedIncident(msg.String() + " (back)") m.table.Focus() // Ensure table regains focus immediately + m.syncSelectedIncidentToHighlightedRow() // Re-establish selection to current cursor position // Return immediately - no need to process anything else or update viewport return m, nil diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index e9a800e..03eb2fe 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -243,7 +243,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Only update selected incident if no incident is selected or this matches the selected one // Skip if we're viewing a different incident (don't let background pre-fetch overwrite it) - if m.selectedIncident == nil || msg.incident.ID == m.selectedIncident.ID { + // Check if this message is still relevant to the current selection + // Prevents late-arriving messages from overwriting when user navigated away + shouldUpdate := false + if m.selectedIncident == nil { + shouldUpdate = true + } else if m.selectedIncident.ID == msg.incident.ID { + shouldUpdate = true + } + + if shouldUpdate { m.setStatus(fmt.Sprintf("got incident %s", msg.incident.ID)) m.selectedIncident = msg.incident m.incidentDataLoaded = true @@ -422,7 +431,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { incidentIDs[i.ID] = true } for id := range m.incidentCache { - if !incidentIDs[id] { + if incidentIDs[id] { + // Incident still exists - mark cache as potentially stale + // Find incident in new list and check if LastStatusChangeAt differs + for _, newIncident := range m.incidentList { + if newIncident.ID == id { + if cached, exists := m.incidentCache[id]; exists { + // Compare timestamps to detect changes + if cached.incident != nil && + cached.incident.LastStatusChangeAt != newIncident.LastStatusChangeAt { + // Incident changed - invalidate cached details + log.Debug("Update", "updatedIncidentListMsg", "invalidating cache for updated incident", "id", id) + delete(m.incidentCache, id) + } + } + break + } + } + } else { + // Incident no longer in list - remove from cache delete(m.incidentCache, id) log.Debug("Update", "updatedIncidentListMsg", "removing cached data for incident no longer in list", "incident", id) } @@ -447,16 +474,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setStatus(fmt.Sprintf("showing %d/%d incidents...", len(m.table.Rows()), totalIncidentCount)) } - if m.selectedIncident != nil { - // Check if the m.selectedIncident is still in the list - idx := slices.IndexFunc(m.incidentList, func(incident pagerduty.Incident) bool { - return incident.ID == m.selectedIncident.ID - }) - - if idx == -1 { - m.clearSelectedIncident("selected incident no longer in list after update") - } - } + // Re-sync selectedIncident to match highlighted row + // This handles cases where the incident list changed but cursor position stayed same + m.syncSelectedIncidentToHighlightedRow() case parseTemplateForNoteMsg: if m.selectedIncident == nil { @@ -503,6 +523,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, func() tea.Msg { return getIncidentMsg(m.selectedIncident.ID) }) case loginMsg: + if m.selectedIncident == nil { + m.setStatus("unable to login - no selected incident") + return m, nil + } + var cluster string switch len(m.selectedIncidentAlerts) { @@ -541,6 +566,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if defaultBrowserOpenCommand == "" { return m, func() tea.Msg { return errMsg{fmt.Errorf("unsupported OS: no browser open command available")} } } + + // TEMPORARY: Log open action to action log for testing + log.Debug("openBrowserMsg", "incident", m.selectedIncident.ID, "title", m.selectedIncident.Title, "service", m.selectedIncident.Service.Summary) + m.addActionLogEntry("o", m.selectedIncident.ID, m.selectedIncident.Title, m.selectedIncident.Service.Summary) + c := []string{defaultBrowserOpenCommand} return m, openBrowserCmd(c, m.selectedIncident.HTMLURL) @@ -617,18 +647,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case acknowledgeIncidentsMsg: // If incidents are provided in the message, use those - // Otherwise, use the highlighted incident from the table, or the selected incident if viewing one + // Otherwise, use the selected incident (which is always synced to highlighted row) incidents := msg.incidents if incidents == nil { - incident := m.getHighlightedIncident() - if incident == nil { - incident = m.selectedIncident - } - if incident == nil { - m.setStatus("failed acknowledging incidents - no incident highlighted or selected") + if m.selectedIncident == nil { + m.setStatus("failed acknowledging incidents - no incident selected") return m, nil } - incidents = []pagerduty.Incident{*incident} + incidents = []pagerduty.Incident{*m.selectedIncident} } m.apiInProgress = true @@ -640,18 +666,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case unAcknowledgeIncidentsMsg: // If incidents are provided in the message, use those - // Otherwise, use the highlighted incident from the table, or the selected incident if viewing one + // Otherwise, use the selected incident (which is always synced to highlighted row) incidents := msg.incidents if incidents == nil { - incident := m.getHighlightedIncident() - if incident == nil { - incident = m.selectedIncident - } - if incident == nil { - m.setStatus("failed re-escalating incidents - no incident highlighted or selected") + if m.selectedIncident == nil { + m.setStatus("failed re-escalating incidents - no incident selected") return m, nil } - incidents = []pagerduty.Incident{*incident} + incidents = []pagerduty.Incident{*m.selectedIncident} } // Skip un-acknowledge step - go directly to re-escalation @@ -742,24 +764,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return updateIncidentListMsg("sender: reEscalatedIncidentsMsg") } case silenceSelectedIncidentMsg: - incident := m.getHighlightedIncident() - if incident == nil { - incident = m.selectedIncident - } - if incident == nil { - m.setStatus("failed silencing incident - no incident highlighted or selected") + if m.selectedIncident == nil { + m.setStatus("failed silencing incident - no incident selected") return m, nil } // Log silence action immediately - m.addActionLogEntry("^s", incident.ID, incident.Title, incident.Service.Summary) + m.addActionLogEntry("^s", m.selectedIncident.ID, m.selectedIncident.Title, m.selectedIncident.Service.Summary) - policyKey := getEscalationPolicyKey(incident.Service.ID, m.config.EscalationPolicies) + policyKey := getEscalationPolicyKey(m.selectedIncident.Service.ID, m.config.EscalationPolicies) m.apiInProgress = true return m, tea.Sequence( m.spinner.Tick, - silenceIncidents([]pagerduty.Incident{*incident}, m.config.EscalationPolicies[policyKey], silentDefaultPolicyLevel), + silenceIncidents([]pagerduty.Incident{*m.selectedIncident}, m.config.EscalationPolicies[policyKey], silentDefaultPolicyLevel), func() tea.Msg { return clearSelectedIncidentsMsg("sender: silenceSelectedIncidentMsg") }, ) diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 8a8e396..f4d1253 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -111,13 +111,16 @@ var ( Header: tableHeaderStyle, } - // Action log table styles - no borders, no header, no selection highlight + // Action log table styles - header with border like main table, no selection highlight actionLogTableStyle = table.Styles{ Cell: tableCellStyle, Selected: tableCellStyle, // Same as cell style = no highlight - Header: lipgloss.NewStyle(), // Empty style = no header + Header: tableHeaderStyle, // Same header style as main table (with border) } + // Action log container style - border like main table + actionLogContainerStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true) + incidentViewerStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true) errorStyle = lipgloss.NewStyle(). @@ -170,8 +173,8 @@ func (m model) View() string { // Only render action log if it's toggled on if m.showActionLog { s.WriteString("\n") - // Render action log table without borders, just with padding to maintain spacing - s.WriteString(paddedStyle.Render(m.actionLogTable.View())) + // Render action log table with border like main table + s.WriteString(actionLogContainerStyle.Render(m.actionLogTable.View())) } } @@ -264,13 +267,9 @@ func (m model) renderBottomStatus() string { var s strings.Builder var selectedID string - // Show highlighted incident from table, or selected incident if viewing one - incident := m.getHighlightedIncident() - if incident == nil { - incident = m.selectedIncident - } - if incident != nil { - selectedID = incident.ID + // Show selected incident (always synced to highlighted row) + if m.selectedIncident != nil { + selectedID = m.selectedIncident.ID } else { selectedID = "" }