Show a colour-coded team picker instead of cycling with T
T used to silently cycle Model.activeTeamID through the caller's teams with no visible list of choices. It now opens a full picker (modelled on the existing user picker) listing every team plus "All teams", each with a stable identity colour from a new six-colour theme palette. The same colour now also shows as a bullet next to "team: <name>" in the header, so the active team stays visible without opening the picker. Adds an Identity palette to the theme package (six hues, skipping the ones that already mean firing/critical), Styles.TeamColor to pick one by team id, and identity_1..6 as theme-file tokens alongside the existing twelve so a fully custom theme can still set every token.
This commit is contained in:
+39
-5
@@ -46,6 +46,7 @@ const (
|
||||
modeSnooze
|
||||
modeConfirm
|
||||
modeUserPicker
|
||||
modeTeamPicker
|
||||
modeUserCreate
|
||||
modeUserNotifyEdit
|
||||
modeAPIKeyMenu
|
||||
@@ -253,11 +254,12 @@ type Model struct {
|
||||
// Teams. The server scopes everything to the caller's teams; activeTeamID
|
||||
// narrows the incident and alert lists to one of them, 0 meaning all. The
|
||||
// schedule is per team and always needs a concrete one, see scheduleTeam.
|
||||
teams []api.Team
|
||||
activeTeamID int64
|
||||
defaultTeam string // config's `team`, resolved on connect
|
||||
meID int64
|
||||
isAdmin bool
|
||||
teams []api.Team
|
||||
activeTeamID int64
|
||||
defaultTeam string // config's `team`, resolved on connect
|
||||
meID int64
|
||||
isAdmin bool
|
||||
teamPickerTable table.Model
|
||||
|
||||
// Sign-in. Until the server accepts a session the TUI is in modeLogin;
|
||||
// loginNote is a line the form shows above the fields (why we are here), and
|
||||
@@ -396,6 +398,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||
pickerT.SetStyles(ts)
|
||||
|
||||
teamPickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||
teamPickerT.SetStyles(ts)
|
||||
|
||||
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
|
||||
manageT.SetStyles(ts)
|
||||
|
||||
@@ -499,6 +504,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
scheduleWindow: window,
|
||||
scheduleTable: schedT,
|
||||
userPickerTable: pickerT,
|
||||
teamPickerTable: teamPickerT,
|
||||
userManageTable: manageT,
|
||||
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
||||
ntfyTopicInput: topicIn,
|
||||
@@ -644,6 +650,20 @@ func (m *Model) rebuildUserPickerTable() {
|
||||
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
// rebuildTeamPickerTable lists "All teams" first, then each of the caller's
|
||||
// teams in the same order switchTeam / selectTeam use, so a row's position
|
||||
// always matches its place in m.teams.
|
||||
func (m *Model) rebuildTeamPickerTable() {
|
||||
m.teamPickerTable.SetColumns(teamPickerColumns(m.width))
|
||||
rows := make([]table.Row, 0, len(m.teams)+1)
|
||||
rows = append(rows, table.Row{m.styles.Muted.Render("●"), "All teams", ""})
|
||||
for _, t := range m.teams {
|
||||
rows = append(rows, table.Row{m.styles.TeamColor(t.ID).Render("●"), t.Name, t.Role})
|
||||
}
|
||||
setRows(&m.teamPickerTable, rows)
|
||||
m.teamPickerTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildUserManageTable() {
|
||||
m.userManageTable.SetColumns(userManageColumns(m.width))
|
||||
rows := make([]table.Row, len(m.users))
|
||||
@@ -903,6 +923,20 @@ func userPickerColumns(width int) []table.Column {
|
||||
}
|
||||
}
|
||||
|
||||
func teamPickerColumns(width int) []table.Column {
|
||||
dotW := 3
|
||||
roleW := 10
|
||||
nameW := width - dotW - roleW - 8
|
||||
if nameW < 15 {
|
||||
nameW = 15
|
||||
}
|
||||
return []table.Column{
|
||||
{Title: "", Width: dotW},
|
||||
{Title: "Team", Width: nameW},
|
||||
{Title: "Role", Width: roleW},
|
||||
}
|
||||
}
|
||||
|
||||
func userManageColumns(width int) []table.Column {
|
||||
createdW := 12
|
||||
usernameW := 25
|
||||
|
||||
@@ -125,6 +125,17 @@ func (s Styles) IncidentStatus(status string) lipgloss.Style {
|
||||
}
|
||||
}
|
||||
|
||||
// TeamColor picks a stable identity colour for a team id, so the same team
|
||||
// always reads the same colour without the server needing to store one.
|
||||
func (s Styles) TeamColor(teamID int64) lipgloss.Style {
|
||||
n := int64(len(s.theme.Identity))
|
||||
i := teamID % n
|
||||
if i < 0 { // ids are never negative in practice, but % can still return one
|
||||
i += n
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(s.theme.Identity[i])
|
||||
}
|
||||
|
||||
// ── Embedded bubbles components ────────────────────────────────────────────
|
||||
//
|
||||
// Each ships its own hardcoded palette, so a theme that stopped at this
|
||||
|
||||
@@ -66,6 +66,36 @@ func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
|
||||
t.Errorf("%s: %s has no foreground", th.Name, what)
|
||||
}
|
||||
}
|
||||
for i := range th.Identity {
|
||||
if s.TeamColor(int64(i)).GetForeground() == (lipgloss.NoColor{}) {
|
||||
t.Errorf("%s: identity %d has no foreground", th.Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStyles_TeamColorIsStableAndDistinct checks the property the header badge
|
||||
// and the team picker both rely on: the same id always reads the same colour,
|
||||
// and ids that differ (up to the size of the palette) read as different
|
||||
// colours rather than all collapsing to one.
|
||||
func TestStyles_TeamColorIsStableAndDistinct(t *testing.T) {
|
||||
s := newStyles(theme.GruvboxDark)
|
||||
|
||||
seen := make(map[lipgloss.Color]bool)
|
||||
for id := int64(0); id < 6; id++ {
|
||||
seen[s.TeamColor(id).GetForeground().(lipgloss.Color)] = true
|
||||
}
|
||||
if len(seen) != 6 {
|
||||
t.Errorf("expected 6 distinct colours across 6 ids, got %d", len(seen))
|
||||
}
|
||||
|
||||
// ids repeat past the palette size, and a negative id (never sent by the
|
||||
// server, but cheap to guard) must not panic.
|
||||
if s.TeamColor(6).GetForeground() != s.TeamColor(0).GetForeground() {
|
||||
t.Error("the palette should wrap rather than index out of range")
|
||||
}
|
||||
if got := s.TeamColor(-1).GetForeground(); got == (lipgloss.NoColor{}) {
|
||||
t.Error("a negative id should still resolve to a colour, not panic or fall back to none")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-17
@@ -34,6 +34,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.rebuildArchivedTable()
|
||||
m.rebuildScheduleTable()
|
||||
m.rebuildUserPickerTable()
|
||||
m.rebuildTeamPickerTable()
|
||||
m.rebuildUserManageTable()
|
||||
m.detailViewport.Width = m.width
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
@@ -400,6 +401,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(tableCmd, ourCmd)
|
||||
|
||||
case modeTeamPicker:
|
||||
var tableCmd tea.Cmd
|
||||
m.teamPickerTable, tableCmd = m.teamPickerTable.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(tableCmd, ourCmd)
|
||||
|
||||
case modeUserCreate:
|
||||
var inputCmd tea.Cmd
|
||||
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
|
||||
@@ -496,6 +503,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m.handleConfirmKey(msg)
|
||||
case modeUserPicker:
|
||||
return m.handleUserPickerKey(msg)
|
||||
case modeTeamPicker:
|
||||
return m.handleTeamPickerKey(msg)
|
||||
case modeUserCreate:
|
||||
return m.handleUserCreateKey(msg)
|
||||
case modeUserNotifyEdit:
|
||||
@@ -566,7 +575,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if !m.connected || len(m.teams) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
return m.switchTeam()
|
||||
return m.openTeamPicker()
|
||||
|
||||
case "enter":
|
||||
switch m.activeSection {
|
||||
@@ -847,22 +856,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// switchTeam steps the active team through all teams, then each of the caller's
|
||||
// teams in turn, and reloads what depends on it. Sections that are not on screen
|
||||
// are emptied rather than fetched, so they load when next opened; the incident
|
||||
// queue is the exception because it is what the caller returns to.
|
||||
func (m Model) switchTeam() (Model, tea.Cmd) {
|
||||
next := int64(0)
|
||||
if m.activeTeamID == 0 {
|
||||
next = m.teams[0].ID
|
||||
} else {
|
||||
for i, t := range m.teams {
|
||||
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
|
||||
next = m.teams[i+1].ID
|
||||
}
|
||||
}
|
||||
}
|
||||
m.activeTeamID = next
|
||||
// openTeamPicker opens the full list of the caller's teams, plus "All teams",
|
||||
// for the "T" key to choose among rather than blindly cycling through them.
|
||||
// Team data is already loaded at connect time, so no fetch is needed.
|
||||
func (m Model) openTeamPicker() (Model, tea.Cmd) {
|
||||
m.mode = modeTeamPicker
|
||||
m.rebuildTeamPickerTable()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// selectTeam sets the active team to teamID (0 meaning all teams) and reloads
|
||||
// what depends on it. Sections that are not on screen are emptied rather than
|
||||
// fetched, so they load when next opened; the incident queue is the exception
|
||||
// because it is what the caller returns to.
|
||||
func (m Model) selectTeam(teamID int64) (Model, tea.Cmd) {
|
||||
m.activeTeamID = teamID
|
||||
|
||||
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
|
||||
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
|
||||
@@ -1253,6 +1261,34 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Team picker ───────────────────────────────────────────────────────────
|
||||
|
||||
// handleTeamPickerKey reads the cursor's team off the table built by
|
||||
// rebuildTeamPickerTable, where row 0 is always "All teams" and row i (i>=1)
|
||||
// is m.teams[i-1].
|
||||
func (m Model) handleTeamPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = modeDashboard
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
cursor := m.teamPickerTable.Cursor()
|
||||
id := int64(0)
|
||||
if cursor > 0 {
|
||||
i := cursor - 1
|
||||
if i < 0 || i >= len(m.teams) {
|
||||
return m, nil
|
||||
}
|
||||
id = m.teams[i].ID
|
||||
}
|
||||
m, cmd := m.selectTeam(id)
|
||||
m.mode = modeDashboard
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// scheduleConflicts reports which of dates are already held by somebody other
|
||||
// than newUserID, and the distinct names holding them.
|
||||
//
|
||||
|
||||
+59
-19
@@ -630,7 +630,7 @@ func TestAlertDetail_JumpToIncident(t *testing.T) {
|
||||
|
||||
// A refresh underneath a prompt would move the ground under the user.
|
||||
func TestRefreshTick_SkipsModalStates(t *testing.T) {
|
||||
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate}
|
||||
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeTeamPicker, modeUserCreate}
|
||||
for _, md := range modal {
|
||||
m := sized()
|
||||
m.mode = md
|
||||
@@ -782,32 +782,38 @@ func TestConnected_DefaultTeamFromConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
|
||||
func TestTeamPicker_Opens(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
var seen []int64
|
||||
for i := 0; i < 4; i++ {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = press(t, m, "T")
|
||||
if cmd == nil {
|
||||
t.Fatal("switching team should reload")
|
||||
}
|
||||
seen = append(seen, m.activeTeamID)
|
||||
m, cmd := press(t, m, "T")
|
||||
if m.mode != modeTeamPicker {
|
||||
t.Fatalf("T should open the team picker, got mode %v", m.mode)
|
||||
}
|
||||
want := []int64{1, 2, 0, 1}
|
||||
for i := range want {
|
||||
if seen[i] != want[i] {
|
||||
t.Fatalf("expected the cycle %v, got %v", want, seen)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("opening the picker should not itself trigger a reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
|
||||
// Row 0 of the picker table is always "All teams"; row i (i>=1) is
|
||||
// m.teams[i-1] — see rebuildTeamPickerTable.
|
||||
func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
|
||||
m.rebuildIncidentTable()
|
||||
|
||||
m, _ = press(t, m, "T")
|
||||
m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops
|
||||
m, cmd := press(t, m, "enter")
|
||||
if cmd == nil {
|
||||
t.Fatal("selecting a team should reload")
|
||||
}
|
||||
if m.mode != modeDashboard {
|
||||
t.Fatalf("enter should close the picker, got mode %v", m.mode)
|
||||
}
|
||||
if m.activeTeamID != 1 {
|
||||
t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID)
|
||||
}
|
||||
if len(m.incidents) != 0 {
|
||||
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
|
||||
}
|
||||
@@ -816,10 +822,44 @@ func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
|
||||
func TestTeamPicker_SelectAllTeams(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
m.activeTeamID = 1
|
||||
|
||||
m, _ = press(t, m, "T")
|
||||
m.teamPickerTable.SetCursor(0) // "All teams"
|
||||
m, cmd := press(t, m, "enter")
|
||||
if cmd == nil {
|
||||
t.Fatal("selecting all teams should reload")
|
||||
}
|
||||
if m.activeTeamID != 0 {
|
||||
t.Fatalf("expected all teams (0), got %d", m.activeTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
m.activeTeamID = 1
|
||||
|
||||
m, _ = press(t, m, "T")
|
||||
m, cmd := press(t, m, "esc")
|
||||
if cmd != nil {
|
||||
t.Error("cancelling should not reload")
|
||||
}
|
||||
if m.mode != modeDashboard {
|
||||
t.Fatalf("esc should close the picker, got mode %v", m.mode)
|
||||
}
|
||||
if m.activeTeamID != 1 {
|
||||
t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) {
|
||||
m, cmd := press(t, sized(), "T")
|
||||
if cmd != nil || m.activeTeamID != 0 {
|
||||
t.Errorf("without teams T has nothing to switch to")
|
||||
if cmd != nil || m.mode == modeTeamPicker {
|
||||
t.Errorf("without teams T has nothing to open")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -28,7 +28,11 @@ func (m Model) View() string {
|
||||
func (m Model) renderHeader() string {
|
||||
title := m.styles.Header.Render("terdut-tui")
|
||||
if len(m.teams) > 0 {
|
||||
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
|
||||
dot := m.styles.Muted.Render("●")
|
||||
if t, ok := m.activeTeam(); ok {
|
||||
dot = m.styles.TeamColor(t.ID).Render("●")
|
||||
}
|
||||
title += " " + dot + m.styles.Muted.Render(" team: "+m.activeTeamLabel())
|
||||
}
|
||||
right := m.styles.Muted.Render(m.serverURL)
|
||||
return spread(title, right, m.width)
|
||||
@@ -153,6 +157,8 @@ func (m Model) renderBody() string {
|
||||
}
|
||||
case modeUserPicker:
|
||||
return m.renderUserPicker()
|
||||
case modeTeamPicker:
|
||||
return m.renderTeamPicker()
|
||||
case modeUserCreate:
|
||||
return m.renderUserCreate()
|
||||
case modeUserNotifyEdit:
|
||||
@@ -210,6 +216,9 @@ func (m Model) renderFooter() string {
|
||||
}
|
||||
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
|
||||
|
||||
case modeTeamPicker:
|
||||
return withStatus(" j/k·navigate enter·select esc·cancel")
|
||||
|
||||
case modeUserCreate:
|
||||
return withStatus(" tab·next field enter·create esc·cancel")
|
||||
|
||||
@@ -496,6 +505,11 @@ func (m Model) renderUserPicker() string {
|
||||
return header + m.userPickerTable.View()
|
||||
}
|
||||
|
||||
func (m Model) renderTeamPicker() string {
|
||||
header := "\n " + m.styles.Bold.Render("Select a team:") + "\n\n"
|
||||
return header + m.teamPickerTable.View()
|
||||
}
|
||||
|
||||
// ── Detail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderDetail() string {
|
||||
|
||||
Reference in New Issue
Block a user