Compare commits
1 Commits
ee25552a53
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 024dc095a5 |
@@ -131,7 +131,7 @@ accent: "#fabd2f"
|
||||
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
|
||||
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 |
|
||||
|---|---|
|
||||
@@ -144,6 +144,7 @@ Without `extends`, every token must be set. The twelve are:
|
||||
| `resolved` | resolved alerts and incidents, the top-alerts chart |
|
||||
| `error` | error banners |
|
||||
| `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
|
||||
would rather follow your terminal's own colours. Colours are downsampled
|
||||
|
||||
+30
-19
@@ -1,30 +1,36 @@
|
||||
package theme
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// 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
|
||||
// its own background.
|
||||
const (
|
||||
darkBg0 = "#282828"
|
||||
darkFg1 = "#ebdbb2"
|
||||
darkGray = "#928374"
|
||||
darkRed = "#fb4934"
|
||||
darkGrn = "#b8bb26"
|
||||
darkYel = "#fabd2f"
|
||||
darkBlu = "#83a598"
|
||||
darkAqua = "#8ec07c"
|
||||
darkOrng = "#fe8019"
|
||||
darkBg0 = "#282828"
|
||||
darkFg1 = "#ebdbb2"
|
||||
darkGray = "#928374"
|
||||
darkRed = "#fb4934"
|
||||
darkGrn = "#b8bb26"
|
||||
darkYel = "#fabd2f"
|
||||
darkBlu = "#83a598"
|
||||
darkAqua = "#8ec07c"
|
||||
darkOrng = "#fe8019"
|
||||
darkPurple = "#d3869b"
|
||||
|
||||
lightBg0 = "#fbf1c7"
|
||||
lightFg1 = "#3c3836"
|
||||
lightFg4 = "#7c6f64"
|
||||
lightRed = "#9d0006"
|
||||
lightGrn = "#79740e"
|
||||
lightYel = "#b57614"
|
||||
lightBlu = "#076678"
|
||||
lightAqua = "#427b58"
|
||||
lightOrng = "#af3a03"
|
||||
lightBg0 = "#fbf1c7"
|
||||
lightFg1 = "#3c3836"
|
||||
lightFg4 = "#7c6f64"
|
||||
lightRed = "#9d0006"
|
||||
lightGrn = "#79740e"
|
||||
lightYel = "#b57614"
|
||||
lightBlu = "#076678"
|
||||
lightAqua = "#427b58"
|
||||
lightOrng = "#af3a03"
|
||||
lightPurple = "#8f3f71"
|
||||
)
|
||||
|
||||
// GruvboxDark is the default scheme. It assumes a dark terminal background:
|
||||
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
|
||||
SevError: darkOrng,
|
||||
SevWarning: darkYel,
|
||||
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.
|
||||
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
|
||||
SevError: lightOrng,
|
||||
SevWarning: lightYel,
|
||||
SevInfo: lightAqua,
|
||||
|
||||
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
|
||||
}
|
||||
|
||||
// Default is the theme used when the config names none.
|
||||
|
||||
@@ -32,6 +32,13 @@ type rawTheme struct {
|
||||
SevError *string `yaml:"sev_error"`
|
||||
SevWarning *string `yaml:"sev_warning"`
|
||||
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,
|
||||
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
|
||||
{"sev_error", r.SevError, &t.SevError},
|
||||
{"sev_warning", r.SevWarning, &t.SevWarning},
|
||||
{"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_warning: "#00000b"
|
||||
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")
|
||||
@@ -115,6 +121,9 @@ sev_info: "#00000c"
|
||||
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
|
||||
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) {
|
||||
|
||||
@@ -29,4 +29,8 @@ type Theme struct {
|
||||
SevError lipgloss.Color
|
||||
SevWarning 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
|
||||
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