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:
@@ -131,7 +131,7 @@ accent: "#fabd2f"
|
|||||||
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
|
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
|
||||||
the default without renaming it.
|
the default without renaming it.
|
||||||
|
|
||||||
Without `extends`, every token must be set. The twelve are:
|
Without `extends`, every token must be set. The eighteen are:
|
||||||
|
|
||||||
| Token | Where it shows |
|
| Token | Where it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -144,6 +144,7 @@ Without `extends`, every token must be set. The twelve are:
|
|||||||
| `resolved` | resolved alerts and incidents, the top-alerts chart |
|
| `resolved` | resolved alerts and incidents, the top-alerts chart |
|
||||||
| `error` | error banners |
|
| `error` | error banners |
|
||||||
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
|
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
|
||||||
|
| `identity_1` .. `identity_6` | a team's colour in the header and the `T` picker — carries no meaning of its own, so pick six colours that just read as distinct from one another (and from `firing`/`sev_critical`, which already mean something) |
|
||||||
|
|
||||||
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
|
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
|
||||||
would rather follow your terminal's own colours. Colours are downsampled
|
would rather follow your terminal's own colours. Colours are downsampled
|
||||||
|
|||||||
+30
-19
@@ -1,30 +1,36 @@
|
|||||||
package theme
|
package theme
|
||||||
|
|
||||||
import "sort"
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
// The gruvbox palettes, in the author's original names. Dark uses the bright
|
// The gruvbox palettes, in the author's original names. Dark uses the bright
|
||||||
// variants and light the faded ones, which is what keeps each readable against
|
// variants and light the faded ones, which is what keeps each readable against
|
||||||
// its own background.
|
// its own background.
|
||||||
const (
|
const (
|
||||||
darkBg0 = "#282828"
|
darkBg0 = "#282828"
|
||||||
darkFg1 = "#ebdbb2"
|
darkFg1 = "#ebdbb2"
|
||||||
darkGray = "#928374"
|
darkGray = "#928374"
|
||||||
darkRed = "#fb4934"
|
darkRed = "#fb4934"
|
||||||
darkGrn = "#b8bb26"
|
darkGrn = "#b8bb26"
|
||||||
darkYel = "#fabd2f"
|
darkYel = "#fabd2f"
|
||||||
darkBlu = "#83a598"
|
darkBlu = "#83a598"
|
||||||
darkAqua = "#8ec07c"
|
darkAqua = "#8ec07c"
|
||||||
darkOrng = "#fe8019"
|
darkOrng = "#fe8019"
|
||||||
|
darkPurple = "#d3869b"
|
||||||
|
|
||||||
lightBg0 = "#fbf1c7"
|
lightBg0 = "#fbf1c7"
|
||||||
lightFg1 = "#3c3836"
|
lightFg1 = "#3c3836"
|
||||||
lightFg4 = "#7c6f64"
|
lightFg4 = "#7c6f64"
|
||||||
lightRed = "#9d0006"
|
lightRed = "#9d0006"
|
||||||
lightGrn = "#79740e"
|
lightGrn = "#79740e"
|
||||||
lightYel = "#b57614"
|
lightYel = "#b57614"
|
||||||
lightBlu = "#076678"
|
lightBlu = "#076678"
|
||||||
lightAqua = "#427b58"
|
lightAqua = "#427b58"
|
||||||
lightOrng = "#af3a03"
|
lightOrng = "#af3a03"
|
||||||
|
lightPurple = "#8f3f71"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GruvboxDark is the default scheme. It assumes a dark terminal background:
|
// GruvboxDark is the default scheme. It assumes a dark terminal background:
|
||||||
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
|
|||||||
SevError: darkOrng,
|
SevError: darkOrng,
|
||||||
SevWarning: darkYel,
|
SevWarning: darkYel,
|
||||||
SevInfo: darkAqua,
|
SevInfo: darkAqua,
|
||||||
|
|
||||||
|
// Red already means an alarm, so it is the one gruvbox hue left out here.
|
||||||
|
Identity: [6]lipgloss.Color{darkBlu, darkAqua, darkYel, darkGrn, darkOrng, darkPurple},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GruvboxLight is the same scheme against a light terminal background.
|
// GruvboxLight is the same scheme against a light terminal background.
|
||||||
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
|
|||||||
SevError: lightOrng,
|
SevError: lightOrng,
|
||||||
SevWarning: lightYel,
|
SevWarning: lightYel,
|
||||||
SevInfo: lightAqua,
|
SevInfo: lightAqua,
|
||||||
|
|
||||||
|
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default is the theme used when the config names none.
|
// Default is the theme used when the config names none.
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ type rawTheme struct {
|
|||||||
SevError *string `yaml:"sev_error"`
|
SevError *string `yaml:"sev_error"`
|
||||||
SevWarning *string `yaml:"sev_warning"`
|
SevWarning *string `yaml:"sev_warning"`
|
||||||
SevInfo *string `yaml:"sev_info"`
|
SevInfo *string `yaml:"sev_info"`
|
||||||
|
|
||||||
|
Identity1 *string `yaml:"identity_1"`
|
||||||
|
Identity2 *string `yaml:"identity_2"`
|
||||||
|
Identity3 *string `yaml:"identity_3"`
|
||||||
|
Identity4 *string `yaml:"identity_4"`
|
||||||
|
Identity5 *string `yaml:"identity_5"`
|
||||||
|
Identity6 *string `yaml:"identity_6"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// binding ties a YAML key to its raw value and the field it fills, so parsing,
|
// binding ties a YAML key to its raw value and the field it fills, so parsing,
|
||||||
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
|
|||||||
{"sev_error", r.SevError, &t.SevError},
|
{"sev_error", r.SevError, &t.SevError},
|
||||||
{"sev_warning", r.SevWarning, &t.SevWarning},
|
{"sev_warning", r.SevWarning, &t.SevWarning},
|
||||||
{"sev_info", r.SevInfo, &t.SevInfo},
|
{"sev_info", r.SevInfo, &t.SevInfo},
|
||||||
|
{"identity_1", r.Identity1, &t.Identity[0]},
|
||||||
|
{"identity_2", r.Identity2, &t.Identity[1]},
|
||||||
|
{"identity_3", r.Identity3, &t.Identity[2]},
|
||||||
|
{"identity_4", r.Identity4, &t.Identity[3]},
|
||||||
|
{"identity_5", r.Identity5, &t.Identity[4]},
|
||||||
|
{"identity_6", r.Identity6, &t.Identity[5]},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ sev_critical: "#000009"
|
|||||||
sev_error: "#00000a"
|
sev_error: "#00000a"
|
||||||
sev_warning: "#00000b"
|
sev_warning: "#00000b"
|
||||||
sev_info: "#00000c"
|
sev_info: "#00000c"
|
||||||
|
identity_1: "#00000d"
|
||||||
|
identity_2: "#00000e"
|
||||||
|
identity_3: "#00000f"
|
||||||
|
identity_4: "#000010"
|
||||||
|
identity_5: "#000011"
|
||||||
|
identity_6: "#000012"
|
||||||
`)
|
`)
|
||||||
|
|
||||||
got, err := loadFrom(dir, "full")
|
got, err := loadFrom(dir, "full")
|
||||||
@@ -115,6 +121,9 @@ sev_info: "#00000c"
|
|||||||
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
|
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
|
||||||
t.Errorf("tokens not applied: %+v", got)
|
t.Errorf("tokens not applied: %+v", got)
|
||||||
}
|
}
|
||||||
|
if got.Identity[0] != "#00000d" || got.Identity[5] != "#000012" {
|
||||||
|
t.Errorf("identity tokens not applied: %+v", got.Identity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFrom_Errors(t *testing.T) {
|
func TestLoadFrom_Errors(t *testing.T) {
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ type Theme struct {
|
|||||||
SevError lipgloss.Color
|
SevError lipgloss.Color
|
||||||
SevWarning lipgloss.Color
|
SevWarning lipgloss.Color
|
||||||
SevInfo lipgloss.Color
|
SevInfo lipgloss.Color
|
||||||
|
|
||||||
|
// Identity tells things like teams apart from one another — not a status
|
||||||
|
// or a severity, so none of the six carries a meaning of its own.
|
||||||
|
Identity [6]lipgloss.Color
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-5
@@ -46,6 +46,7 @@ const (
|
|||||||
modeSnooze
|
modeSnooze
|
||||||
modeConfirm
|
modeConfirm
|
||||||
modeUserPicker
|
modeUserPicker
|
||||||
|
modeTeamPicker
|
||||||
modeUserCreate
|
modeUserCreate
|
||||||
modeUserNotifyEdit
|
modeUserNotifyEdit
|
||||||
modeAPIKeyMenu
|
modeAPIKeyMenu
|
||||||
@@ -253,11 +254,12 @@ type Model struct {
|
|||||||
// Teams. The server scopes everything to the caller's teams; activeTeamID
|
// 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
|
// 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.
|
// schedule is per team and always needs a concrete one, see scheduleTeam.
|
||||||
teams []api.Team
|
teams []api.Team
|
||||||
activeTeamID int64
|
activeTeamID int64
|
||||||
defaultTeam string // config's `team`, resolved on connect
|
defaultTeam string // config's `team`, resolved on connect
|
||||||
meID int64
|
meID int64
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
|
teamPickerTable table.Model
|
||||||
|
|
||||||
// Sign-in. Until the server accepts a session the TUI is in modeLogin;
|
// 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
|
// 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 := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||||
pickerT.SetStyles(ts)
|
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 := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
|
||||||
manageT.SetStyles(ts)
|
manageT.SetStyles(ts)
|
||||||
|
|
||||||
@@ -499,6 +504,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
scheduleWindow: window,
|
scheduleWindow: window,
|
||||||
scheduleTable: schedT,
|
scheduleTable: schedT,
|
||||||
userPickerTable: pickerT,
|
userPickerTable: pickerT,
|
||||||
|
teamPickerTable: teamPickerT,
|
||||||
userManageTable: manageT,
|
userManageTable: manageT,
|
||||||
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
||||||
ntfyTopicInput: topicIn,
|
ntfyTopicInput: topicIn,
|
||||||
@@ -644,6 +650,20 @@ func (m *Model) rebuildUserPickerTable() {
|
|||||||
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
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() {
|
func (m *Model) rebuildUserManageTable() {
|
||||||
m.userManageTable.SetColumns(userManageColumns(m.width))
|
m.userManageTable.SetColumns(userManageColumns(m.width))
|
||||||
rows := make([]table.Row, len(m.users))
|
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 {
|
func userManageColumns(width int) []table.Column {
|
||||||
createdW := 12
|
createdW := 12
|
||||||
usernameW := 25
|
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 ────────────────────────────────────────────
|
// ── Embedded bubbles components ────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Each ships its own hardcoded palette, so a theme that stopped at this
|
// 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)
|
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.rebuildArchivedTable()
|
||||||
m.rebuildScheduleTable()
|
m.rebuildScheduleTable()
|
||||||
m.rebuildUserPickerTable()
|
m.rebuildUserPickerTable()
|
||||||
|
m.rebuildTeamPickerTable()
|
||||||
m.rebuildUserManageTable()
|
m.rebuildUserManageTable()
|
||||||
m.detailViewport.Width = m.width
|
m.detailViewport.Width = m.width
|
||||||
m.detailViewport.Height = m.detailViewportHeight()
|
m.detailViewport.Height = m.detailViewportHeight()
|
||||||
@@ -400,6 +401,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(tableCmd, ourCmd)
|
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:
|
case modeUserCreate:
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
|
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)
|
return m.handleConfirmKey(msg)
|
||||||
case modeUserPicker:
|
case modeUserPicker:
|
||||||
return m.handleUserPickerKey(msg)
|
return m.handleUserPickerKey(msg)
|
||||||
|
case modeTeamPicker:
|
||||||
|
return m.handleTeamPickerKey(msg)
|
||||||
case modeUserCreate:
|
case modeUserCreate:
|
||||||
return m.handleUserCreateKey(msg)
|
return m.handleUserCreateKey(msg)
|
||||||
case modeUserNotifyEdit:
|
case modeUserNotifyEdit:
|
||||||
@@ -566,7 +575,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
if !m.connected || len(m.teams) == 0 {
|
if !m.connected || len(m.teams) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.switchTeam()
|
return m.openTeamPicker()
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
@@ -847,22 +856,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchTeam steps the active team through all teams, then each of the caller's
|
// openTeamPicker opens the full list of the caller's teams, plus "All teams",
|
||||||
// teams in turn, and reloads what depends on it. Sections that are not on screen
|
// for the "T" key to choose among rather than blindly cycling through them.
|
||||||
// are emptied rather than fetched, so they load when next opened; the incident
|
// Team data is already loaded at connect time, so no fetch is needed.
|
||||||
// queue is the exception because it is what the caller returns to.
|
func (m Model) openTeamPicker() (Model, tea.Cmd) {
|
||||||
func (m Model) switchTeam() (Model, tea.Cmd) {
|
m.mode = modeTeamPicker
|
||||||
next := int64(0)
|
m.rebuildTeamPickerTable()
|
||||||
if m.activeTeamID == 0 {
|
return m, nil
|
||||||
next = m.teams[0].ID
|
}
|
||||||
} else {
|
|
||||||
for i, t := range m.teams {
|
// selectTeam sets the active team to teamID (0 meaning all teams) and reloads
|
||||||
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
|
// what depends on it. Sections that are not on screen are emptied rather than
|
||||||
next = m.teams[i+1].ID
|
// 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.activeTeamID = next
|
|
||||||
|
|
||||||
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
|
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
|
||||||
m.scheduleEntries, m.scheduleDays, m.currentOnCall = 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
|
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
|
// scheduleConflicts reports which of dates are already held by somebody other
|
||||||
// than newUserID, and the distinct names holding them.
|
// 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.
|
// A refresh underneath a prompt would move the ground under the user.
|
||||||
func TestRefreshTick_SkipsModalStates(t *testing.T) {
|
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 {
|
for _, md := range modal {
|
||||||
m := sized()
|
m := sized()
|
||||||
m.mode = md
|
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 := sized()
|
||||||
m.teams = twoTeams()
|
m.teams = twoTeams()
|
||||||
var seen []int64
|
m, cmd := press(t, m, "T")
|
||||||
for i := 0; i < 4; i++ {
|
if m.mode != modeTeamPicker {
|
||||||
var cmd tea.Cmd
|
t.Fatalf("T should open the team picker, got mode %v", m.mode)
|
||||||
m, cmd = press(t, m, "T")
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("switching team should reload")
|
|
||||||
}
|
|
||||||
seen = append(seen, m.activeTeamID)
|
|
||||||
}
|
}
|
||||||
want := []int64{1, 2, 0, 1}
|
if cmd != nil {
|
||||||
for i := range want {
|
t.Error("opening the picker should not itself trigger a reload")
|
||||||
if seen[i] != want[i] {
|
|
||||||
t.Fatalf("expected the cycle %v, got %v", want, seen)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := sized()
|
||||||
m.teams = twoTeams()
|
m.teams = twoTeams()
|
||||||
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
|
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
|
||||||
m.rebuildIncidentTable()
|
m.rebuildIncidentTable()
|
||||||
|
|
||||||
m, _ = press(t, m, "T")
|
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 {
|
if len(m.incidents) != 0 {
|
||||||
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
|
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")
|
m, cmd := press(t, sized(), "T")
|
||||||
if cmd != nil || m.activeTeamID != 0 {
|
if cmd != nil || m.mode == modeTeamPicker {
|
||||||
t.Errorf("without teams T has nothing to switch to")
|
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 {
|
func (m Model) renderHeader() string {
|
||||||
title := m.styles.Header.Render("terdut-tui")
|
title := m.styles.Header.Render("terdut-tui")
|
||||||
if len(m.teams) > 0 {
|
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)
|
right := m.styles.Muted.Render(m.serverURL)
|
||||||
return spread(title, right, m.width)
|
return spread(title, right, m.width)
|
||||||
@@ -153,6 +157,8 @@ func (m Model) renderBody() string {
|
|||||||
}
|
}
|
||||||
case modeUserPicker:
|
case modeUserPicker:
|
||||||
return m.renderUserPicker()
|
return m.renderUserPicker()
|
||||||
|
case modeTeamPicker:
|
||||||
|
return m.renderTeamPicker()
|
||||||
case modeUserCreate:
|
case modeUserCreate:
|
||||||
return m.renderUserCreate()
|
return m.renderUserCreate()
|
||||||
case modeUserNotifyEdit:
|
case modeUserNotifyEdit:
|
||||||
@@ -210,6 +216,9 @@ func (m Model) renderFooter() string {
|
|||||||
}
|
}
|
||||||
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
|
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:
|
case modeUserCreate:
|
||||||
return withStatus(" tab·next field enter·create esc·cancel")
|
return withStatus(" tab·next field enter·create esc·cancel")
|
||||||
|
|
||||||
@@ -496,6 +505,11 @@ func (m Model) renderUserPicker() string {
|
|||||||
return header + m.userPickerTable.View()
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Detail ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) renderDetail() string {
|
func (m Model) renderDetail() string {
|
||||||
|
|||||||
Reference in New Issue
Block a user