diff --git a/.gitignore b/.gitignore index f1489bf..23c484b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /iontk .env *.txt +# go:embed cannot find a file git never took, and *.txt is blanket-ignored. +!internal/clock/zones.txt *.json *.md # The release job builds into dist/, and a local reproduction of it leaves diff --git a/cmd/iontk/flagdoc.go b/cmd/iontk/flagdoc.go index c577eb6..0299ba8 100644 --- a/cmd/iontk/flagdoc.go +++ b/cmd/iontk/flagdoc.go @@ -68,7 +68,7 @@ var flagGroups = []struct { {"Rich data", "The TUI's third element mode reads the portal instead of opening a session. Its panes are defined in YAML: `--views` points at a file instead of `views.yaml` in the config directory, which is also how a `--fixtures` run gets views of its own.", []string{ "views", }}, - {"Appearance", "Colour schemes are YAML too: `--themes` points at a file whose schemes are added to the built-in ones, or replace one of the same name, instead of `themes.yaml` in the config directory. The same escape hatch as `--views`, for the same reason.", []string{ + {"Appearance", "Color schemes are YAML too: `--themes` points at a file whose schemes are added to the built-in ones, or replace one of the same name, instead of `themes.yaml` in the config directory. The same escape hatch as `--views`, for the same reason.", []string{ "themes", }}, } diff --git a/cmd/iontk/root.go b/cmd/iontk/root.go index 5da84d9..c1c3313 100644 --- a/cmd/iontk/root.go +++ b/cmd/iontk/root.go @@ -123,7 +123,7 @@ func init() { pf.BoolVar(&app.Debug, "debug", false, "run against a throwaway config with no saved tenants and secrets held only in memory") pf.StringVar(&flagFixtures, "fixtures", "", "YAML file of fake tenants, elements and CLI responses to run against instead of real hardware") pf.StringVar(&flagViews, "views", "", "YAML file of rich-data views to use instead of views.yaml in the config directory") - pf.StringVar(&flagThemes, "themes", "", "YAML file of colour schemes to add to the built-in ones, instead of themes.yaml in the config directory") + pf.StringVar(&flagThemes, "themes", "", "YAML file of color schemes to add to the built-in ones, instead of themes.yaml in the config directory") pf.StringVar(&app.Defaults.TSGID, "tsg", "", "tenant service group ID (overrides SCM_TSG_ID)") pf.StringVar(&flagFormat, "format", "", `batch output format: "json", "jsonl", "text", "table" or "csv" (default from settings, else json)`) pf.StringVar(&flagOrient, "orientation", "", `row order for "table" and "csv": "ion" groups each element's commands, "command" puts one command across the fleet together (default from settings, else ion)`) diff --git a/internal/app/auth.go b/internal/app/auth.go index efec695..0b29c56 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -115,13 +115,18 @@ func ShortAuthError(err error) string { switch { case strings.Contains(text, "did not complete within"): return "sign-in timed out" + case errors.Is(err, saseauth.ErrNoCredential): + return "no sign-in for this tenant" case strings.Contains(text, "invalid_grant"), strings.Contains(text, "stored refresh token rejected"): return "sign-in expired" - case errors.Is(err, saseauth.ErrInteractiveRequired), - strings.Contains(text, "interaction_required"), + case errors.Is(err, saseauth.ErrNoSession): + return "sign-in needed" + case strings.Contains(text, "interaction_required"), strings.Contains(text, "login_required"): return "not accessible with this sign-in" + case errors.Is(err, saseauth.ErrInteractiveRequired): + return "sign-in needed" case IsAuthFailure(text): return "credential rejected" } diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go index fe4524a..41a58dc 100644 --- a/internal/app/auth_test.go +++ b/internal/app/auth_test.go @@ -1,8 +1,11 @@ package app import ( + "context" "errors" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -189,6 +192,24 @@ func TestAccountSeedIsSharedBetweenTenants(t *testing.T) { } } +func TestWarmingATenantSpendsTheCredentialsAlreadyHeld(t *testing.T) { + st := testStore(t) + if err := st.Secrets.Set(config.AccountRefreshTokenKey(), "account-rt"); err != nil { + t.Fatalf("seed account token: %v", err) + } + if err := st.Secrets.Set(config.RefreshTokenKey("1"), "tenant-rt"); err != nil { + t.Fatalf("seed tenant token: %v", err) + } + + src := tenantSource(st, nil, "1") + if got := src.RefreshToken(); got != "tenant-rt" { + t.Errorf("RefreshToken() = %q, want the stored token tried before any browser", got) + } + if src.Seed() == nil { + t.Error("Seed() = nil, want the account token offered too, or login opens a second window") + } +} + func TestATenantsOwnTokenComesFirstButTheSeedIsStillThere(t *testing.T) { st := testStore(t) if err := st.Secrets.Set(config.AccountRefreshTokenKey(), "account-rt"); err != nil { @@ -275,6 +296,72 @@ func TestFixturesSeedTenantsThatResolveWithoutASignIn(t *testing.T) { } } +func TestATenantWithNoSignInReadsTheSameWhateverTheAccountCredentialDid(t *testing.T) { + const ( + good = "1984021017" + bad = "1538107912" + ) + + tests := []struct { + name string + seed func(t *testing.T, tokenURL string, client *http.Client) *saseauth.Seed + }{ + { + name: "no account credential at all", + seed: func(*testing.T, string, *http.Client) *saseauth.Seed { return nil }, + }, + { + name: "another tenant narrowed it first", + seed: func(t *testing.T, tokenURL string, client *http.Client) *saseauth.Seed { + s := saseauth.NewSeed("acct") + opt := saseauth.Options{TokenURL: tokenURL, Client: client, Seed: s} + if _, _, err := saseauth.NewSource(good, "", opt, nil).Token(context.Background()); err != nil { + t.Fatalf("the first tenant could not narrow the account credential: %v", err) + } + return s + }, + }, + { + name: "it was refused outright", + seed: func(*testing.T, string, *http.Client) *saseauth.Seed { + return saseauth.NewSeed("dead") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + if r.PostFormValue("refresh_token") == "acct" { + w.Write([]byte(`{"access_token":"at","refresh_token":"narrowed","expires_in":899}`)) + return + } + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_grant"}`)) + })) + defer srv.Close() + + opt := saseauth.Options{ + TokenURL: srv.URL, + Client: srv.Client(), + Seed: tt.seed(t, srv.URL, srv.Client()), + } + _, _, err := saseauth.NewSource(bad, "", opt, nil).Token(context.Background()) + if err == nil { + t.Fatal("Token() succeeded for a tenant with no sign-in of its own") + } + if got := ShortAuthError(err); got != "no sign-in for this tenant" { + t.Errorf("ShortAuthError() = %q, want %q — the message must not report what the "+ + "shared account credential did as a verdict on this tenant", got, "no sign-in for this tenant") + } + if !errors.Is(err, saseauth.ErrInteractiveRequired) { + t.Errorf("Token() error = %v, want it to still wrap ErrInteractiveRequired so the TUI offers a sign-in", err) + } + }) + } +} + func TestShortAuthErrorSaysWhatToDoAboutIt(t *testing.T) { tests := []struct { name string @@ -290,9 +377,27 @@ func TestShortAuthErrorSaysWhatToDoAboutIt(t *testing.T) { want: "sign-in expired", }, { - name: "no access to the tenant", - err: fmt.Errorf("%w: no existing browser session: authorization failed: interaction_required", - saseauth.ErrInteractiveRequired), + name: "never signed in to this tenant", + err: fmt.Errorf("%w: %w; %w: authorization failed: interaction_required", + saseauth.ErrInteractiveRequired, saseauth.ErrNoCredential, saseauth.ErrNoSession), + want: "no sign-in for this tenant", + }, + { + name: "another tenant had already spent the account credential", + err: fmt.Errorf("%w: %w; account credential rejected: token: status 400 Bad Request: "+ + `{"error":"invalid_grant"}`+"; %w: authorization failed: interaction_required", + saseauth.ErrInteractiveRequired, saseauth.ErrNoCredential, saseauth.ErrNoSession), + want: "no sign-in for this tenant", + }, + { + name: "nothing silent is left to try", + err: fmt.Errorf("%w: %w: authorization failed: interaction_required", + saseauth.ErrInteractiveRequired, saseauth.ErrNoSession), + want: "sign-in needed", + }, + { + name: "the authorization server refused an interactive sign-in", + err: errors.New("authorization failed: login_required: no access to this tsg"), want: "not accessible with this sign-in", }, { diff --git a/internal/app/signin.go b/internal/app/signin.go index e93a499..fe7b215 100644 --- a/internal/app/signin.go +++ b/internal/app/signin.go @@ -35,27 +35,50 @@ func SignIn(ctx context.Context, st *config.Store, w io.Writer) (saseauth.Discov return d, nil } -func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, w io.Writer) error { - httpClient := &http.Client{Timeout: Defaults.HTTPTimeout} - var failed int +type TenantWarm struct { + Tenant config.Tenant + Skipped bool + Err error +} +// rotated as it is redeemed, so it narrows to one tenant +func WarmEach(ctx context.Context, st *config.Store, tenants []config.Tenant) []TenantWarm { + httpClient := &http.Client{Timeout: Defaults.HTTPTimeout} + out := make([]TenantWarm, 0, len(tenants)) for _, t := range tenants { if !t.UsesBrowser() { - fmt.Fprintf(w, " %-28s service account, nothing to sign in\n", t.Name) + out = append(out, TenantWarm{Tenant: t, Skipped: true}) + continue + } + _, _, err := tenantSource(st, httpClient, t.TSGID).Token(ctx) + out = append(out, TenantWarm{Tenant: t, Err: err}) + if ctx.Err() != nil { + return out + } + } + return out +} + +func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, w io.Writer) error { + var failed, browser int + + for _, r := range WarmEach(ctx, st, tenants) { + if r.Skipped { + fmt.Fprintf(w, " %-28s service account, nothing to sign in\n", r.Tenant.Name) continue } - src := saseauth.NewSource(t.TSGID, "", BrowserOptions(httpClient, st), - func(rt string) error { return st.Secrets.Set(config.RefreshTokenKey(t.TSGID), rt) }) - if _, _, err := src.Token(ctx); err != nil { + browser++ + if r.Err != nil { failed++ - fmt.Fprintf(w, " %-28s %s\n", t.Name, ShortAuthError(err)) + fmt.Fprintf(w, " %-28s %s\n", r.Tenant.Name, ShortAuthError(r.Err)) continue } - fmt.Fprintf(w, " %-28s ready\n", t.Name) + fmt.Fprintf(w, " %-28s ready\n", r.Tenant.Name) } - if failed > 0 && failed == len(tenants) { - return fmt.Errorf("no tenant could be signed in") + if failed > 0 && failed == browser { + return fmt.Errorf("no tenant could be signed in: every one failed the same way, " + + "so it is the browser sign-in that did not take and not access to any one tenant") } if failed > 0 { fmt.Fprintf(w, "%d of %d tenants could not be signed in; this account may not have access to them\n", @@ -64,6 +87,10 @@ func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, return nil } +func tenantSource(st *config.Store, httpClient *http.Client, tsg string) *saseauth.Source { + return BrowserSource(Flags{TSGID: tsg, BrowserAuth: true}, httpClient, st) +} + func SessionLeft(st *config.Store, tsg string) (time.Duration, bool) { if st == nil { return 0, false diff --git a/internal/clock/clock.go b/internal/clock/clock.go index e4116f5..f8ec768 100644 --- a/internal/clock/clock.go +++ b/internal/clock/clock.go @@ -53,6 +53,14 @@ func Valid(spec string) error { return err } +func StampIn(spec string, t time.Time) (string, error) { + l, _, err := load(spec) + if err != nil { + return "", err + } + return t.In(l).Format(layout), nil +} + func Zone() *time.Location { mu.RLock() defer mu.RUnlock() diff --git a/internal/clock/zones.go b/internal/clock/zones.go new file mode 100644 index 0000000..1398d7a --- /dev/null +++ b/internal/clock/zones.go @@ -0,0 +1,29 @@ +package clock + +import ( + _ "embed" + "strings" + "sync" + + // windows ships no zoneinfo, so LoadLocation needs the embedded copy + _ "time/tzdata" +) + +// generated from the Go distribution's lib/time/zoneinfo.zip; the stdlib lists no zones +// +//go:embed zones.txt +var zoneList string + +var zones = sync.OnceValue(func() []string { + lines := strings.Split(zoneList, "\n") + out := make([]string, 0, len(lines)+2) + out = append(out, UTC, Local) + for _, l := range lines { + if l = strings.TrimSpace(l); l != "" { + out = append(out, l) + } + } + return out +}) + +func Zones() []string { return zones() } diff --git a/internal/clock/zones.txt b/internal/clock/zones.txt new file mode 100644 index 0000000..bcfee7d --- /dev/null +++ b/internal/clock/zones.txt @@ -0,0 +1,553 @@ +Africa/Abidjan +Africa/Accra +Africa/Addis_Ababa +Africa/Algiers +Africa/Asmara +Africa/Asmera +Africa/Bamako +Africa/Bangui +Africa/Banjul +Africa/Bissau +Africa/Blantyre +Africa/Brazzaville +Africa/Bujumbura +Africa/Cairo +Africa/Casablanca +Africa/Ceuta +Africa/Conakry +Africa/Dakar +Africa/Dar_es_Salaam +Africa/Djibouti +Africa/Douala +Africa/El_Aaiun +Africa/Freetown +Africa/Gaborone +Africa/Harare +Africa/Johannesburg +Africa/Juba +Africa/Kampala +Africa/Khartoum +Africa/Kigali +Africa/Kinshasa +Africa/Lagos +Africa/Libreville +Africa/Lome +Africa/Luanda +Africa/Lubumbashi +Africa/Lusaka +Africa/Malabo +Africa/Maputo +Africa/Maseru +Africa/Mbabane +Africa/Mogadishu +Africa/Monrovia +Africa/Nairobi +Africa/Ndjamena +Africa/Niamey +Africa/Nouakchott +Africa/Ouagadougou +Africa/Porto-Novo +Africa/Sao_Tome +Africa/Timbuktu +Africa/Tripoli +Africa/Tunis +Africa/Windhoek +America/Adak +America/Anchorage +America/Anguilla +America/Antigua +America/Araguaina +America/Argentina/Buenos_Aires +America/Argentina/Catamarca +America/Argentina/ComodRivadavia +America/Argentina/Cordoba +America/Argentina/Jujuy +America/Argentina/La_Rioja +America/Argentina/Mendoza +America/Argentina/Rio_Gallegos +America/Argentina/Salta +America/Argentina/San_Juan +America/Argentina/San_Luis +America/Argentina/Tucuman +America/Argentina/Ushuaia +America/Aruba +America/Asuncion +America/Atikokan +America/Atka +America/Bahia +America/Bahia_Banderas +America/Barbados +America/Belem +America/Belize +America/Blanc-Sablon +America/Boa_Vista +America/Bogota +America/Boise +America/Buenos_Aires +America/Cambridge_Bay +America/Campo_Grande +America/Cancun +America/Caracas +America/Catamarca +America/Cayenne +America/Cayman +America/Chicago +America/Chihuahua +America/Ciudad_Juarez +America/Coral_Harbour +America/Cordoba +America/Costa_Rica +America/Coyhaique +America/Creston +America/Cuiaba +America/Curacao +America/Danmarkshavn +America/Dawson +America/Dawson_Creek +America/Denver +America/Detroit +America/Dominica +America/Edmonton +America/Eirunepe +America/El_Salvador +America/Ensenada +America/Fort_Nelson +America/Fort_Wayne +America/Fortaleza +America/Glace_Bay +America/Godthab +America/Goose_Bay +America/Grand_Turk +America/Grenada +America/Guadeloupe +America/Guatemala +America/Guayaquil +America/Guyana +America/Halifax +America/Havana +America/Hermosillo +America/Indiana/Indianapolis +America/Indiana/Knox +America/Indiana/Marengo +America/Indiana/Petersburg +America/Indiana/Tell_City +America/Indiana/Vevay +America/Indiana/Vincennes +America/Indiana/Winamac +America/Indianapolis +America/Inuvik +America/Iqaluit +America/Jamaica +America/Jujuy +America/Juneau +America/Kentucky/Louisville +America/Kentucky/Monticello +America/Knox_IN +America/Kralendijk +America/La_Paz +America/Lima +America/Los_Angeles +America/Louisville +America/Lower_Princes +America/Maceio +America/Managua +America/Manaus +America/Marigot +America/Martinique +America/Matamoros +America/Mazatlan +America/Mendoza +America/Menominee +America/Merida +America/Metlakatla +America/Mexico_City +America/Miquelon +America/Moncton +America/Monterrey +America/Montevideo +America/Montreal +America/Montserrat +America/Nassau +America/New_York +America/Nipigon +America/Nome +America/Noronha +America/North_Dakota/Beulah +America/North_Dakota/Center +America/North_Dakota/New_Salem +America/Nuuk +America/Ojinaga +America/Panama +America/Pangnirtung +America/Paramaribo +America/Phoenix +America/Port-au-Prince +America/Port_of_Spain +America/Porto_Acre +America/Porto_Velho +America/Puerto_Rico +America/Punta_Arenas +America/Rainy_River +America/Rankin_Inlet +America/Recife +America/Regina +America/Resolute +America/Rio_Branco +America/Rosario +America/Santa_Isabel +America/Santarem +America/Santiago +America/Santo_Domingo +America/Sao_Paulo +America/Scoresbysund +America/Shiprock +America/Sitka +America/St_Barthelemy +America/St_Johns +America/St_Kitts +America/St_Lucia +America/St_Thomas +America/St_Vincent +America/Swift_Current +America/Tegucigalpa +America/Thule +America/Thunder_Bay +America/Tijuana +America/Toronto +America/Tortola +America/Vancouver +America/Virgin +America/Whitehorse +America/Winnipeg +America/Yakutat +America/Yellowknife +Antarctica/Casey +Antarctica/Davis +Antarctica/DumontDUrville +Antarctica/Macquarie +Antarctica/Mawson +Antarctica/McMurdo +Antarctica/Palmer +Antarctica/Rothera +Antarctica/South_Pole +Antarctica/Syowa +Antarctica/Troll +Antarctica/Vostok +Arctic/Longyearbyen +Asia/Aden +Asia/Almaty +Asia/Amman +Asia/Anadyr +Asia/Aqtau +Asia/Aqtobe +Asia/Ashgabat +Asia/Ashkhabad +Asia/Atyrau +Asia/Baghdad +Asia/Bahrain +Asia/Baku +Asia/Bangkok +Asia/Barnaul +Asia/Beirut +Asia/Bishkek +Asia/Brunei +Asia/Calcutta +Asia/Chita +Asia/Choibalsan +Asia/Chongqing +Asia/Chungking +Asia/Colombo +Asia/Dacca +Asia/Damascus +Asia/Dhaka +Asia/Dili +Asia/Dubai +Asia/Dushanbe +Asia/Famagusta +Asia/Gaza +Asia/Harbin +Asia/Hebron +Asia/Ho_Chi_Minh +Asia/Hong_Kong +Asia/Hovd +Asia/Irkutsk +Asia/Istanbul +Asia/Jakarta +Asia/Jayapura +Asia/Jerusalem +Asia/Kabul +Asia/Kamchatka +Asia/Karachi +Asia/Kashgar +Asia/Kathmandu +Asia/Katmandu +Asia/Khandyga +Asia/Kolkata +Asia/Krasnoyarsk +Asia/Kuala_Lumpur +Asia/Kuching +Asia/Kuwait +Asia/Macao +Asia/Macau +Asia/Magadan +Asia/Makassar +Asia/Manila +Asia/Muscat +Asia/Nicosia +Asia/Novokuznetsk +Asia/Novosibirsk +Asia/Omsk +Asia/Oral +Asia/Phnom_Penh +Asia/Pontianak +Asia/Pyongyang +Asia/Qatar +Asia/Qostanay +Asia/Qyzylorda +Asia/Rangoon +Asia/Riyadh +Asia/Saigon +Asia/Sakhalin +Asia/Samarkand +Asia/Seoul +Asia/Shanghai +Asia/Singapore +Asia/Srednekolymsk +Asia/Taipei +Asia/Tashkent +Asia/Tbilisi +Asia/Tehran +Asia/Tel_Aviv +Asia/Thimbu +Asia/Thimphu +Asia/Tokyo +Asia/Tomsk +Asia/Ujung_Pandang +Asia/Ulaanbaatar +Asia/Ulan_Bator +Asia/Urumqi +Asia/Ust-Nera +Asia/Vientiane +Asia/Vladivostok +Asia/Yakutsk +Asia/Yangon +Asia/Yekaterinburg +Asia/Yerevan +Atlantic/Azores +Atlantic/Bermuda +Atlantic/Canary +Atlantic/Cape_Verde +Atlantic/Faeroe +Atlantic/Faroe +Atlantic/Jan_Mayen +Atlantic/Madeira +Atlantic/Reykjavik +Atlantic/South_Georgia +Atlantic/St_Helena +Atlantic/Stanley +Australia/ACT +Australia/Adelaide +Australia/Brisbane +Australia/Broken_Hill +Australia/Canberra +Australia/Currie +Australia/Darwin +Australia/Eucla +Australia/Hobart +Australia/LHI +Australia/Lindeman +Australia/Lord_Howe +Australia/Melbourne +Australia/NSW +Australia/North +Australia/Perth +Australia/Queensland +Australia/South +Australia/Sydney +Australia/Tasmania +Australia/Victoria +Australia/West +Australia/Yancowinna +Brazil/Acre +Brazil/DeNoronha +Brazil/East +Brazil/West +Canada/Atlantic +Canada/Central +Canada/Eastern +Canada/Mountain +Canada/Newfoundland +Canada/Pacific +Canada/Saskatchewan +Canada/Yukon +Chile/Continental +Chile/EasterIsland +Etc/GMT +Etc/GMT+0 +Etc/GMT+1 +Etc/GMT+10 +Etc/GMT+11 +Etc/GMT+12 +Etc/GMT+2 +Etc/GMT+3 +Etc/GMT+4 +Etc/GMT+5 +Etc/GMT+6 +Etc/GMT+7 +Etc/GMT+8 +Etc/GMT+9 +Etc/GMT-0 +Etc/GMT-1 +Etc/GMT-10 +Etc/GMT-11 +Etc/GMT-12 +Etc/GMT-13 +Etc/GMT-14 +Etc/GMT-2 +Etc/GMT-3 +Etc/GMT-4 +Etc/GMT-5 +Etc/GMT-6 +Etc/GMT-7 +Etc/GMT-8 +Etc/GMT-9 +Etc/GMT0 +Etc/Greenwich +Etc/UCT +Etc/UTC +Etc/Universal +Etc/Zulu +Europe/Amsterdam +Europe/Andorra +Europe/Astrakhan +Europe/Athens +Europe/Belfast +Europe/Belgrade +Europe/Berlin +Europe/Bratislava +Europe/Brussels +Europe/Bucharest +Europe/Budapest +Europe/Busingen +Europe/Chisinau +Europe/Copenhagen +Europe/Dublin +Europe/Gibraltar +Europe/Guernsey +Europe/Helsinki +Europe/Isle_of_Man +Europe/Istanbul +Europe/Jersey +Europe/Kaliningrad +Europe/Kiev +Europe/Kirov +Europe/Kyiv +Europe/Lisbon +Europe/Ljubljana +Europe/London +Europe/Luxembourg +Europe/Madrid +Europe/Malta +Europe/Mariehamn +Europe/Minsk +Europe/Monaco +Europe/Moscow +Europe/Nicosia +Europe/Oslo +Europe/Paris +Europe/Podgorica +Europe/Prague +Europe/Riga +Europe/Rome +Europe/Samara +Europe/San_Marino +Europe/Sarajevo +Europe/Saratov +Europe/Simferopol +Europe/Skopje +Europe/Sofia +Europe/Stockholm +Europe/Tallinn +Europe/Tirane +Europe/Tiraspol +Europe/Ulyanovsk +Europe/Uzhgorod +Europe/Vaduz +Europe/Vatican +Europe/Vienna +Europe/Vilnius +Europe/Volgograd +Europe/Warsaw +Europe/Zagreb +Europe/Zaporozhye +Europe/Zurich +Indian/Antananarivo +Indian/Chagos +Indian/Christmas +Indian/Cocos +Indian/Comoro +Indian/Kerguelen +Indian/Mahe +Indian/Maldives +Indian/Mauritius +Indian/Mayotte +Indian/Reunion +Mexico/BajaNorte +Mexico/BajaSur +Mexico/General +Pacific/Apia +Pacific/Auckland +Pacific/Bougainville +Pacific/Chatham +Pacific/Chuuk +Pacific/Easter +Pacific/Efate +Pacific/Enderbury +Pacific/Fakaofo +Pacific/Fiji +Pacific/Funafuti +Pacific/Galapagos +Pacific/Gambier +Pacific/Guadalcanal +Pacific/Guam +Pacific/Honolulu +Pacific/Johnston +Pacific/Kanton +Pacific/Kiritimati +Pacific/Kosrae +Pacific/Kwajalein +Pacific/Majuro +Pacific/Marquesas +Pacific/Midway +Pacific/Nauru +Pacific/Niue +Pacific/Norfolk +Pacific/Noumea +Pacific/Pago_Pago +Pacific/Palau +Pacific/Pitcairn +Pacific/Pohnpei +Pacific/Ponape +Pacific/Port_Moresby +Pacific/Rarotonga +Pacific/Saipan +Pacific/Samoa +Pacific/Tahiti +Pacific/Tarawa +Pacific/Tongatapu +Pacific/Truk +Pacific/Wake +Pacific/Wallis +Pacific/Yap +US/Alaska +US/Aleutian +US/Arizona +US/Central +US/East-Indiana +US/Eastern +US/Hawaii +US/Indiana-Starke +US/Michigan +US/Mountain +US/Pacific +US/Samoa diff --git a/internal/saseauth/browser.go b/internal/saseauth/browser.go index 1ebe413..1000ead 100644 --- a/internal/saseauth/browser.go +++ b/internal/saseauth/browser.go @@ -60,20 +60,32 @@ func (o Options) timeout() time.Duration { } func Login(ctx context.Context, tsg string, opt Options) (*Token, error) { - if opt.DataDir != "" { - if fi, err := os.Stat(opt.DataDir); err == nil && fi.IsDir() { - tok, err := login(ctx, tsg, opt, true) - if err == nil { - return tok, nil - } - if ctx.Err() != nil { - return nil, err - } - } + tok, err := LoginSilent(ctx, tsg, opt) + if err == nil { + return tok, nil + } + if ctx.Err() != nil { + return nil, err } return login(ctx, tsg, opt, false) } +var silentRefused sync.Map + +func refusalKey(dir, tsg string) string { return dir + "\x00" + tsg } + +func clearRefusals(dir string) { + if dir == "" { + return + } + silentRefused.Range(func(k, _ any) bool { + if s, ok := k.(string); ok && strings.HasPrefix(s, dir+"\x00") { + silentRefused.Delete(k) + } + return true + }) +} + func LoginSilent(ctx context.Context, tsg string, opt Options) (*Token, error) { if opt.DataDir == "" { return nil, ErrNoSession @@ -81,15 +93,25 @@ func LoginSilent(ctx context.Context, tsg string, opt Options) (*Token, error) { if fi, err := os.Stat(opt.DataDir); err != nil || !fi.IsDir() { return nil, ErrNoSession } + key := refusalKey(opt.DataDir, tsg) + if _, refused := silentRefused.Load(key); refused { + return nil, fmt.Errorf("%w: a silent sign-in was already refused for this profile", ErrNoSession) + } tok, err := login(ctx, tsg, opt, true) if err != nil && ctx.Err() == nil { + silentRefused.Store(key, struct{}{}) return nil, fmt.Errorf("%w: %v", ErrNoSession, err) } return tok, err } func login(ctx context.Context, tsg string, opt Options, silent bool) (*Token, error) { - return loginToken(ctx, tsg, opt, silent) + tok, err := loginToken(ctx, tsg, opt, silent) + if err != nil { + return nil, err + } + clearRefusals(opt.DataDir) + return tok, nil } func loginToken(ctx context.Context, tsg string, opt Options, silent bool) (*Token, error) { diff --git a/internal/saseauth/browser_test.go b/internal/saseauth/browser_test.go index 8f015fd..a0fd70a 100644 --- a/internal/saseauth/browser_test.go +++ b/internal/saseauth/browser_test.go @@ -2,6 +2,7 @@ package saseauth import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -332,6 +333,53 @@ func TestDiscoverThenSwitchTenantsSilently(t *testing.T) { } } +func TestARefusedSilentLegIsNotLaunchedAgain(t *testing.T) { + profile := t.TempDir() + count := filepath.Join(t.TempDir(), "launches") + fake := filepath.Join(t.TempDir(), "fake-chrome") + script := "#!/bin/sh\necho x >> " + count + "\nexit 1\n" + if err := os.WriteFile(fake, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + launches := func() int { + b, err := os.ReadFile(count) + if err != nil { + return 0 + } + return strings.Count(string(b), "x") + } + + opt := Options{Chrome: fake, DataDir: profile, NoSandbox: true, Timeout: 10 * time.Second} + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if _, err := LoginSilent(ctx, "1984021017", opt); err == nil { + t.Fatal("LoginSilent() succeeded with a browser that cannot start") + } + first := launches() + if first == 0 { + t.Fatalf("the browser was never launched, so this test proves nothing") + } + + _, err := LoginSilent(ctx, "1984021017", opt) + if !errors.Is(err, ErrNoSession) { + t.Errorf("second LoginSilent() error = %v, want it to wrap ErrNoSession", err) + } + if got := launches(); got != first { + t.Errorf("the browser was launched %d times over two silent legs, want %d — a refused "+ + "prompt=none does not start answering differently on its own", got, first) + } + + if _, err := LoginSilent(ctx, "1538107912", opt); err == nil { + t.Fatal("LoginSilent() succeeded for the other tenant") + } + if got := launches(); got <= first { + t.Errorf("another tenant's silent leg was skipped after %d launches; the refusal is "+ + "per tenant, since prompt=none can refuse one tenant of a live session", first) + } +} + func TestCleanUpDoesNotWaitForABrowserThatWillNeverExit(t *testing.T) { defer func(d time.Duration) { cleanUpGrace = d }(cleanUpGrace) cleanUpGrace = 50 * time.Millisecond diff --git a/internal/saseauth/source.go b/internal/saseauth/source.go index fb10dc4..43f989b 100644 --- a/internal/saseauth/source.go +++ b/internal/saseauth/source.go @@ -10,6 +10,8 @@ import ( var ErrInteractiveRequired = errors.New("interactive sign-in required") +var ErrNoCredential = errors.New("no stored sign-in for this tenant") + type Source struct { tsg string opt Options @@ -62,7 +64,7 @@ func (s *Source) accept(tok *Token) (string, time.Duration, error) { } func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { - var stored error + var stored, account error if rt := s.RefreshToken(); rt != "" { tok, err := Refresh(ctx, s.opt.httpClient(), s.opt.token(), rt, s.tsg) @@ -82,9 +84,7 @@ func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { if ctx.Err() != nil { return "", 0, err } - if stored == nil { - stored = fmt.Errorf("account credential rejected: %w", err) - } + account = fmt.Errorf("account credential rejected: %w", err) } tok, err := LoginSilent(ctx, s.tsg, s.opt) @@ -95,9 +95,13 @@ func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { return "", 0, err } if stored != nil { - return "", 0, permanentError{fmt.Errorf("%w: %v; %v", ErrInteractiveRequired, stored, err)} + return "", 0, permanentError{fmt.Errorf("%w: %v; %w", ErrInteractiveRequired, stored, err)} + } + if account != nil { + return "", 0, permanentError{fmt.Errorf("%w: %w; %v; %w", + ErrInteractiveRequired, ErrNoCredential, account, err)} } - return "", 0, permanentError{fmt.Errorf("%w: %v", ErrInteractiveRequired, err)} + return "", 0, permanentError{fmt.Errorf("%w: %w; %w", ErrInteractiveRequired, ErrNoCredential, err)} } type permanentError struct{ err error } diff --git a/internal/saseauth/tenants.go b/internal/saseauth/tenants.go index 168910c..401fd2a 100644 --- a/internal/saseauth/tenants.go +++ b/internal/saseauth/tenants.go @@ -96,7 +96,7 @@ type Discovery struct { } func Discover(ctx context.Context, opt Options, tenancyURL string) (Discovery, error) { - tok, err := loginToken(ctx, "", opt, false) + tok, err := login(ctx, "", opt, false) if err != nil { return Discovery{}, err } diff --git a/internal/tui/appearance.go b/internal/tui/appearance.go index 89b7cd4..dc81148 100644 --- a/internal/tui/appearance.go +++ b/internal/tui/appearance.go @@ -55,22 +55,6 @@ func (m tuiModel) applyZone(spec string) (tuiModel, bool) { return m, true } -func (m tuiModel) openZoneForm() (tea.Model, tea.Cmd) { - f := newField("zone", "America/Chicago", false) - f.input.SetValue(m.cfg.Zone) - return m.openForm(formZone, "Zone for printed times", phaseAddUser, []formField{f}) -} - -func (m tuiModel) submitZone() (tea.Model, tea.Cmd) { - nm, ok := m.applyZone(m.formValue(0)) - if !ok { - return nm, nil - } - nm.form, nm.formKind = nil, formNone - nm.phase = nm.formReturn - return nm, nil -} - func (m tuiModel) themeAt(cursor int) (ui.Theme, bool) { themes := ui.Themes() if cursor < 1 || cursor > len(themes) { diff --git a/internal/tui/appearance_test.go b/internal/tui/appearance_test.go index 4a13760..d82e414 100644 --- a/internal/tui/appearance_test.go +++ b/internal/tui/appearance_test.go @@ -80,41 +80,89 @@ func TestTheCycleKeepsTheTypedZone(t *testing.T) { } } -func TestEnterOnTheTimesRowOpensTheZoneForm(t *testing.T) { +func TestEnterOnTheTimesRowOpensTheZonePicker(t *testing.T) { pinZone(t) m := settingsModel(t) m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow mm, _ := m.onGeneralKey("enter") m = mm.(tuiModel) - if m.formKind != formZone { - t.Fatalf("formKind = %v, want the zone form", m.formKind) + if m.phase != phaseZone { + t.Fatalf("phase = %v, want the zone picker", m.phase) + } + if len(m.zonePick.items) < 100 { + t.Fatalf("the picker offers %d zones, want the whole list to choose from", len(m.zonePick.items)) + } + + m = typeString(t, m, "london") + if len(m.zonePick.items) == 0 { + t.Fatal("a fuzzy query matched no zone at all") + } + if got, _ := m.zonePick.current(); got != "Europe/London" { + t.Errorf("highlighted %q, want Europe/London first for %q", got, "london") } - m.form[0].input.SetValue("Europe/London") - mm, _ = m.submitZone() + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) m = mm.(tuiModel) - if m.formKind != formNone { - t.Error("the form stayed open after a zone that loads") + if m.phase != phaseSettings { + t.Errorf("phase = %v, want to return to settings", m.phase) } if clock.Name() != "Europe/London" { - t.Errorf("clock.Name() = %q, want the typed zone", clock.Name()) + t.Errorf("clock.Name() = %q, want the picked zone", clock.Name()) + } + if m.cfg.Zone != "Europe/London" { + t.Errorf("cfg.Zone = %q, want the pick persisted", m.cfg.Zone) } } -func TestAZoneThatWillNotLoadIsRefusedByTheForm(t *testing.T) { +func TestTheZonePickerScrollsAndPreviewsTheHighlightedZone(t *testing.T) { pinZone(t) m := settingsModel(t) m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow - mm, _ := m.onGeneralKey("enter") m = mm.(tuiModel) - m.form[0].input.SetValue("Mars/Olympus_Mons") - mm, _ = m.submitZone() + + first, _ := m.zonePick.current() + for i := 0; i < suggestRows+2; i++ { + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + m = mm.(tuiModel) + } + later, ok := m.zonePick.current() + if !ok || later == first { + t.Errorf("the list did not move with the cursor: %q then %q", first, later) + } + + start, end := m.zonePick.window(suggestRows) + if m.zonePick.cursor < start || m.zonePick.cursor >= end { + t.Errorf("cursor %d is outside the shown window %d..%d, so it scrolled off screen", + m.zonePick.cursor, start, end) + } + + out := plain(m.viewZonePick()) + if !strings.Contains(out, later) { + t.Errorf("the highlighted zone is not on the page:\n%s", out) + } + if !strings.Contains(out, "now") { + t.Errorf("no clock preview to check the pick against:\n%s", out) + } +} + +func TestAZoneThatWillNotLoadIsRefusedByThePicker(t *testing.T) { + pinZone(t) + m := settingsModel(t) + m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow + mm, _ := m.onGeneralKey("enter") m = mm.(tuiModel) - if m.formKind != formZone { - t.Error("the form closed on a zone that does not exist") + m = typeString(t, m, "Mars/Olympus_Mons") + if _, ok := m.zonePick.current(); ok { + t.Fatal("a zone that does not exist matched the list") + } + + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = mm.(tuiModel) + if m.phase != phaseZone { + t.Error("the picker closed on a zone that does not exist") } if m.status == "" { t.Error("nothing said why the zone was refused") @@ -177,7 +225,7 @@ func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { t.Error("picking a theme issued no command, so the spinner kept the old one") } if ui.StyleOK.Render("x") != "x" { - t.Errorf("mono still colours StyleOK: %q", ui.StyleOK.Render("x")) + t.Errorf("mono still colors StyleOK: %q", ui.StyleOK.Render("x")) } } @@ -241,7 +289,7 @@ func TestThePreviewRendersInTheSchemeItNames(t *testing.T) { t.Errorf("the nord preview has no truecolor in it:\n%q", nordLines) } if strings.Contains(monoLines, "38;2;") { - t.Errorf("the mono preview emitted colour:\n%q", monoLines) + t.Errorf("the mono preview emitted color:\n%q", monoLines) } if !strings.Contains(plain(monoLines), mono.Spinner) { t.Errorf("the preview does not name mono's spinner %q", mono.Spinner) diff --git a/internal/tui/authline_test.go b/internal/tui/authline_test.go index 9d5b81d..643a26e 100644 --- a/internal/tui/authline_test.go +++ b/internal/tui/authline_test.go @@ -56,7 +56,7 @@ func TestAuthLineNamesTheCredentialKind(t *testing.T) { } } -func TestAuthLineColourTracksTheSessionLeft(t *testing.T) { +func TestAuthLineColorTracksTheSessionLeft(t *testing.T) { const ( red = "\x1b[31m" green = "\x1b[32m" @@ -85,7 +85,7 @@ func TestAuthLineColourTracksTheSessionLeft(t *testing.T) { line := m.authLine() if !strings.Contains(line, tt.wantCode) { - t.Errorf("authLine() = %q, want colour %q", line, strings.TrimPrefix(tt.wantCode, "\x1b")) + t.Errorf("authLine() = %q, want color %q", line, strings.TrimPrefix(tt.wantCode, "\x1b")) } text := plain(line) hasTimer := strings.Contains(text, "left") diff --git a/internal/tui/cmdsuggest.go b/internal/tui/cmdsuggest.go new file mode 100644 index 0000000..20f6755 --- /dev/null +++ b/internal/tui/cmdsuggest.go @@ -0,0 +1,60 @@ +package tui + +import ( + "strings" + + "tyrtop.com/iontk/internal/ui" +) + +func lastSegment(s string) string { + if i := strings.LastIndex(s, ","); i >= 0 { + return strings.TrimSpace(s[i+1:]) + } + return strings.TrimSpace(s) +} + +func replaceLastSegment(s, with string) string { + if i := strings.LastIndex(s, ","); i >= 0 { + return strings.TrimRight(s[:i+1], " ") + " " + with + } + return with +} + +func (m tuiModel) refilterCommands(query string) tuiModel { + m.cmdPick = m.cmdPick.refilter(m.catalog(), query) + return m +} + +func (m tuiModel) cmdSuggestBlock() string { + var b strings.Builder + b.WriteString(m.cmdPick.view(m.width, suggestRows)) + line := ui.StyleDim.Render("ctrl+n/ctrl+p pick a command · tab completes it") + + " " + m.cmdPick.count("commands") + if pick, ok := m.cmdPick.current(); ok { + line = ui.StyleDim.Render("tab inserts ") + ui.StyleElement.Render(pick) + } + b.WriteString(line + "\n") + return b.String() +} + +func (m tuiModel) insertBatchCommand(pick string) tuiModel { + v := replaceLastSegment(m.cmdInput.Value(), pick) + m.cmdInput.SetValue(v) + m.cmdInput.SetCursor(len(v)) + return m.refilterCommands(lastSegment(v)) +} + +func (m tuiModel) syncMacroSuggestions() tuiModel { + if m.formKind != formMacro || m.formCursor < 1 { + m.cmdPick = newSuggest(nil, "") + return m + } + return m.refilterCommands(m.formValue(m.formCursor)) +} + +func (m tuiModel) insertMacroCommand(pick string) tuiModel { + in := &m.form[m.formCursor].input + in.SetValue(pick) + in.SetCursor(len(pick)) + return m.syncMacroSuggestions() +} diff --git a/internal/tui/colourpick.go b/internal/tui/colorpick.go similarity index 92% rename from internal/tui/colourpick.go rename to internal/tui/colorpick.go index 9c906ec..63c6c7a 100644 --- a/internal/tui/colourpick.go +++ b/internal/tui/colorpick.go @@ -10,7 +10,7 @@ import ( "tyrtop.com/iontk/internal/ui" ) -type colourPick struct { +type colorPick struct { on bool role string was string @@ -43,20 +43,20 @@ func (m tuiModel) wheelSize() (cols, rows int) { } func (m tuiModel) openTabPicker(list string, i int, label string) (tea.Model, tea.Cmd) { - next, cmd := m.openPickerOn(colourPick{ + next, cmd := m.openPickerOn(colorPick{ on: true, list: list, idx: i, label: label, - was: m.tabColourAt(list, i), + was: m.tabColorAt(list, i), }) return next, cmd } func (m tuiModel) openPicker(role string) (tea.Model, tea.Cmd) { - return m.openPickerOn(colourPick{ + return m.openPickerOn(colorPick{ on: true, role: role, label: ui.RoleLabel(role), was: m.themeDraft.Role(role), }) } -func (m tuiModel) openPickerOn(p colourPick) (tea.Model, tea.Cmd) { +func (m tuiModel) openPickerOn(p colorPick) (tea.Model, tea.Cmd) { cols, rows := m.wheelSize() p.grid = !ui.TrueColor() @@ -81,8 +81,8 @@ func (m tuiModel) onPickerKey(key string) (tea.Model, tea.Cmd) { nm.status = err.Error() } m = nm - m.pick = colourPick{} - m.status = label + " left as " + describeColour(was) + m.pick = colorPick{} + m.status = label + " left as " + describeColor(was) return m.applyWorking() case "enter": @@ -92,7 +92,7 @@ func (m tuiModel) onPickerKey(key string) (tea.Model, tea.Cmd) { m = m.remember(back.themeDraft) } m.themeDraft = kept - m.pick = colourPick{} + m.pick = colorPick{} return m.saveDraft() case "tab": @@ -111,10 +111,10 @@ func (m tuiModel) onPickerKey(key string) (tea.Model, tea.Cmd) { case "t": if m.pick.list != "" { - m.status = "tab a colour off the chart, or type one into a role" + m.status = "tab a color off the chart, or type one into a role" return m, nil } - return m.openColourForm(m.pick.role) + return m.openColorForm(m.pick.role) case "ctrl+d", "x": if m.pick.list != "" { @@ -213,14 +213,14 @@ func (m tuiModel) applyPicked() (tea.Model, tea.Cmd) { func (m tuiModel) setPicked(v string) (tuiModel, error) { if m.pick.list != "" { - return m.setTabColour(m.pick.list, m.pick.idx, v) + return m.setTabColor(m.pick.list, m.pick.idx, v) } return m, m.themeDraft.SetRole(m.pick.role, v) } func (m tuiModel) pickedCurrent() string { if m.pick.list != "" { - return m.tabColourAt(m.pick.list, m.pick.idx) + return m.tabColorAt(m.pick.list, m.pick.idx) } return m.themeDraft.Role(m.pick.role) } @@ -235,12 +235,12 @@ func (m tuiModel) applyWorking() (tea.Model, tea.Cmd) { func (m tuiModel) pickedStyle() lipgloss.Style { if m.pick.list != "" { - return ui.ColourStyle(m.pickedCurrent()) + return ui.ColorStyle(m.pickedCurrent()) } return m.themeDraft.RoleStyle(m.pick.role) } -func describeColour(spec string) string { +func describeColor(spec string) string { if strings.TrimSpace(spec) == "" { return "unset" } diff --git a/internal/tui/colourpick_test.go b/internal/tui/colorpick_test.go similarity index 78% rename from internal/tui/colourpick_test.go rename to internal/tui/colorpick_test.go index caaf90b..9bb265d 100644 --- a/internal/tui/colourpick_test.go +++ b/internal/tui/colorpick_test.go @@ -34,7 +34,7 @@ func pickerOn(t *testing.T, theme, role string) tuiModel { return m } -func TestMovingOnTheDiscAppliesTheColourWithoutSavingIt(t *testing.T) { +func TestMovingOnTheDiscAppliesTheColorWithoutSavingIt(t *testing.T) { m := pickerOn(t, "nord", "ok") before := m.themeDraft.OK @@ -47,7 +47,7 @@ func TestMovingOnTheDiscAppliesTheColourWithoutSavingIt(t *testing.T) { t.Errorf("the disc gave %q, want a hex", m.themeDraft.OK) } if got := ui.CurrentTheme().OK; got != m.themeDraft.OK { - t.Errorf("CurrentTheme().OK = %q, want the colour under the cursor %q", + t.Errorf("CurrentTheme().OK = %q, want the color under the cursor %q", got, m.themeDraft.OK) } @@ -71,7 +71,7 @@ func TestMovingOnTheDiscAppliesTheColourWithoutSavingIt(t *testing.T) { } } -func TestEscapingThePickerPutsTheOldColourBack(t *testing.T) { +func TestEscapingThePickerPutsTheOldColorBack(t *testing.T) { m := pickerOn(t, "nord", "warn") original := m.themeDraft.Warn @@ -117,11 +117,11 @@ func TestTheChartStoresAnIndexRatherThanAHex(t *testing.T) { t.Errorf("element = %q off the chart, want an index 0-255", got) } if got := ui.CurrentTheme().Element; got != m.themeDraft.Element { - t.Errorf("the chart colour was not applied: frame has %q", got) + t.Errorf("the chart color was not applied: frame has %q", got) } } -func TestBrightnessChangesTheColourUnderTheCursor(t *testing.T) { +func TestBrightnessChangesTheColorUnderTheCursor(t *testing.T) { m := pickerOn(t, "nord", "cmd") next, _ := m.onSettingsKey("left") m = next.(tuiModel) @@ -175,7 +175,7 @@ func TestTheDiscFitsTheTerminalItIsDrawnIn(t *testing.T) { } } -func TestRenderTheColourPicker(t *testing.T) { +func TestRenderTheColorPicker(t *testing.T) { m := pickerOn(t, "Ben's Theme", "element") t.Log("\n" + m.viewSettings()) next, _ := m.onSettingsKey("tab") @@ -203,7 +203,7 @@ func TestTheFrameCarriesTheSchemesBackground(t *testing.T) { if got := dark.View().BackgroundColor; got != nil { t.Errorf("the background went out before the terminal was asked: %v", got) } - answered, _ := dark.Update(colourProbeDoneMsg{}) + answered, _ := dark.Update(colorProbeDoneMsg{}) dark = answered.(tuiModel) v := dark.View() @@ -211,7 +211,7 @@ func TestTheFrameCarriesTheSchemesBackground(t *testing.T) { t.Error("the frame carries no background for a scheme that sets one") } if v.ForegroundColor == nil { - t.Error("the frame carries no default text colour") + t.Error("the frame carries no default text color") } plainTheme := use("classic") @@ -246,7 +246,7 @@ func TestTheBackgroundIsPickedLikeAnyOtherRole(t *testing.T) { } } -func TestSwitchingViewsDoesNotChangeTheColour(t *testing.T) { +func TestSwitchingViewsDoesNotChangeTheColor(t *testing.T) { m := pickerOn(t, "Ben's Theme", "element") before := m.themeDraft.Element @@ -283,86 +283,86 @@ func TestSwitchingViewsDoesNotChangeTheColour(t *testing.T) { func probeAnswered(t *testing.T) { t.Helper() - before := termColour - t.Cleanup(func() { termColour = before }) - termColour.probed = true + before := termColor + t.Cleanup(func() { termColor = before }) + termColor.probed = true } -func TestTheTerminalsOwnColoursAreSetRatherThanReset(t *testing.T) { +func TestTheTerminalsOwnColorsAreSetRatherThanReset(t *testing.T) { pinTheme(t) - before := termColour - t.Cleanup(func() { termColour = before }) + before := termColor + t.Cleanup(func() { termColor = before }) - termColour = termColourState{} - if bg, fg := frameColours(); bg != nil || fg != nil { - t.Errorf("frameColours() = %v/%v before the terminal answered", bg, fg) + termColor = termColorState{} + if bg, fg := frameColors(); bg != nil || fg != nil { + t.Errorf("frameColors() = %v/%v before the terminal answered", bg, fg) } - rememberTermColour(colourOf(0x12, 0x34, 0x56), colourOf(0xab, 0xcd, 0xef)) - if termColour.hexBg != "#123456" || termColour.hexFg != "#abcdef" { - t.Fatalf("the report was not kept: %q / %q", termColour.hexBg, termColour.hexFg) + rememberTermColor(colorOf(0x12, 0x34, 0x56), colorOf(0xab, 0xcd, 0xef)) + if termColor.hexBg != "#123456" || termColor.hexFg != "#abcdef" { + t.Fatalf("the report was not kept: %q / %q", termColor.hexBg, termColor.hexFg) } - if !termColour.probed { - t.Error("both halves answered, so the frame should stop holding its colours") + if !termColor.probed { + t.Error("both halves answered, so the frame should stop holding its colors") } if ok := ui.UseTheme("classic"); !ok { t.Fatal("classic is not in the set") } - bg, _ := frameColours() - if got := hexOfColour(bg); got != "#123456" { + bg, _ := frameColors() + if got := hexOfColor(bg); got != "#123456" { t.Errorf("on classic the frame carries %q, want the terminal's own #123456", got) } if ok := ui.UseTheme("Ben's Theme"); !ok { t.Fatal("Ben's Theme is not in the set") } - bg, _ = frameColours() - if got := hexOfColour(bg); got != "#1e1e2e" { + bg, _ = frameColors() + if got := hexOfColor(bg); got != "#1e1e2e" { t.Errorf("on Ben's Theme the frame carries %q, want the scheme's own", got) } var buf strings.Builder - restoreTermColour(&buf) + restoreTermColor(&buf) if got := buf.String(); got != "\x1b]11;#123456\x07\x1b]10;#abcdef\x07" { t.Errorf("restore wrote %q", got) } - rememberTermColour(colourOf(0, 0, 0), nil) - if termColour.hexBg != "#123456" { - t.Errorf("bg = %q, want the colour from before iontk set one", termColour.hexBg) + rememberTermColor(colorOf(0, 0, 0), nil) + if termColor.hexBg != "#123456" { + t.Errorf("bg = %q, want the color from before iontk set one", termColor.hexBg) } - termColour = termColourState{probed: true} + termColor = termColorState{probed: true} ui.UseTheme("classic") buf.Reset() - if bg, fg := frameColours(); bg != nil || fg != nil { - t.Errorf("frameColours() = %v/%v with no answer and a bare scheme", bg, fg) + if bg, fg := frameColors(); bg != nil || fg != nil { + t.Errorf("frameColors() = %v/%v with no answer and a bare scheme", bg, fg) } - restoreTermColour(&buf) + restoreTermColor(&buf) if got := buf.String(); got != "" { t.Errorf("restore wrote %q with no answer to go on", got) } } func TestTheDebugPageSaysWhatTheTerminalReported(t *testing.T) { - before := termColour - t.Cleanup(func() { termColour = before }) + before := termColor + t.Cleanup(func() { termColor = before }) - termColour = termColourState{} - if got := termColourLine(); !strings.Contains(got, "asking") { + termColor = termColorState{} + if got := termColorLine(); !strings.Contains(got, "asking") { t.Errorf("before an answer the line reads %q", got) } - termColour = termColourState{probed: true} - if got := termColourLine(); !strings.Contains(got, "did not answer") { + termColor = termColorState{probed: true} + if got := termColorLine(); !strings.Contains(got, "did not answer") { t.Errorf("with no answer the line reads %q", got) } - termColour = termColourState{probed: true, hexBg: "#1a1b26", hexFg: "#c0caf5"} - if got := termColourLine(); !strings.Contains(got, "#1a1b26") || !strings.Contains(got, "#c0caf5") { - t.Errorf("the line reads %q, want both colours", got) + termColor = termColorState{probed: true, hexBg: "#1a1b26", hexFg: "#c0caf5"} + if got := termColorLine(); !strings.Contains(got, "#1a1b26") || !strings.Contains(got, "#c0caf5") { + t.Errorf("the line reads %q, want both colors", got) } } -func colourOf(r, g, b uint8) color.Color { +func colorOf(r, g, b uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: 0xff} } diff --git a/internal/tui/discover.go b/internal/tui/discover.go index 0802c9a..88ea7da 100644 --- a/internal/tui/discover.go +++ b/internal/tui/discover.go @@ -293,6 +293,13 @@ func (m tuiModel) saveTenantPick() (tea.Model, tea.Cmd) { m.discovered, m.picked = nil, nil m.menuCursor = 0 m.status = "" + + if warm := m.cfg.AddedTenants(); m.browserAuthDone && len(warm) > 0 { + m.warming = len(warm) + m.phase = phaseWarming + return m, tea.Batch(m.spinner.Tick, warmTenants(m.ctx, m.store, warm)) + } + m.phase = phaseMenu m.loadFromCache() if m.cacheState != cacheCached { @@ -301,6 +308,67 @@ func (m tuiModel) saveTenantPick() (tea.Model, tea.Cmd) { return m, nil } +type tenantsWarmedMsg struct { + results []app.TenantWarm +} + +func warmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant) tea.Cmd { + return func() tea.Msg { + return tenantsWarmedMsg{results: app.WarmEach(ctx, st, tenants)} + } +} + +func (m tuiModel) viewWarming() string { + var b strings.Builder + b.WriteString(ui.Banner(m.width, m.height)) + b.WriteString(ui.StyleOK.Render(ui.MarkCheck.String()+" signed in as "+m.ident.Display()) + "\n") + b.WriteString(fmt.Sprintf(" %s signing in to each tenant\n\n", m.spinner.View())) + b.WriteString(" " + ui.StyleDim.Render(fmt.Sprintf( + "%d tenants, one at a time -- this is what makes one sign-in last", m.warming)) + "\n") + b.WriteString("\n") + b.WriteString(ui.Footer(m.status, "please wait")) + return b.String() +} + +func (m tuiModel) onTenantsWarmed(msg tenantsWarmedMsg) (tea.Model, tea.Cmd) { + m.phase = phaseMenu + m.warming = 0 + + var failed []string + for _, r := range msg.results { + if r.Skipped { + continue + } + if r.Err != nil { + if m.tenantIssues == nil { + m.tenantIssues = map[string]string{} + } + m.tenantIssues[r.Tenant.TSGID] = shortTenantErr(r.Err) + failed = append(failed, r.Tenant.Name+": "+shortTenantErr(r.Err)) + continue + } + delete(m.tenantIssues, r.Tenant.TSGID) + if r.Tenant.TSGID == m.tenant.TSGID { + m.tokenDead = "" + } + } + + note := strings.Join(failed, "; ") + m.status = note + + m.loadFromCache() + if m.cacheState == cacheCached { + return m, nil + } + + next, cmd := m.refreshActive() + mm := next.(tuiModel) + if note != "" { + mm.status = note + } + return mm, cmd +} + func (m tuiModel) viewTenantPick() string { var b strings.Builder b.WriteString(ui.Banner(m.width, m.height)) diff --git a/internal/tui/discover_test.go b/internal/tui/discover_test.go index 22ff028..301691f 100644 --- a/internal/tui/discover_test.go +++ b/internal/tui/discover_test.go @@ -1,10 +1,12 @@ package tui import ( + "fmt" "strings" "testing" "time" + "tyrtop.com/iontk/internal/app" "tyrtop.com/iontk/internal/config" "tyrtop.com/iontk/internal/sase/api" "tyrtop.com/iontk/internal/saseauth" @@ -468,6 +470,29 @@ func TestSwitchingActuallyFetchesForTheNewTenant(t *testing.T) { } } +func TestAddingATenantFetchesEvenBesideAFreshlyCachedOne(t *testing.T) { + m := discoverModel(t) + m.discovered = discoveredTenants + m.picked = map[string]bool{"1643707006": true, "1984021017": true} + + if err := m.store.SaveCache(&config.ElementCache{ + TSGID: "1643707006", FetchedAt: time.Now().Add(-2 * time.Minute), + Elements: []api.Element{{ID: "e1", Name: "s0001-rtr-01"}}, + }); err != nil { + t.Fatalf("SaveCache() error = %v", err) + } + + mm, cmd := m.saveTenantPick() + got := mm.(tuiModel) + + if cmd == nil { + t.Error("nothing was fetched, so the tenant just added has no elements until a manual refresh") + } + if got.cacheState != cacheUpdating { + t.Errorf("cacheState = %v, want cacheUpdating with a tenant still unread", got.cacheState) + } +} + func TestSwitchingToATenantWithNoUsableCredentialSaysSo(t *testing.T) { m := discoverModel(t) m.cfg = &config.Config{Tenants: []config.Tenant{ @@ -695,3 +720,95 @@ func TestABrowserTenantIsNeverListedAsAServiceAccount(t *testing.T) { t.Error("there is a selectable row below the action row") } } + +func TestThePickWarmsEveryTenantSoOneSignInLasts(t *testing.T) { + m := multiModel(t) + m.browserAuthDone = true + m.discovered = []saseauth.Tenant{ + {ID: "111", Name: "Tractor Supply"}, + {ID: "222", Name: "Niagara Bottling"}, + } + m.picked = map[string]bool{"111": true, "222": true} + m.spinner = ui.NewSpinner("") + + next, cmd := m.saveTenantPick() + mm := next.(tuiModel) + if mm.phase != phaseWarming { + t.Errorf("phase = %v, want phaseWarming so each tenant gets a credential of its own", mm.phase) + } + if mm.warming != 2 { + t.Errorf("warming = %d, want 2 tenants", mm.warming) + } + if cmd == nil { + t.Fatal("saveTenantPick() returned no command, so nothing warms the tenants") + } +} + +func TestThePickDoesNotWarmWithoutABrowserSignIn(t *testing.T) { + m := multiModel(t) + m.discovered = []saseauth.Tenant{{ID: "111", Name: "Tractor Supply"}} + m.picked = map[string]bool{"111": true} + + next, _ := m.saveTenantPick() + if mm := next.(tuiModel); mm.phase == phaseWarming { + t.Error("a service-account-only pick went warming; there is no browser session to warm from") + } +} + +func TestAWarmFailureIsRecordedWithoutKillingTheSignIn(t *testing.T) { + m := multiModel(t) + m.browserAuthDone = true + m.spinner = ui.NewSpinner("") + + next, _ := m.onTenantsWarmed(tenantsWarmedMsg{results: []app.TenantWarm{ + {Tenant: m.cfg.Tenants[0]}, + {Tenant: m.cfg.Tenants[1], Err: fmt.Errorf("%w: %w; %w", + saseauth.ErrInteractiveRequired, saseauth.ErrNoCredential, saseauth.ErrNoSession)}, + }}) + mm := next.(tuiModel) + + if mm.tenantIssues["222"] == "" { + t.Error("the tenant that could not be warmed carries no issue, so nothing will say why") + } + if mm.tenantIssues["111"] != "" { + t.Errorf("the warmed tenant carries %q, want nothing", mm.tenantIssues["111"]) + } + if mm.tokenDead != "" { + t.Errorf("tokenDead = %q; one tenant refusing must not mark the whole sign-in dead", mm.tokenDead) + } + if !strings.Contains(mm.status, "Niagara Bottling") { + t.Errorf("status = %q, want it to name the tenant that failed", mm.status) + } + if mm.phase == phaseWarming { + t.Error("still warming after the results arrived") + } +} + +func TestASecondBrowserSignInIsNotOfferedForATenantItAlreadyFailed(t *testing.T) { + m := multiModel(t) + m.browserAuthDone = true + m.tenantIssues = map[string]string{"111": "no sign-in for this tenant"} + m.phase = phaseLoading + m.spinner = ui.NewSpinner("") + + next, _ := m.startSignInAuto() + mm := next.(tuiModel) + if mm.phase == phaseSignIn { + t.Error("iontk reopened the browser for a tenant a fresh sign-in had just failed to reach") + } + if !strings.Contains(mm.status, "will not help") { + t.Errorf("status = %q, want it to say another sign-in will not help", mm.status) + } +} + +func TestAnExpiredTenantStillGetsABrowserSignIn(t *testing.T) { + m := multiModel(t) + m.browserAuthDone = true + m.tokenDead = "sign-in expired" + m.spinner = ui.NewSpinner("") + + next, _ := m.startSignInAuto() + if mm := next.(tuiModel); mm.phase != phaseSignIn { + t.Errorf("phase = %v, want phaseSignIn: a tenant with no recorded issue has only aged out", mm.phase) + } +} diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 038449d..1c144f5 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -318,6 +318,50 @@ func TestSeveralMatchesJustApplyTheFilter(t *testing.T) { } } +func TestEscapeAfterApplyingTheQueryLeavesTheList(t *testing.T) { + m := filterModel(t) + m.phase = phasePicking + m.filterOn = true + m.filter.SetValue("s0100") + m.applyFilter() + + mm, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + applied := mm.(tuiModel) + if applied.filterOn || applied.filter.Value() != "s0100" { + t.Fatalf("enter left on=%v query=%q, want the query applied and the input closed", + applied.filterOn, applied.filter.Value()) + } + + mm, _ = applied.onPickKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape}), "esc") + got := mm.(tuiModel) + if got.phase != phaseMenu { + t.Errorf("phase = %v, want esc to go back rather than clear the query", got.phase) + } + if got.filter.Value() != "s0100" { + t.Errorf("filter = %q, want the query left alone", got.filter.Value()) + } +} + +func TestEscapeWhileTypingStillClearsTheQuery(t *testing.T) { + m := filterModel(t) + m.phase = phasePicking + m.filterOn = true + m.filter.SetValue("s0100") + m.applyFilter() + + mm, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape}), "esc") + got := mm.(tuiModel) + if got.phase != phasePicking { + t.Errorf("phase = %v, want to stay on the list", got.phase) + } + if got.filterOn || got.filter.Value() != "" { + t.Errorf("on=%v query=%q, want esc to clear and close the input", got.filterOn, got.filter.Value()) + } + if len(got.matches) != len(got.all) { + t.Errorf("matched %d of %d, want the whole list back", len(got.matches), len(got.all)) + } +} + func TestASingleMatchBeatsAnEarlierSelection(t *testing.T) { m := searchEnterModel(t) m.mode = modeShell @@ -922,7 +966,7 @@ func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { t.Errorf("the state of a connected element is not green:\n%q", out) } if strings.Contains(plain(out), "offline") && strings.Contains(out, ui.StyleErr.Render("offline")) { - t.Error("the row under the cursor kept its own colour, which breaks the highlight") + t.Error("the row under the cursor kept its own color, which breaks the highlight") } m.moveCursorTo(1) @@ -934,7 +978,7 @@ func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { t.Error("the row under the cursor is still green, which breaks the highlight") } if strings.Contains(out, ui.StyleErr.Render("connected")) { - t.Error("connected is rendered in the error colour") + t.Error("connected is rendered in the error color") } } diff --git a/internal/tui/macros.go b/internal/tui/macros.go index 677dc8d..792fc25 100644 --- a/internal/tui/macros.go +++ b/internal/tui/macros.go @@ -304,6 +304,7 @@ func (m tuiModel) openMacroForm(mac config.Macro) (tea.Model, tea.Cmd) { fields = append(fields, newMacroCommand(len(mac.Commands))) m.formMacroName = mac.Name + m.cmdPick = newSuggest(nil, "") title := "New macro" if mac.Name != "" { title = "Edit " + mac.Name @@ -315,7 +316,7 @@ func (m tuiModel) addMacroCommand() (tea.Model, tea.Cmd) { m.form[m.formCursor].input.Blur() m.form = append(m.form, newMacroCommand(len(m.form)-1)) m.formCursor = len(m.form) - 1 - return m, m.form[m.formCursor].input.Focus() + return m.syncMacroSuggestions(), m.form[m.formCursor].input.Focus() } func (m tuiModel) removeMacroCommand() (tea.Model, tea.Cmd) { @@ -329,7 +330,7 @@ func (m tuiModel) removeMacroCommand() (tea.Model, tea.Cmd) { m.formCursor = max(1, min(m.formCursor, len(m.form)-1)) in := &m.form[m.formCursor].input in.SetCursor(len(in.Value())) - return m, in.Focus() + return m.syncMacroSuggestions(), in.Focus() } func (m tuiModel) macroFormCommands() []string { @@ -424,7 +425,7 @@ func (m tuiModel) viewMacros() string { b.WriteString("\n") b.WriteString(ui.Footer(m.status, - "↑↓ move · e edit · x delete · u undo · / filter · esc menu")) + "↑↓ move · e edit · x delete · u undo · / filter · esc back")) return b.String() } @@ -451,7 +452,7 @@ func (m tuiModel) pasteMacroCommands(cmds []string) (tea.Model, tea.Cmd) { m.formCursor = len(m.form) - 1 in := &m.form[m.formCursor].input in.SetCursor(len(in.Value())) - return m, in.Focus() + return m.syncMacroSuggestions(), in.Focus() } func (m tuiModel) listReturn() tuiPhase { diff --git a/internal/tui/multitenant.go b/internal/tui/multitenant.go index 2fb01be..25d1c77 100644 --- a/internal/tui/multitenant.go +++ b/internal/tui/multitenant.go @@ -153,9 +153,11 @@ func (m *tuiModel) mergeTenantElements(tsg, tenant string, els []api.Element) { func (m *tuiModel) loadFromCache() { var rows []elementRow oldest := time.Duration(0) + missing := false for _, t := range m.aggregateTenants() { c, err := m.store.Cache(t.TSGID) if err != nil || !c.Usable() { + missing = true continue } for _, el := range c.Elements { @@ -184,7 +186,7 @@ func (m *tuiModel) loadFromCache() { m.cacheCount = len(rows) m.cacheAt = time.Now().Add(-oldest) m.cacheState = cacheCached - if oldest >= cacheFresh { + if missing || oldest >= cacheFresh { m.cacheState = cacheUpdating } } diff --git a/internal/tui/multitenant_test.go b/internal/tui/multitenant_test.go index daf97f8..1b7ec3a 100644 --- a/internal/tui/multitenant_test.go +++ b/internal/tui/multitenant_test.go @@ -1257,7 +1257,7 @@ func TestAltWClosesWhatYouAreIn(t *testing.T) { m.spinner = ui.NewSpinner("") out := plain(m.viewPicking()) - if !strings.Contains(out, "alt+w menu") { + if !strings.Contains(out, "alt+w back") { t.Errorf("the element list does not advertise alt+w as the way back:\n%s", out) } if strings.Contains(out, "alt+w quit") { diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 444f512..c8dcb9a 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -655,7 +655,7 @@ func TestAltWOnElementsReturnsToTheMenu(t *testing.T) { } hint := plain(m.viewPicking()) - if !strings.Contains(hint, "alt+w menu") { + if !strings.Contains(hint, "alt+w back") { t.Errorf("the element list does not advertise alt+w as the way back:\n%s", hint) } } @@ -977,7 +977,7 @@ func TestStatusSitsAboveTheKeybindLine(t *testing.T) { if !strings.Contains(got, "detached S0100-RTR-01") { t.Fatalf("status missing:\n%s", got) } - if !strings.Contains(got, "alt+w menu") { + if !strings.Contains(got, "alt+w back") { t.Fatalf("keybind line was overwritten by the status:\n%s", got) } @@ -987,7 +987,7 @@ func TestStatusSitsAboveTheKeybindLine(t *testing.T) { if strings.Contains(ln, "detached S0100-RTR-01") { statusAt = i } - if strings.Contains(ln, "alt+w menu") { + if strings.Contains(ln, "alt+w back") { hintAt = i } } diff --git a/internal/tui/settings.go b/internal/tui/settings.go index a767ae9..3508165 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -64,7 +64,10 @@ func (m tuiModel) sectionRows() int { case secTenants: return len(m.cfg.Tenants) case secUsers: - return len(m.tenant.Users) + if !m.hasTenant { + return 0 + } + return len(m.tenant.Users) + 1 case secSession: return 2 case secDebug: @@ -227,7 +230,7 @@ func (m tuiModel) onGeneralKey(key string) (tea.Model, tea.Cmd) { switch m.setCursor { case zoneRow: if key == "enter" { - return m.openZoneForm() + return m.openZonePick() } return m.cycleZone() case modeRow: @@ -306,19 +309,20 @@ func (m tuiModel) onTenantsKey(key string) (tea.Model, tea.Cmd) { func (m tuiModel) onUsersSectionKey(key string) (tea.Model, tea.Cmd) { users := m.tenant.Users - switch key { - case "a": - if m.tenantIndex() >= 0 { - return m.openUserFormFor(m.tenant) + if m.setCursor == 0 { + if key == "enter" { + if m.tenantIndex() >= 0 { + return m.openUserFormFor(m.tenant) + } + m.status = "no tenant selected" } - m.status = "no tenant selected" return m, nil } - if m.setCursor >= len(users) { + if m.setCursor > len(users) { return m, nil } - name := users[m.setCursor].Name + name := users[m.setCursor-1].Name switch key { case "p": @@ -350,7 +354,7 @@ func (m tuiModel) onUsersSectionKey(key string) (tea.Model, tea.Cmd) { if m.ionUser == name { m.ionUser = t.DefaultUser() } - m.setCursor = max(0, min(m.setCursor, len(t.Users)-1)) + m.setCursor = max(0, min(m.setCursor, len(t.Users))) } } return m, nil @@ -470,9 +474,7 @@ func (m tuiModel) sectionContent() []string { out = append(out, ui.StyleDim.Render("no tenant selected")) break } - if len(m.tenant.Users) == 0 { - out = append(out, ui.StyleDim.Render("no ion users — press a to add one")) - } + out = append(out, ui.StyleGhost.Render("+ New ION User")) for _, u := range m.tenant.Users { tags := "" if u.Default { @@ -499,7 +501,7 @@ func (m tuiModel) sectionContent() []string { case secReset: out = append(out, ui.StyleWarn.Render("Factory Reset")) out = append(out, ui.StyleWarn.Render("Restore Themes")+ - ui.StyleDim.Render(" the schemes iontk ships; your own are untouched")) + ui.StyleDim.Render(" restores default iontk themes - user themes are untouched")) if m.resetArmed { what := "delete every tenant, ION password, token and scheme" if m.setCursor == themeRestoreRow { @@ -517,8 +519,8 @@ func (m tuiModel) sectionContent() []string { ui.Label("Secrets")+ui.StyleDim.Render(m.store.Secrets.Backend()), ui.Label("Version")+ui.StyleDim.Render(Version), ui.Label("Stats")+ui.StyleDim.Render("alt+i overlays live counters"), - ui.Label("Colours")+ui.StyleDim.Render(ui.CurrentTheme().Name+" "+profileLabel()), - ui.Label("Terminal")+ui.StyleDim.Render(termColourLine()), + ui.Label("Colors")+ui.StyleDim.Render(ui.CurrentTheme().Name+" "+profileLabel()), + ui.Label("Terminal")+ui.StyleDim.Render(termColorLine()), ui.Label("Times")+ui.StyleDim.Render(clock.Name()), "", ui.Label("Tenant")+ui.StyleDim.Render(m.tenant.Name+" tsg "+m.tenant.TSGID), @@ -564,7 +566,7 @@ func (m tuiModel) sectionHint() string { case secTenants: return "↑↓ row · space include · a add · enter edit · s switch · u add user · x forget" case secUsers: - return "↑↓ row · enter edit · r run as · a add · p password · d default · x remove · tab back" + return "↑↓ row · enter new/edit · r run as · p password · d default · x remove · tab back" case secSession: return "↑↓ row · enter type · -/+ adjust · tab back" case secDebug: diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 40a2a3b..45cf402 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -152,7 +152,7 @@ func TestEditPasswordFromSettingsUsers(t *testing.T) { m := settingsModel(t) m.setSection = secUsers m.setFocus = focusContent - m.setCursor = 0 + m.setCursor = 1 mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'p', Text: "p"})) f := mm.(tuiModel) @@ -234,8 +234,9 @@ func TestAddUserFromSettingsReturnsToSettings(t *testing.T) { m := settingsModel(t) m.setSection = secUsers m.setFocus = focusContent + m.setCursor = 0 - mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"})) + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) f := mm.(tuiModel) if f.formReturn != phaseSettings { t.Errorf("formReturn = %v, want phaseSettings", f.formReturn) @@ -302,6 +303,32 @@ func TestSettingsUsersPaneKeepsTheWholeRow(t *testing.T) { } } +func TestSettingsUsersSectionOpensForATenantWithNoUsers(t *testing.T) { + m := settingsModel(t) + m.tenant = m.cfg.Tenants[1] + m.setSection = secUsers + m.setFocus = focusNav + + if got := plain(m.viewSettings()); !strings.Contains(got, "+ New ION User") { + t.Errorf("no new-user row to put the cursor on:\n%s", got) + } + + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + f := mm.(tuiModel) + if f.setFocus != focusContent { + t.Fatal("section could not be entered, so nothing could add the first user") + } + + mm, _ = f.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + g := mm.(tuiModel) + if g.formKind != formUser { + t.Fatalf("formKind = %d, want the add-user form", g.formKind) + } + if g.formTenantName != "Lab" { + t.Errorf("formTenantName = %q, want Lab", g.formTenantName) + } +} + func TestDefaultUserSwitchesRepeatedlyInSettings(t *testing.T) { m := settingsModel(t) m.setSection = secUsers @@ -311,7 +338,7 @@ func TestDefaultUserSwitchesRepeatedlyInSettings(t *testing.T) { cursor int want string }{ - {1, "cisops"}, {0, "readonly"}, {1, "cisops"}, + {2, "cisops"}, {1, "readonly"}, {2, "cisops"}, } { m.setCursor = step.cursor mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) @@ -365,6 +392,7 @@ func TestDefaultUserSwitchViaNavigationKeys(t *testing.T) { return out.(tuiModel) } + m = press(m, tea.Key{Code: tea.KeyDown}) m = press(m, tea.Key{Code: tea.KeyDown}) t.Logf("after down: setCursor=%d focus=%d rows=%d", m.setCursor, m.setFocus, m.sectionRows()) m = press(m, tea.Key{Code: 'd', Text: "d"}) @@ -556,7 +584,7 @@ func TestEditUserRenameKeepsDefaultAndMovesPassword(t *testing.T) { m := settingsModel(t) m.setSection = secUsers m.setFocus = focusContent - m.setCursor = 0 + m.setCursor = 1 mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) f := mm.(tuiModel) diff --git a/internal/tui/suggest.go b/internal/tui/suggest.go new file mode 100644 index 0000000..3607d06 --- /dev/null +++ b/internal/tui/suggest.go @@ -0,0 +1,107 @@ +package tui + +import ( + "fmt" + "strings" + + "tyrtop.com/iontk/internal/ioncmd" + "tyrtop.com/iontk/internal/ui" +) + +const suggestRows = 8 + +type suggest struct { + items []string + cursor int + sticky bool +} + +func newSuggest(pool []string, query string) suggest { + return suggest{items: ioncmd.Fuzzy(pool, query), cursor: -1} +} + +func newPicker(pool []string, query string) suggest { + return suggest{items: ioncmd.Fuzzy(pool, query), cursor: 0, sticky: true}.clamp() +} + +func (s suggest) floor() int { + if s.sticky { + return 0 + } + return -1 +} + +func (s suggest) clamp() suggest { + if len(s.items) == 0 { + s.cursor = -1 + return s + } + s.cursor = min(max(s.cursor, s.floor()), len(s.items)-1) + return s +} + +func (s suggest) refilter(pool []string, query string) suggest { + held, ok := s.current() + s.items = ioncmd.Fuzzy(pool, query) + s.cursor = s.floor() + if ok { + for i, it := range s.items { + if it == held { + s.cursor = i + break + } + } + } + return s.clamp() +} + +func (s suggest) move(step int) suggest { + s.cursor += step + return s.clamp() +} + +func (s suggest) current() (string, bool) { + if s.cursor < 0 || s.cursor >= len(s.items) { + return "", false + } + return s.items[s.cursor], true +} + +func (s suggest) window(rows int) (int, int) { + if rows <= 0 { + return 0, 0 + } + if len(s.items) <= rows { + return 0, len(s.items) + } + start := 0 + if s.cursor >= rows { + start = s.cursor - rows + 1 + } + return start, start + rows +} + +func (s suggest) count(what string) string { + if len(s.items) == 0 { + return ui.StyleWarn.Render("no " + what + " match") + } + return ui.StyleDim.Render(fmt.Sprintf("%d %s", len(s.items), what)) +} + +func (s suggest) view(width, rows int) string { + var b strings.Builder + start, end := s.window(rows) + for i := start; i < end; i++ { + prefix := " " + line := ui.Truncate(s.items[i], max(10, width-4)) + if i == s.cursor { + prefix = ui.StyleCmd.Render("> ") + line = ui.StyleElement.Render(line) + } + b.WriteString(prefix + line + "\n") + } + for i := end - start; i < rows; i++ { + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/tui/suggest_test.go b/internal/tui/suggest_test.go new file mode 100644 index 0000000..e37a123 --- /dev/null +++ b/internal/tui/suggest_test.go @@ -0,0 +1,183 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestSuggestKeepsTheHighlightedItemAcrossARefilter(t *testing.T) { + pool := []string{"dump device status", "dump spoke-ha status", "dump interface status"} + + s := newSuggest(pool, "") + if _, ok := s.current(); ok { + t.Fatal("a fresh suggestion list highlights something, want nothing until asked") + } + + s = s.move(1) + first, _ := s.current() + if first != pool[0] { + t.Fatalf("current = %q, want the first item", first) + } + + s = s.refilter(pool, "spokeha") + if got, ok := s.current(); ok && got != "dump spoke-ha status" { + t.Errorf("current = %q, want the highlight to follow the one surviving item", got) + } + + s = s.refilter(pool, "zzz") + if _, ok := s.current(); ok { + t.Error("an empty result still highlights something") + } + if s.cursor != -1 { + t.Errorf("cursor = %d, want -1 with nothing to point at", s.cursor) + } +} + +func TestAPickerAlwaysHasASelection(t *testing.T) { + pool := []string{"UTC", "local", "America/Chicago"} + + s := newPicker(pool, "") + if got, ok := s.current(); !ok || got != "UTC" { + t.Fatalf("current = %q ok=%v, want the first row picked", got, ok) + } + s = s.move(-5) + if got, _ := s.current(); got != "UTC" { + t.Errorf("current = %q, want up at the top to stay on the first row", got) + } + s = s.move(99) + if got, _ := s.current(); got != "America/Chicago" { + t.Errorf("current = %q, want down past the end to stay on the last row", got) + } +} + +func TestTheSuggestWindowKeepsTheCursorOnScreen(t *testing.T) { + pool := make([]string, 40) + for i := range pool { + pool[i] = strings.Repeat("x", i+1) + } + s := newPicker(pool, "") + for i := 0; i < 39; i++ { + s = s.move(1) + start, end := s.window(8) + if s.cursor < start || s.cursor >= end { + t.Fatalf("cursor %d outside window %d..%d", s.cursor, start, end) + } + if end-start != 8 { + t.Fatalf("window is %d rows, want 8 so the frame height holds", end-start) + } + } +} + +func TestTheSuggestViewIsAFixedNumberOfRows(t *testing.T) { + s := newSuggest([]string{"one", "two"}, "") + got := strings.Count(s.view(60, 8), "\n") + if got != 8 { + t.Errorf("view wrote %d lines, want 8 whatever it has to show", got) + } +} + +func TestTheBatchBoxFuzzySearchesTheCommandCatalog(t *testing.T) { + m := historyModel(t) + m.width, m.height = 120, 40 + m.phase = phaseCommand + m.cmdInput.Focus() + m.cmdPick = newSuggest(m.catalog(), "") + + if len(m.cmdPick.items) < 100 { + t.Fatalf("the box opens with %d commands, want the catalog on offer", len(m.cmdPick.items)) + } + + m = typeString(t, m, "spokeha") + if n := len(m.cmdPick.items); n == 0 || n > 10 { + t.Fatalf("the query matched %d commands, want it narrowed to a few", n) + } + if _, ok := m.cmdPick.current(); ok { + t.Error("something is highlighted before anything asked, so tab would stop cycling the ION user") + } + if out := plain(m.viewCommand()); !strings.Contains(out, "dump spoke-ha") { + t.Errorf("the matches are not on the page:\n%s", out) + } + + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Mod: tea.ModCtrl})) + m = mm.(tuiModel) + pick, ok := m.cmdPick.current() + if !ok { + t.Fatal("ctrl+n highlighted nothing") + } + + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = mm.(tuiModel) + if got := m.cmdInput.Value(); got != pick { + t.Errorf("cmdInput = %q, want tab to complete to %q", got, pick) + } +} + +func TestTabCompletesOnlyTheCommandBeingTyped(t *testing.T) { + m := historyModel(t) + m.width, m.height = 120, 40 + m.phase = phaseCommand + m.cmdInput.Focus() + m.cmdInput.SetValue("dump device status, ") + m.cmdInput.SetCursor(len(m.cmdInput.Value())) + m = m.refilterCommands(lastSegment(m.cmdInput.Value())) + + m = typeString(t, m, "spokeha") + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Mod: tea.ModCtrl})) + m = mm.(tuiModel) + pick, ok := m.cmdPick.current() + if !ok { + t.Fatal("ctrl+n highlighted nothing") + } + + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = mm.(tuiModel) + want := "dump device status, " + pick + if got := m.cmdInput.Value(); got != want { + t.Errorf("cmdInput = %q, want %q — only the segment being typed is replaced", got, want) + } +} + +func TestTheMacroFormFuzzySearchesCommands(t *testing.T) { + m := macroModel(t) + m.height = 40 + m.macroCursor = 0 + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + m = next.(tuiModel) + if m.formKind != formMacro { + t.Fatalf("formKind = %v, want the macro form", m.formKind) + } + + m.form[0].input.SetValue("ha check 2") + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = mm.(tuiModel) + if m.formCursor != 1 { + t.Fatalf("formCursor = %d, want the first command line", m.formCursor) + } + + m = typeString(t, m, "spokeha") + if n := len(m.cmdPick.items); n == 0 || n > 10 { + t.Fatalf("the query matched %d commands, want it narrowed", n) + } + if out := plain(m.viewForm()); !strings.Contains(out, "dump spoke-ha") { + t.Errorf("the macro form does not show the matches:\n%s", out) + } + + fields := len(m.form) + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Mod: tea.ModCtrl})) + m = mm.(tuiModel) + pick, ok := m.cmdPick.current() + if !ok { + t.Fatal("ctrl+n highlighted nothing") + } + + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = mm.(tuiModel) + if got := m.formValue(1); got != pick { + t.Errorf("command 1 = %q, want tab to complete it to %q", got, pick) + } + if len(m.form) != fields { + t.Errorf("form grew to %d fields, want tab to complete rather than add a line", len(m.form)) + } +} diff --git a/internal/tui/tabcolours.go b/internal/tui/tabcolors.go similarity index 72% rename from internal/tui/tabcolours.go rename to internal/tui/tabcolors.go index 6980d56..b61cd0f 100644 --- a/internal/tui/tabcolours.go +++ b/internal/tui/tabcolors.go @@ -9,7 +9,7 @@ import ( ) const ( - tabsRow = "Tab Colours" + tabsRow = "Tab Colors" tabModeRow = "Mode" listTabs = "tabs" listShll = "shell" @@ -25,10 +25,10 @@ type tabTarget struct { func (m tuiModel) tabTargets() []tabTarget { out := []tabTarget{{label: tabModeRow}} - entries := func(list, word string, colours []string, grow bool) { - for i := range colours { + entries := func(list, word string, colors []string, grow bool) { + for i := range colors { label := word - if len(colours) > 1 || grow { + if len(colors) > 1 || grow { label = fmt.Sprintf("%s %d", word, i+1) } out = append(out, tabTarget{label: label, list: list, idx: i}) @@ -40,18 +40,18 @@ func (m tuiModel) tabTargets() []tabTarget { switch m.themeDraft.TabMode { case ui.TabByType: - entries(listShll, "Shell", m.tabColours(listShll)[:1], false) - entries(listData, "Rich Data", m.tabColours(listData)[:1], false) + entries(listShll, "Shell", m.tabColors(listShll)[:1], false) + entries(listData, "Rich Data", m.tabColors(listData)[:1], false) case ui.TabBoth: - entries(listShll, "Shell", m.tabColours(listShll), true) - entries(listData, "Rich Data", m.tabColours(listData), true) + entries(listShll, "Shell", m.tabColors(listShll), true) + entries(listData, "Rich Data", m.tabColors(listData), true) default: - entries(listTabs, "Tab", m.tabColours(listTabs), true) + entries(listTabs, "Tab", m.tabColors(listTabs), true) } return out } -func (m tuiModel) tabColours(list string) []string { +func (m tuiModel) tabColors(list string) []string { var out []string switch list { case listShll: @@ -75,21 +75,21 @@ func (m tuiModel) tabColours(list string) []string { return out } -func (m tuiModel) tabColourAt(list string, i int) string { - set := m.tabColours(list) +func (m tuiModel) tabColorAt(list string, i int) string { + set := m.tabColors(list) if i < 0 || i >= len(set) { return "" } return set[i] } -func (m tuiModel) setTabColour(list string, i int, spec string) (tuiModel, error) { - if err := ui.CheckColour(spec); err != nil { +func (m tuiModel) setTabColor(list string, i int, spec string) (tuiModel, error) { + if err := ui.CheckColor(spec); err != nil { return m, err } - set := slices.Clone(m.tabColours(list)) + set := slices.Clone(m.tabColors(list)) if i < 0 || i >= len(set) { - return m, fmt.Errorf("no tab colour %d", i+1) + return m, fmt.Errorf("no tab color %d", i+1) } set[i] = spec @@ -104,14 +104,14 @@ func (m tuiModel) setTabColour(list string, i int, spec string) (tuiModel, error return m, nil } -func (m tuiModel) openTabColours() (tea.Model, tea.Cmd) { +func (m tuiModel) openTabColors() (tea.Model, tea.Cmd) { m.tabEdit = true m.tabRow = 0 m.status = "" return m, nil } -func (m tuiModel) onTabColoursKey(key string) (tea.Model, tea.Cmd) { +func (m tuiModel) onTabColorsKey(key string) (tea.Model, tea.Cmd) { if m.pick.on { return m.onPickerKey(key) } @@ -156,18 +156,18 @@ func (m tuiModel) onTabColoursKey(key string) (tea.Model, tea.Cmd) { if at.list == "" { return m, nil } - return m.nudgeTabColour(at, false) + return m.nudgeTabColor(at, false) case "R": if at.list == "" { return m, nil } - return m.nudgeTabColour(at, true) + return m.nudgeTabColor(at, true) case "ctrl+d", "x", "backspace": if at.list == "" || at.idx < 0 { return m, nil } - return m.dropTabColour(at) + return m.dropTabColor(at) } return m, nil } @@ -197,12 +197,12 @@ func (m tuiModel) tabModeName() string { } func (m tuiModel) growTabList(list string) (tea.Model, tea.Cmd) { - set := slices.Clone(m.tabColours(list)) - if len(set) >= maxTabColours { - m.status = fmt.Sprintf("%d is as many as a rotation holds", maxTabColours) + set := slices.Clone(m.tabColors(list)) + if len(set) >= maxTabColors { + m.status = fmt.Sprintf("%d is as many as a rotation holds", maxTabColors) return m, nil } - set = append(set, ui.SimilarColour(set[len(set)-1], 1)) + set = append(set, ui.SimilarColor(set[len(set)-1], 1)) m = m.remember(m.themeDraft) m.themeDraft = m.themeDraft.Clone() @@ -217,12 +217,12 @@ func (m tuiModel) growTabList(list string) (tea.Model, tea.Cmd) { return m.saveDraft() } -const maxTabColours = 12 +const maxTabColors = 12 -func (m tuiModel) dropTabColour(at tabTarget) (tea.Model, tea.Cmd) { - set := slices.Clone(m.tabColours(at.list)) +func (m tuiModel) dropTabColor(at tabTarget) (tea.Model, tea.Cmd) { + set := slices.Clone(m.tabColors(at.list)) if len(set) <= 1 { - m.status = "a rotation needs one colour" + m.status = "a rotation needs one color" return m, nil } set = slices.Delete(set, at.idx, at.idx+1) @@ -241,18 +241,18 @@ func (m tuiModel) dropTabColour(at tabTarget) (tea.Model, tea.Cmd) { return m.saveDraft() } -func (m tuiModel) nudgeTabColour(at tabTarget, roll bool) (tea.Model, tea.Cmd) { +func (m tuiModel) nudgeTabColor(at tabTarget, roll bool) (tea.Model, tea.Cmd) { if at.idx < 0 { return m, nil } - cur := m.tabColourAt(at.list, at.idx) - next := ui.SimilarColour(cur, 1) + cur := m.tabColorAt(at.list, at.idx) + next := ui.SimilarColor(cur, 1) if roll || cur == "" { - next = ui.RandomColour(m.draftIsLight()) + next = ui.RandomColor(m.draftIsLight()) } m = m.remember(m.themeDraft) - nm, err := m.setTabColour(at.list, at.idx, next) + nm, err := m.setTabColor(at.list, at.idx, next) if err != nil { m.status = err.Error() return m, nil @@ -260,14 +260,14 @@ func (m tuiModel) nudgeTabColour(at tabTarget, roll bool) (tea.Model, tea.Cmd) { return nm.saveDraft() } -func (m tuiModel) tabColoursContent() []string { +func (m tuiModel) tabColorsContent() []string { if m.pick.on { return m.pickerContent() } mode := m.tabModeName() out := []string{ - ui.StyleCmd.Render("Tab Colours · "+m.themeDraft.Name) + + ui.StyleCmd.Render("Tab Colors · "+m.themeDraft.Name) + ui.StyleDim.Render(" the bar across the top, once something is open"), "", } @@ -289,13 +289,13 @@ func (m tuiModel) tabColoursContent() []string { continue } - spec := m.tabColourAt(at.list, at.idx) + spec := m.tabColorAt(at.list, at.idx) shown := spec if shown == "" { shown = "unset" } out = append(out, cursor+ui.Label(at.label)+" "+ - ui.ColourStyle(spec).Render("████")+" "+ + ui.ColorStyle(spec).Render("████")+" "+ ui.StyleElement.Render(shown)) } diff --git a/internal/tui/tabcolours_test.go b/internal/tui/tabcolors_test.go similarity index 83% rename from internal/tui/tabcolours_test.go rename to internal/tui/tabcolors_test.go index 737e5e9..7fc0866 100644 --- a/internal/tui/tabcolours_test.go +++ b/internal/tui/tabcolors_test.go @@ -13,12 +13,12 @@ func tabPageOn(t *testing.T, theme string) tuiModel { m := editorOn(t, theme) m.themeRow = slices.Index(editRows(), tabsRow) if m.themeRow < 0 { - t.Fatal("there is no tab colours row in the editor") + t.Fatal("there is no tab colors row in the editor") } next, _ := m.onSettingsKey("enter") m = next.(tuiModel) if !m.tabEdit { - t.Fatal("enter did not open the tab colours page") + t.Fatal("enter did not open the tab colors page") } return m } @@ -27,7 +27,7 @@ func TestTheTabPageListsTheRowsItsModeNeeds(t *testing.T) { m := tabPageOn(t, "nord") out := plain(m.viewSettings()) - for _, want := range []string{"Tab Colours · nord", "Mode", "rotate", "Tab 1", "+ Another Tab"} { + for _, want := range []string{"Tab Colors · nord", "Mode", "rotate", "Tab 1", "+ Another Tab"} { if !strings.Contains(out, want) { t.Errorf("the page does not show %q:\n%s", want, out) } @@ -81,10 +81,10 @@ func TestTheRotationGrowsAndShrinks(t *testing.T) { next, _ := m.onSettingsKey("enter") m = next.(tuiModel) if got := len(m.themeDraft.Tabs); got != before+1 { - t.Fatalf("the rotation has %d colours, want %d", got, before+1) + t.Fatalf("the rotation has %d colors, want %d", got, before+1) } if last := m.themeDraft.Tabs[len(m.themeDraft.Tabs)-1]; last == "" { - t.Error("the new entry is unset, which renders as no colour") + t.Error("the new entry is unset, which renders as no color") } if at := m.tabTargets()[m.tabRow]; at.idx != before { t.Errorf("the cursor is on %+v, want the row that appeared", at) @@ -93,7 +93,7 @@ func TestTheRotationGrowsAndShrinks(t *testing.T) { next, _ = m.onSettingsKey("ctrl+d") m = next.(tuiModel) if got := len(m.themeDraft.Tabs); got != before { - t.Errorf("the rotation has %d colours after a drop, want %d", got, before) + t.Errorf("the rotation has %d colors after a drop, want %d", got, before) } for range before + 2 { @@ -102,14 +102,14 @@ func TestTheRotationGrowsAndShrinks(t *testing.T) { m = next.(tuiModel) } if got := len(m.themeDraft.Tabs); got != 1 { - t.Errorf("the rotation has %d colours, want it stopped at one", got) + t.Errorf("the rotation has %d colors, want it stopped at one", got) } - if !strings.Contains(m.status, "needs one colour") { + if !strings.Contains(m.status, "needs one color") { t.Errorf("status = %q, want it to say why", m.status) } } -func TestSettingAShellColourMaterialisesTheList(t *testing.T) { +func TestSettingAShellColorMaterialisesTheList(t *testing.T) { m := tabPageOn(t, "nord") next, _ := m.onSettingsKey("right") m = next.(tuiModel) @@ -121,18 +121,18 @@ func TestSettingAShellColourMaterialisesTheList(t *testing.T) { next, _ = m.onSettingsKey("R") m = next.(tuiModel) if len(m.themeDraft.TabShell) == 0 { - t.Fatal("rolling the shell colour did not write the list") + t.Fatal("rolling the shell color did not write the list") } if m.themeDraft.TabShell[0] == "" { - t.Error("the shell colour is unset") + t.Error("the shell color is unset") } reloaded, _ := ui.ThemeByName("nord") if !slices.Equal(reloaded.Tabs, m.themeDraft.Tabs) { - t.Error("setting a shell colour changed the rotation") + t.Error("setting a shell color changed the rotation") } } -func TestATabColourIsPickedWithTheSamePicker(t *testing.T) { +func TestATabColorIsPickedWithTheSamePicker(t *testing.T) { m := tabPageOn(t, "nord") m.tabRow = 1 before := m.themeDraft.Tabs[0] @@ -143,7 +143,7 @@ func TestATabColourIsPickedWithTheSamePicker(t *testing.T) { t.Fatal("enter did not open the picker") } if m.pick.list != listTabs || m.pick.idx != 0 { - t.Fatalf("the picker is on %+v, want the first tab colour", m.pick) + t.Fatalf("the picker is on %+v, want the first tab color", m.pick) } if out := plain(m.viewSettings()); !strings.Contains(out, "Picking Tab 1") { t.Errorf("the picker does not name what it is on:\n%s", out) @@ -154,7 +154,7 @@ func TestATabColourIsPickedWithTheSamePicker(t *testing.T) { m = next.(tuiModel) } if m.themeDraft.Tabs[0] == before { - t.Error("moving on the disc did not change the tab colour") + t.Error("moving on the disc did not change the tab color") } next, _ = m.onSettingsKey("enter") m = next.(tuiModel) @@ -213,7 +213,7 @@ func TestThePreviewBarShowsTwoOfEachKind(t *testing.T) { } } -func TestRenderTheTabColoursPage(t *testing.T) { +func TestRenderTheTabColorsPage(t *testing.T) { m := tabPageOn(t, "Ben's Theme") t.Log("\n" + m.viewSettings()) for range 2 { diff --git a/internal/tui/termcolor.go b/internal/tui/termcolor.go new file mode 100644 index 0000000..1e900ac --- /dev/null +++ b/internal/tui/termcolor.go @@ -0,0 +1,99 @@ +package tui + +import ( + "fmt" + "image/color" + "io" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/ui" +) + +type termColorState struct { + bg, fg color.Color + hexBg string + hexFg string + probed bool + applied bool +} + +var termColor termColorState + +const colorProbeWait = 120 * time.Millisecond + +type colorProbeDoneMsg struct{} + +func probeTermColor() tea.Cmd { + return tea.Batch( + tea.RequestBackgroundColor, + tea.RequestForegroundColor, + tea.Tick(colorProbeWait, func(time.Time) tea.Msg { return colorProbeDoneMsg{} }), + ) +} + +func rememberTermColor(bg, fg color.Color) { + if termColor.applied { + return + } + if h := hexOfColor(bg); h != "" { + termColor.bg, termColor.hexBg = bg, h + } + if h := hexOfColor(fg); h != "" { + termColor.fg, termColor.hexFg = fg, h + } + if termColor.hexBg != "" && termColor.hexFg != "" { + termColor.probed = true + } +} + +func hexOfColor(c color.Color) string { + if c == nil { + return "" + } + r, g, b, _ := c.RGBA() + return ui.HexFromRGB([3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}) +} + +func frameColors() (bg, fg color.Color) { + if !termColor.probed { + return nil, nil + } + bg, fg = ui.Background(), ui.Foreground() + if bg == nil { + bg = termColor.bg + } + if fg == nil { + fg = termColor.fg + } + if bg != nil || fg != nil { + termColor.applied = true + } + return bg, fg +} + +func restoreTermColor(w io.Writer) { + if !termColor.applied { + return + } + if termColor.hexBg != "" { + fmt.Fprintf(w, "\x1b]11;%s\x07", termColor.hexBg) + } + if termColor.hexFg != "" { + fmt.Fprintf(w, "\x1b]10;%s\x07", termColor.hexFg) + } +} + +func termColorLine() string { + switch { + case !termColor.probed: + return "asking the terminal" + case termColor.hexBg == "" && termColor.hexFg == "": + return "terminal did not answer; iontk cannot put its colors back" + case termColor.hexFg == "": + return termColor.hexBg + " bg, no fg answer" + case termColor.hexBg == "": + return termColor.hexFg + " fg, no bg answer" + } + return termColor.hexBg + " bg · " + termColor.hexFg + " fg" +} diff --git a/internal/tui/termcolour.go b/internal/tui/termcolour.go deleted file mode 100644 index 15c2e92..0000000 --- a/internal/tui/termcolour.go +++ /dev/null @@ -1,99 +0,0 @@ -package tui - -import ( - "fmt" - "image/color" - "io" - "time" - - tea "charm.land/bubbletea/v2" - "tyrtop.com/iontk/internal/ui" -) - -type termColourState struct { - bg, fg color.Color - hexBg string - hexFg string - probed bool - applied bool -} - -var termColour termColourState - -const colourProbeWait = 120 * time.Millisecond - -type colourProbeDoneMsg struct{} - -func probeTermColour() tea.Cmd { - return tea.Batch( - tea.RequestBackgroundColor, - tea.RequestForegroundColor, - tea.Tick(colourProbeWait, func(time.Time) tea.Msg { return colourProbeDoneMsg{} }), - ) -} - -func rememberTermColour(bg, fg color.Color) { - if termColour.applied { - return - } - if h := hexOfColour(bg); h != "" { - termColour.bg, termColour.hexBg = bg, h - } - if h := hexOfColour(fg); h != "" { - termColour.fg, termColour.hexFg = fg, h - } - if termColour.hexBg != "" && termColour.hexFg != "" { - termColour.probed = true - } -} - -func hexOfColour(c color.Color) string { - if c == nil { - return "" - } - r, g, b, _ := c.RGBA() - return ui.HexFromRGB([3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}) -} - -func frameColours() (bg, fg color.Color) { - if !termColour.probed { - return nil, nil - } - bg, fg = ui.Background(), ui.Foreground() - if bg == nil { - bg = termColour.bg - } - if fg == nil { - fg = termColour.fg - } - if bg != nil || fg != nil { - termColour.applied = true - } - return bg, fg -} - -func restoreTermColour(w io.Writer) { - if !termColour.applied { - return - } - if termColour.hexBg != "" { - fmt.Fprintf(w, "\x1b]11;%s\x07", termColour.hexBg) - } - if termColour.hexFg != "" { - fmt.Fprintf(w, "\x1b]10;%s\x07", termColour.hexFg) - } -} - -func termColourLine() string { - switch { - case !termColour.probed: - return "asking the terminal" - case termColour.hexBg == "" && termColour.hexFg == "": - return "terminal did not answer; iontk cannot put its colours back" - case termColour.hexFg == "": - return termColour.hexBg + " bg, no fg answer" - case termColour.hexBg == "": - return termColour.hexFg + " fg, no bg answer" - } - return termColour.hexBg + " bg · " + termColour.hexFg + " fg" -} diff --git a/internal/tui/themeedit.go b/internal/tui/themeedit.go index 53c34e0..70ac80d 100644 --- a/internal/tui/themeedit.go +++ b/internal/tui/themeedit.go @@ -55,7 +55,7 @@ func (m tuiModel) remember(before ui.Theme) tuiModel { func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { if m.tabEdit { - return m.onTabColoursKey(key) + return m.onTabColorsKey(key) } if m.pick.on { return m.onPickerKey(key) @@ -93,7 +93,7 @@ func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { case spinnerRole: return m.stepRole(role, 1) case tabsRow: - return m.openTabColours() + return m.openTabColors() case animRow: m.status = "animations are not wired up yet" return m, nil @@ -127,7 +127,7 @@ func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { if role == spinnerRole || role == animRow || role == tabsRow || isAction(role) { return m, nil } - return m.openColourForm(role) + return m.openColorForm(role) case "ctrl+d", "x": if isAction(role) || role == animRow || role == tabsRow { @@ -150,11 +150,11 @@ func (m tuiModel) randomiseDraft() (tea.Model, tea.Cmd) { case "other": _ = m.themeDraft.SetRole(role, fg) default: - _ = m.themeDraft.SetRole(role, ui.RandomColour(onLight)) + _ = m.themeDraft.SetRole(role, ui.RandomColor(onLight)) } } for i := range m.themeDraft.Tabs { - m.themeDraft.Tabs[i] = ui.RandomColour(onLight) + m.themeDraft.Tabs[i] = ui.RandomColor(onLight) } m.themeDraft.Spinner = ui.RandomSpinner() return m.saveDraft() @@ -166,7 +166,7 @@ func (m tuiModel) randomiseRole(role string) (tea.Model, tea.Cmd) { m.themeDraft.Spinner = ui.RandomSpinner() return m.saveDraft() } - if err := m.themeDraft.SetRole(role, ui.RandomColour(m.draftIsLight())); err != nil { + if err := m.themeDraft.SetRole(role, ui.RandomColor(m.draftIsLight())); err != nil { m.status = err.Error() return m, nil } @@ -181,14 +181,14 @@ func (m tuiModel) nudgeDraft() (tea.Model, tea.Cmd) { if cur == "" { continue } - next := ui.SimilarColour(cur, 1) + next := ui.SimilarColor(cur, 1) if role == "background" { next = ui.SimilarBackground(cur, 1) } _ = m.themeDraft.SetRole(role, next) } for i, c := range m.themeDraft.Tabs { - m.themeDraft.Tabs[i] = ui.SimilarColour(c, 1) + m.themeDraft.Tabs[i] = ui.SimilarColor(c, 1) } return m.saveDraft() } @@ -203,7 +203,7 @@ func (m tuiModel) nudgeRole(role string) (tea.Model, tea.Cmd) { if cur == "" { return m.randomiseRole(role) } - next := ui.SimilarColour(cur, 1) + next := ui.SimilarColor(cur, 1) if role == "background" { next = ui.SimilarBackground(cur, 1) } @@ -274,14 +274,14 @@ func (m tuiModel) resetRole(role string) (tea.Model, tea.Cmd) { return m.saveDraft() } -func (m tuiModel) openColourForm(role string) (tea.Model, tea.Cmd) { +func (m tuiModel) openColorForm(role string) (tea.Model, tea.Cmd) { f := newField(role, "#89b4fa or 0-255", false) f.input.SetValue(m.themeDraft.Role(role)) m.formThemeRole = role - return m.openForm(formColour, "Colour for "+role, phaseAddUser, []formField{f}) + return m.openForm(formColor, "Color for "+role, phaseAddUser, []formField{f}) } -func (m tuiModel) submitColour() (tea.Model, tea.Cmd) { +func (m tuiModel) submitColor() (tea.Model, tea.Cmd) { m = m.remember(m.themeDraft) if err := m.themeDraft.SetRole(m.formThemeRole, m.formValue(0)); err != nil { m.status = err.Error() @@ -320,7 +320,7 @@ func (m tuiModel) saveDraft() (tea.Model, tea.Cmd) { func (m tuiModel) themeEditContent() []string { if m.tabEdit { - return m.tabColoursContent() + return m.tabColorsContent() } if m.pick.on { return m.pickerContent() @@ -350,8 +350,8 @@ func (m tuiModel) themeEditContent() []string { mode := m.tabModeName() out = append(out, cursor+ui.Label(role)+ ui.StyleElement.Render(fmt.Sprintf("%-8s", mode))+ - ui.StyleDim.Render(fmt.Sprintf("%d colour(s) · enter opens them", - len(m.tabColours(listTabs))))) + ui.StyleDim.Render(fmt.Sprintf("%d color(s) · enter opens them", + len(m.tabColors(listTabs))))) continue } @@ -387,7 +387,7 @@ func (m tuiModel) actionRow(row string) string { switch row { case randomRow: return ui.StyleCmd.Render(row) + - ui.StyleDim.Render(" every colour, the tabs and the spinner") + ui.StyleDim.Render(" every color, the tabs and the spinner") case similarRow: return ui.StyleCmd.Render(row) + ui.StyleDim.Render(" the same scheme, shifted") @@ -410,6 +410,6 @@ var roleHelp = map[string]string{ "border": "a modal's frame", "divider": "the rule between panes", "background": "the terminal's own background", - "other": "text no role has coloured", + "other": "text no role has colored", "spinner_color": "every spinner", } diff --git a/internal/tui/themeedit_test.go b/internal/tui/themeedit_test.go index ff99e39..c6e833b 100644 --- a/internal/tui/themeedit_test.go +++ b/internal/tui/themeedit_test.go @@ -81,37 +81,37 @@ func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { } } -func TestTypingAColourIsValidated(t *testing.T) { +func TestTypingAColorIsValidated(t *testing.T) { m := editorOn(t, "nord") next, _ := m.onSettingsKey("down") m = next.(tuiModel) next, _ = m.onSettingsKey("t") m = next.(tuiModel) - if m.formKind != formColour { - t.Fatalf("formKind = %v, want the colour form", m.formKind) + if m.formKind != formColor { + t.Fatalf("formKind = %v, want the color form", m.formKind) } m.form[0].input.SetValue("chartreuse") - next, _ = m.submitColour() + next, _ = m.submitColor() m = next.(tuiModel) - if m.formKind != formColour { - t.Error("the form closed on a colour that will not parse") + if m.formKind != formColor { + t.Error("the form closed on a color that will not parse") } if m.status == "" { - t.Error("nothing said why the colour was refused") + t.Error("nothing said why the color was refused") } m.form[0].input.SetValue("#ff00aa") - next, _ = m.submitColour() + next, _ = m.submitColor() m = next.(tuiModel) if m.formKind != formNone { - t.Errorf("the form stayed open on a good colour: %s", m.status) + t.Errorf("the form stayed open on a good color: %s", m.status) } if got := m.themeDraft.Err; got != "#ff00aa" { - t.Errorf("err = %q, want the typed colour", got) + t.Errorf("err = %q, want the typed color", got) } if got := ui.CurrentTheme().Err; got != "#ff00aa" { - t.Errorf("CurrentTheme().Err = %q, want the typed colour applied", got) + t.Errorf("CurrentTheme().Err = %q, want the typed color applied", got) } } @@ -175,7 +175,7 @@ func TestTheEditorNamesEveryRoleAndPreviewsTheDraft(t *testing.T) { t.Errorf("sectionRows() = %d while editing, want 0", got) } if !strings.Contains(out, "enter pick") { - t.Errorf("the hint does not say how to change a colour:\n%s", out) + t.Errorf("the hint does not say how to change a color:\n%s", out) } next, _ := m.onSettingsKey("esc") @@ -317,7 +317,7 @@ func TestUndoWalksBackOneChangeAtATime(t *testing.T) { } } -func TestPickingAColourIsOneUndoStep(t *testing.T) { +func TestPickingAColorIsOneUndoStep(t *testing.T) { m := pickerOn(t, "nord", "cmd") before := m.themeDraft.Cmd @@ -341,7 +341,7 @@ func TestPickingAColourIsOneUndoStep(t *testing.T) { } } -func TestRandomizeRollsEveryColourAndKeepsItReadable(t *testing.T) { +func TestRandomizeRollsEveryColorAndKeepsItReadable(t *testing.T) { m := editorOn(t, "classic") m.themeRow = slices.Index(editRoles(), randomRow) @@ -379,7 +379,7 @@ func TestRandomizeRollsEveryColourAndKeepsItReadable(t *testing.T) { } } -func TestSimilarShiftsEveryColourWithoutLosingTheScheme(t *testing.T) { +func TestSimilarShiftsEveryColorWithoutLosingTheScheme(t *testing.T) { m := editorOn(t, "Ben's Theme") start := m.themeDraft m.themeRow = slices.Index(editRoles(), similarRow) @@ -400,7 +400,7 @@ func TestSimilarShiftsEveryColourWithoutLosingTheScheme(t *testing.T) { d = -d } if d > 60 && d < 300 { - t.Errorf("%s moved %.0f degrees of hue, which is a different colour", role, d) + t.Errorf("%s moved %.0f degrees of hue, which is a different color", role, d) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 167b91c..b7ec755 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -51,8 +51,10 @@ const ( phaseSignedIn phaseDiscover phaseTenantPick + phaseWarming phaseData phaseMacros + phaseZone ) type pickMode int @@ -120,8 +122,7 @@ const ( formGeometry formFavourite formMacro - formZone - formColour + formColor formNewTheme ) @@ -182,6 +183,8 @@ type tuiModel struct { setCursor int tokenDead string authMode authMode + browserAuthDone bool + warming int tenantIssues map[string]string runAs map[string]string usersTSG string @@ -230,7 +233,7 @@ type tuiModel struct { switchUndo []config.Tenant tabEdit bool tabRow int - pick colourPick + pick colorPick spinTick int formThemeRole string typedZone string @@ -263,6 +266,11 @@ type tuiModel struct { listValues [][]string cmdInput textinput.Model + cmdPick suggest + + zoneInput textinput.Model + zonePick suggest + zoneReturn tuiPhase events <-chan sase.Event ran []string @@ -354,7 +362,7 @@ func Run(ctx context.Context) error { model := newTUIModel(ctx, store, cfg) defer model.sessions.CloseAll() // bubbletea's own reset is OSC 111, which not every terminal implements - defer restoreTermColour(os.Stdout) + defer restoreTermColor(os.Stdout) _, err = tea.NewProgram(model, tea.WithContext(ctx)).Run() if err != nil && !errors.Is(err, tea.ErrProgramKilled) { @@ -508,7 +516,7 @@ func (m tuiModel) Init() tea.Cmd { cmds = cmds[:1] } } - cmds = append(cmds, probeTermColour()) + cmds = append(cmds, probeTermColor()) return tea.Batch(cmds...) } @@ -590,15 +598,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.BackgroundColorMsg: - rememberTermColour(msg.Color, nil) + rememberTermColor(msg.Color, nil) return m, nil case tea.ForegroundColorMsg: - rememberTermColour(nil, msg.Color) + rememberTermColor(nil, msg.Color) return m, nil - case colourProbeDoneMsg: - termColour.probed = true + case colorProbeDoneMsg: + termColor.probed = true return m, nil case tea.MouseWheelMsg: @@ -653,7 +661,10 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.signInResume = true - return m.startSignIn() + return m.startSignInAuto() + + case tenantsWarmedMsg: + return m.onTenantsWarmed(msg) case discoveredMsg: if msg.err != nil { @@ -662,6 +673,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.ident = msg.d.Identity + m.browserAuthDone = true if msg.d.RefreshToken != "" { if err := m.store.Secrets.Set(config.AccountRefreshTokenKey(), msg.d.RefreshToken); err != nil { m.status = err.Error() @@ -689,7 +701,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if errors.Is(msg.err, saseauth.ErrInteractiveRequired) { m.tokenDead = "sign-in expired" m.signInResume = false - return m.startSignIn() + return m.startSignInAuto() } m.status = "token refresh failed: " + msg.err.Error() return m, nil @@ -712,6 +724,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.client = msg.client m.pool.set(m.tenant.TSGID, msg.client) + m.browserAuthDone = true m.status = "" m.tokenDead = "" watch := watchToken(msg.client.TokenErrors()) @@ -756,7 +769,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if app.IsAuthFailure(string(msg)) { m.tokenDead = "sign-in expired" m.signInResume = true - return m.startSignIn() + return m.startSignInAuto() } m.status = string(msg) m.cacheState = cacheFailed @@ -944,7 +957,7 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd m.form[m.formCursor].input, cmd = m.form[m.formCursor].input.Update(msg) - return m, cmd + return m.syncMacroSuggestions(), cmd case phasePicking: if m.filterOn { @@ -965,6 +978,12 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd m.cmdInput, cmd = m.cmdInput.Update(msg) + return m.refilterCommands(lastSegment(m.cmdInput.Value())), cmd + + case phaseZone: + var cmd tea.Cmd + m.zoneInput, cmd = m.zoneInput.Update(msg) + m.zonePick = m.zonePick.refilter(clock.Zones(), m.zoneInput.Value()) return m, cmd case phaseMacros: @@ -997,7 +1016,7 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { func (m tuiModel) wantsSpinner() bool { switch m.phase { - case phaseLoading, phaseRunning, phaseSettings, phaseSignIn, phaseDiscover: + case phaseLoading, phaseRunning, phaseSettings, phaseSignIn, phaseDiscover, phaseWarming: return true case phasePicking: return m.cacheState == cacheUpdating @@ -1402,6 +1421,8 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.onDataKey(key) case phaseMacros: return m.onMacroKey(msg, key) + case phaseZone: + return m.onZoneKey(msg, key) case phaseMenu: return m.onMenuKey(key) case phaseSwitch: @@ -1415,6 +1436,8 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case phaseTenantPick: return m.onTenantPickKey(key) + case phaseWarming: + return m, nil case phaseDiscover: if key == "esc" || key == "q" { m.status = "cancelled" @@ -1469,7 +1492,7 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd if key == "q" && m.filterOn { break } - if m.filterOn || m.filter.Value() != "" { + if m.filterOn { m.filterOn = false m.filter.SetValue("") m.filter.Blur() @@ -1729,6 +1752,7 @@ func (m tuiModel) onPickTargets(targets []sessionTarget) (tea.Model, tea.Cmd) { m.status = "" m.phase = phaseCommand + m.cmdPick = newSuggest(m.catalog(), lastSegment(m.cmdInput.Value())) return m, m.cmdInput.Focus() } @@ -1738,7 +1762,16 @@ func (m tuiModel) onCommandKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. m.cmdInput.Blur() m.phase = phasePicking return m, nil + case "ctrl+n", "down": + m.cmdPick = m.cmdPick.move(1) + return m, nil + case "ctrl+p", "up": + m.cmdPick = m.cmdPick.move(-1) + return m, nil case "tab": + if pick, ok := m.cmdPick.current(); ok { + return m.insertBatchCommand(pick), nil + } if n := len(m.tenant.Users); n > 1 { for i, u := range m.tenant.Users { if u.Name == m.ionUser { @@ -1759,6 +1792,7 @@ func (m tuiModel) onCommandKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. } var cmd tea.Cmd m.cmdInput, cmd = m.cmdInput.Update(msg) + m = m.refilterCommands(lastSegment(m.cmdInput.Value())) return m, cmd } @@ -1901,6 +1935,8 @@ func (m tuiModel) View() tea.View { v = tea.NewView(m.viewDiscover()) case phaseTenantPick: v = tea.NewView(m.viewTenantPick()) + case phaseWarming: + v = tea.NewView(m.viewWarming()) case phaseSignIn: v = tea.NewView(m.viewSignIn()) case phaseSignedIn: @@ -1912,6 +1948,8 @@ func (m tuiModel) View() tea.View { v = tea.NewView(m.viewPicking()) case phaseCommand: v = tea.NewView(m.viewCommand()) + case phaseZone: + v = tea.NewView(m.viewZonePick()) case phaseRunning: v = tea.NewView(m.viewRunning()) case phaseResults: @@ -1934,7 +1972,7 @@ func (m tuiModel) View() tea.View { v.Content = ui.ClipFrame(v.Content, m.height) v.AltScreen = true v.MouseMode = m.mouseMode() - v.BackgroundColor, v.ForegroundColor = frameColours() + v.BackgroundColor, v.ForegroundColor = frameColors() return v } @@ -1987,7 +2025,7 @@ func (m tuiModel) viewPicking() string { if len(m.lastCleared) > 0 { clear = "alt+z undo clear" } - back := "menu" + back := "back" if m.listReturn() == phaseMacros { back = "macros" } @@ -2029,6 +2067,7 @@ func (m tuiModel) viewCommand() string { b.WriteString("\n\n") b.WriteString(m.cmdInput.View() + "\n\n") + b.WriteString(m.cmdSuggestBlock() + "\n") b.WriteString(ui.StyleDim.Render("comma separated · read-only commands only") + "\n") if m.status != "" { b.WriteString(ui.StyleErr.Render(m.status) + "\n") diff --git a/internal/tui/tuimenu.go b/internal/tui/tuimenu.go index f1b7fe6..ec95b3a 100644 --- a/internal/tui/tuimenu.go +++ b/internal/tui/tuimenu.go @@ -318,6 +318,21 @@ func (m tuiModel) openPasswordForm(t config.Tenant, user string) (tea.Model, tea } func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd) { + if m.formKind == formMacro && m.formCursor >= 1 { + switch key { + case "ctrl+n": + m.cmdPick = m.cmdPick.move(1) + return m, nil + case "ctrl+p": + m.cmdPick = m.cmdPick.move(-1) + return m, nil + case "tab": + if pick, ok := m.cmdPick.current(); ok { + return m.insertMacroCommand(pick), nil + } + } + } + switch key { case "esc": m.form = nil @@ -334,12 +349,12 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd } m.form[m.formCursor].input.Blur() m.formCursor = (m.formCursor + 1) % len(m.form) - return m, m.form[m.formCursor].input.Focus() + return m.syncMacroSuggestions(), m.form[m.formCursor].input.Focus() case "shift+tab", "up": m.form[m.formCursor].input.Blur() m.formCursor = (m.formCursor - 1 + len(m.form)) % len(m.form) - return m, m.form[m.formCursor].input.Focus() + return m.syncMacroSuggestions(), m.form[m.formCursor].input.Focus() case "enter": return m.submitForm() @@ -352,7 +367,7 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd var cmd tea.Cmd m.form[m.formCursor].input, cmd = m.form[m.formCursor].input.Update(msg) - return m, cmd + return m.syncMacroSuggestions(), cmd } func (m tuiModel) formValue(i int) string { @@ -551,11 +566,8 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { m.phase = m.formReturn return m, nil - case formZone: - return m.submitZone() - - case formColour: - return m.submitColour() + case formColor: + return m.submitColor() case formNewTheme: return m.submitNewTheme() @@ -764,6 +776,7 @@ func (m tuiModel) viewForm() string { if m.formKind == formMacro { b.WriteString(" " + strings.Repeat(" ", 16) + ui.StyleGhost.Render("+ another command") + "\n") + b.WriteString("\n" + m.cmdSuggestBlock()) } if w := m.plaintextWarning(); w != "" { @@ -975,6 +988,21 @@ func (m tuiModel) moveUserSecret(tsg, from, to string) { } } +func (m tuiModel) startSignInAuto() (tea.Model, tea.Cmd) { + why := m.tenantIssues[m.tenant.TSGID] + if !m.browserAuthDone || why == "" { + return m.startSignIn() + } + m.signInResume = false + m.cacheState = cacheFailed + m.status = m.tenantName(m.tenant.TSGID) + ": " + why + + " — already signed in, so another browser sign-in will not help" + if m.phase == phaseLoading || m.phase == phaseSignIn { + m.phase = phaseMenu + } + return m, nil +} + func (m tuiModel) startSignIn() (tea.Model, tea.Cmd) { if !m.hasTenant { m.status = "no tenant to sign in to" @@ -1042,7 +1070,7 @@ func (m tuiModel) viewSignedIn() string { } b.WriteString("\n") - b.WriteString(ui.Footer(m.status, "enter continue · esc menu")) + b.WriteString(ui.Footer(m.status, "enter continue · esc back")) return b.String() } diff --git a/internal/tui/zonepick.go b/internal/tui/zonepick.go new file mode 100644 index 0000000..1a87271 --- /dev/null +++ b/internal/tui/zonepick.go @@ -0,0 +1,90 @@ +package tui + +import ( + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/clock" + "tyrtop.com/iontk/internal/ui" +) + +func newZoneInput() textinput.Model { + in := textinput.New() + in.Prompt = "/" + in.Placeholder = "chicago · london · utc · local" + in.SetWidth(40) + return in +} + +func (m tuiModel) openZonePick() (tea.Model, tea.Cmd) { + m.zoneInput = newZoneInput() + m.zonePick = newPicker(clock.Zones(), "") + m.zoneReturn = m.phase + m.phase = phaseZone + m.status = "" + return m, m.zoneInput.Focus() +} + +func (m tuiModel) closeZonePick() tuiModel { + m.zoneInput.Blur() + m.phase = m.zoneReturn + return m +} + +func (m tuiModel) onZoneKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd) { + switch key { + case "esc": + m = m.closeZonePick() + m.status = "" + return m, nil + + case "up", "ctrl+p": + m.zonePick = m.zonePick.move(-1) + return m, nil + case "down", "ctrl+n": + m.zonePick = m.zonePick.move(1) + return m, nil + case "pgup": + m.zonePick = m.zonePick.move(-suggestRows) + return m, nil + case "pgdown": + m.zonePick = m.zonePick.move(suggestRows) + return m, nil + + case "enter": + spec, ok := m.zonePick.current() + if !ok { + spec = strings.TrimSpace(m.zoneInput.Value()) + } + nm, applied := m.applyZone(spec) + if !applied { + return nm, nil + } + return nm.closeZonePick(), nil + } + + var cmd tea.Cmd + m.zoneInput, cmd = m.zoneInput.Update(msg) + m.zonePick = m.zonePick.refilter(clock.Zones(), m.zoneInput.Value()) + return m, cmd +} + +func (m tuiModel) viewZonePick() string { + var b strings.Builder + b.WriteString(ui.StyleCmd.Render("Zone for Printed Times") + "\n\n") + b.WriteString(m.zoneInput.View() + " " + m.zonePick.count("zones") + "\n\n") + b.WriteString(m.zonePick.view(m.width, suggestRows)) + b.WriteString("\n") + + line := ui.StyleDim.Render("fuzzy search · every stamp names its zone") + if spec, ok := m.zonePick.current(); ok { + if stamp, err := clock.StampIn(spec, time.Now()); err == nil { + line = ui.StyleDim.Render("now ") + ui.StyleElement.Render(stamp) + } + } + b.WriteString(line + "\n") + + return b.String() + ui.Footer(m.status, "↑↓ move · enter use · esc back") +} diff --git a/internal/ui/colour.go b/internal/ui/color.go similarity index 98% rename from internal/ui/colour.go rename to internal/ui/color.go index bb3db15..b8700de 100644 --- a/internal/ui/colour.go +++ b/internal/ui/color.go @@ -238,7 +238,7 @@ func GridCell(spec string) (row, col int, ok bool) { return 0, 0, false } -func RandomColour(onLight bool) string { +func RandomColor(onLight bool) string { h := rand.Float64() * 360 s := 0.45 + rand.Float64()*0.5 v := 0.62 + rand.Float64()*0.38 @@ -260,7 +260,7 @@ func RandomBackground() (bg, fg string, onLight bool) { HexFromHSV(h, 0.05+rand.Float64()*0.15, 0.82+rand.Float64()*0.14), false } -func SimilarColour(spec string, spread float64) string { +func SimilarColor(spec string, spread float64) string { c, ok := RGBOf(spec) if !ok { return spec diff --git a/internal/ui/colour_test.go b/internal/ui/color_test.go similarity index 95% rename from internal/ui/colour_test.go rename to internal/ui/color_test.go index ae45be0..391fcc8 100644 --- a/internal/ui/colour_test.go +++ b/internal/ui/color_test.go @@ -104,11 +104,11 @@ func TestTheWheelIsADisc(t *testing.T) { dim := WheelAt(cx+2, cy, cols, rows, 0.3) bright := WheelAt(cx+2, cy, cols, rows, 1) if dim == bright { - t.Error("the brightness did not change the colour") + t.Error("the brightness did not change the color") } } -func TestWheelCellFindsTheColourItWasGiven(t *testing.T) { +func TestWheelCellFindsTheColorItWasGiven(t *testing.T) { const cols, rows = 22, 11 for _, spec := range []string{"#ff0000", "#00ff00", "#0000ff", "#89b4fa"} { x, y, v := WheelCell(spec, cols, rows) @@ -138,7 +138,7 @@ func TestTheChartHoldsEveryIndexOnce(t *testing.T) { } } if len(seen) != 256 { - t.Errorf("the chart holds %d colours, want 256", len(seen)) + t.Errorf("the chart holds %d colors, want 256", len(seen)) } row, col, ok := GridCell("111") diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 5b7d7b8..2e4e979 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -146,8 +146,8 @@ const ( func TabModes() []string { return []string{TabRotate, TabByType, TabBoth, TabShared} } var TabModeBlurb = map[string]string{ - TabRotate: "a colour per position in the bar", - TabByType: "one colour for shells, one for data", + TabRotate: "a color per position in the bar", + TabByType: "one color for shells, one for data", TabBoth: "a rotation of its own per kind", TabShared: "one rotation, counted within the kind", } @@ -163,7 +163,7 @@ func TabFallback(kind TabKind) int { return 0 } -func (t Theme) TabColours(kind TabKind) []string { +func (t Theme) TabColors(kind TabKind) []string { switch kind { case TabShell: if len(t.TabShell) > 0 { @@ -179,7 +179,7 @@ func (t Theme) TabColours(kind TabKind) []string { var fallback = Theme{ Name: "classic", - Blurb: "your terminal's own 16 colours", + Blurb: "your terminal's own 16 colors", OK: "2", Err: "1", Warn: "3", Cmd: "6", Element: "4", Row: "5", Hot: "9", Border: "3", Divider: "8", Spinner: DefaultSpinner, SpinnerColor: "5", @@ -331,22 +331,22 @@ func ParseThemes(b []byte) ([]Theme, error) { return set.Themes, nil } -// lipgloss renders an unparseable colour as nothing, not as wrong +// lipgloss renders an unparseable color as nothing, not as wrong func (t Theme) validate() error { if strings.TrimSpace(t.Name) == "" { return fmt.Errorf("a theme needs a name") } for _, role := range RoleNames() { - if err := checkColour(t.Role(role)); err != nil { + if err := checkColor(t.Role(role)); err != nil { return fmt.Errorf("theme %s: %s: %w", t.Name, role, err) } } for _, set := range []struct { - what string - colours []string + what string + colors []string }{{"tab", t.Tabs}, {"tab_shell", t.TabShell}, {"tab_data", t.TabData}} { - for i, c := range set.colours { - if err := checkColour(c); err != nil { + for i, c := range set.colors { + if err := checkColor(c); err != nil { return fmt.Errorf("theme %s: %s %d: %w", t.Name, set.what, i+1, err) } } @@ -362,9 +362,9 @@ func (t Theme) validate() error { return nil } -func CheckColour(spec string) error { return checkColour(spec) } +func CheckColor(spec string) error { return checkColor(spec) } -func checkColour(spec string) error { +func checkColor(spec string) error { switch { case spec == "": return nil @@ -373,7 +373,7 @@ func checkColour(spec string) error { return fmt.Errorf("%q is not #rgb or #rrggbb", spec) } if _, err := strconv.ParseUint(spec[1:], 16, 32); err != nil { - return fmt.Errorf("%q is not a hex colour", spec) + return fmt.Errorf("%q is not a hex color", spec) } return nil } @@ -580,11 +580,11 @@ func atTab(set []lipgloss.Style, i int) lipgloss.Style { func BorderColor() color.Color { return styles.Border } -func Background() color.Color { return terminalColour(current.Bg) } +func Background() color.Color { return terminalColor(current.Bg) } -func Foreground() color.Color { return terminalColour(current.Fg) } +func Foreground() color.Color { return terminalColor(current.Fg) } -func terminalColour(spec string) color.Color { +func terminalColor(spec string) color.Color { c, ok := RGBOf(spec) if !ok { return nil @@ -607,11 +607,11 @@ func ProfileName() string { case colorprofile.TrueColor: return "truecolor" case colorprofile.ANSI256: - return "256 colours" + return "256 colors" case colorprofile.ANSI: - return "16 colours" + return "16 colors" case colorprofile.ASCII, colorprofile.NoTTY: - return "no colour" + return "no color" } return "" } @@ -639,7 +639,7 @@ var roleLabels = map[string]string{ "ok": "OK", "err": "Err", "warn": "Warn", "cmd": "Cmd", "element": "Element", "row": "Row", "hot": "Hot", "border": "Border", "divider": "Divider", "other": "Other", "background": "Background", - "spinner_color": "Spinner Colour", + "spinner_color": "Spinner Color", } func RoleLabel(role string) string { @@ -681,7 +681,7 @@ func (t Theme) Role(name string) string { func (t *Theme) SetRole(name, spec string) error { spec = strings.TrimSpace(spec) - if err := checkColour(spec); err != nil { + if err := checkColor(spec); err != nil { return err } switch name { @@ -710,7 +710,7 @@ func (t *Theme) SetRole(name, spec string) error { case "spinner_color": t.SpinnerColor = spec default: - return fmt.Errorf("no colour called %q", name) + return fmt.Errorf("no color called %q", name) } return nil } @@ -797,7 +797,7 @@ func SaveUserTheme(path string, t Theme) error { return fmt.Errorf("encode themes: %w", err) } header := "# Written by iontk's theme editor. A scheme here replaces the built-in\n" + - "# one of the same name; anything not set falls back to no colour.\n" + "# one of the same name; anything not set falls back to no color.\n" tmp := path + ".tmp" if err := os.WriteFile(tmp, append([]byte(header), b...), 0o600); err != nil { return fmt.Errorf("write %s: %w", tmp, err) @@ -841,7 +841,7 @@ func (t Theme) Equal(o Theme) bool { return true } -func ColourStyle(spec string) lipgloss.Style { +func ColorStyle(spec string) lipgloss.Style { if spec == "" { return lipgloss.NewStyle() } diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index 0f129a0..52dd7df 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -78,7 +78,7 @@ func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { continue } if len(th.Tabs) < 4 { - t.Errorf("%s has %d tab colours, want enough to tell tabs apart", th.Name, len(th.Tabs)) + t.Errorf("%s has %d tab colors, want enough to tell tabs apart", th.Name, len(th.Tabs)) } status := map[string]string{th.OK: "ok", th.Err: "err", th.Warn: "warn"} for _, c := range th.Tabs { @@ -86,7 +86,7 @@ func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { t.Errorf("%s uses %s (%s) for a tab, which carries status meaning", th.Name, c, name) } if role, clash := status[c]; clash { - t.Errorf("%s uses its %s colour (%s) for a tab", th.Name, role, c) + t.Errorf("%s uses its %s color (%s) for a tab", th.Name, role, c) } } } @@ -155,11 +155,11 @@ func TestABrokenThemeFileIsRefusedAndTheBuiltinsSurvive(t *testing.T) { yaml string want string }{ - {"a colour that is not one", "themes:\n - name: x\n ok: \"blue\"\n", "ANSI index"}, + {"a color that is not one", "themes:\n - name: x\n ok: \"blue\"\n", "ANSI index"}, {"an index past 255", "themes:\n - name: x\n ok: \"300\"\n", "ANSI index"}, - {"half a hex colour", "themes:\n - name: x\n ok: \"#12345\"\n", "#rrggbb"}, + {"half a hex color", "themes:\n - name: x\n ok: \"#12345\"\n", "#rrggbb"}, {"a spinner nobody has", "themes:\n - name: x\n spinner: wheel\n", "spinner"}, - {"a misspelled key", "themes:\n - name: x\n colour: \"2\"\n", "field colour"}, + {"a misspelled key", "themes:\n - name: x\n color: \"2\"\n", "field color"}, {"a theme with no name", "themes:\n - blurb: nameless\n", "needs a name"}, } for _, tc := range tests { @@ -221,7 +221,7 @@ func TestApplyRestylesTheMarks(t *testing.T) { mono, _ := ThemeByName("mono") Apply(mono) if got := MarkCheck.String(); strings.Contains(got, "[3") { - t.Errorf("MarkCheck = %q, want no colour under mono", got) + t.Errorf("MarkCheck = %q, want no color under mono", got) } nord, _ := ThemeByName("nord") Apply(nord) @@ -230,7 +230,7 @@ func TestApplyRestylesTheMarks(t *testing.T) { } } -func TestMonoKeepsRolesApartWithoutColour(t *testing.T) { +func TestMonoKeepsRolesApartWithoutColor(t *testing.T) { mono, _ := ThemeByName("mono") s := mono.Styles() if s.Err.Render("x") == s.Warn.Render("x") { @@ -262,12 +262,12 @@ func TestThemeNoteOnlyWarnsWhenItMatters(t *testing.T) { SetProfile(colorprofile.ANSI256) if got := ThemeNote(nord); got == "" { - t.Error("ThemeNote() says nothing about nord on a 256-colour terminal") + t.Error("ThemeNote() says nothing about nord on a 256-color terminal") } if got := ThemeNote(Default()); got != "" { t.Errorf("ThemeNote() = %q for a 0-15 scheme, which needs no warning", got) } - if got := ProfileName(); got != "256 colours" { + if got := ProfileName(); got != "256 colors" { t.Errorf("ProfileName() = %q", got) } } @@ -281,7 +281,7 @@ func TestStyleTabMarksTheActiveOne(t *testing.T) { StyleTab(TabShell, len(styles.Tabs)+3, len(styles.Tabs)+3, false) } -func TestTheTabModesColourDifferently(t *testing.T) { +func TestTheTabModesColorDifferently(t *testing.T) { restore(t) base := Theme{ Name: "tabs", OK: "2", @@ -400,8 +400,8 @@ func TestEveryShippedSchemeSetsABackgroundExceptTwo(t *testing.T) { loaded, loadErr = nil, "" bare := map[string]string{ - "classic": "the default, and the terminal's own sixteen colours", - "mono": "no colour at all is the whole premise", + "classic": "the default, and the terminal's own sixteen colors", + "mono": "no color at all is the whole premise", } for _, th := range Themes() { @@ -413,7 +413,7 @@ func TestEveryShippedSchemeSetsABackgroundExceptTwo(t *testing.T) { case th.Bg == "": t.Errorf("%s sets no bg, so choosing it leaves the terminal unchanged", th.Name) case th.Fg == "": - t.Errorf("%s sets a bg but no fg, so uncoloured text keeps the old colour", th.Name) + t.Errorf("%s sets a bg but no fg, so uncolored text keeps the old color", th.Name) } for _, role := range []string{"background", "other"} { @@ -545,7 +545,7 @@ func TestOneBadFileLeavesTheRestLoaded(t *testing.T) { t.Error("the good scheme was lost with the bad one") } if _, ok := ThemeByName("bad"); ok { - t.Error("the scheme with an unparseable colour was loaded anyway") + t.Error("the scheme with an unparseable color was loaded anyway") } if ThemesErr() == "" { t.Error("nothing is on the settings page about it") diff --git a/internal/ui/themes.yaml b/internal/ui/themes.yaml index acdcbf1..6acca35 100644 --- a/internal/ui/themes.yaml +++ b/internal/ui/themes.yaml @@ -1,5 +1,5 @@ -# Colour schemes. A theme is a table of roles, not of widgets: nothing in iontk -# asks for "blue", it asks for the colour an element name is drawn in. +# Color schemes. A theme is a table of roles, not of widgets: nothing in iontk +# asks for "blue", it asks for the color an element name is drawn in. # # A themes.yaml in the config root is appended to these, and a scheme of the # same name replaces the built-in one; --themes points somewhere else, which is @@ -17,37 +17,37 @@ # divider the rule between two panes # bg the terminal's own background, set while iontk runs and put back # when it exits; unset leaves the terminal as the user has it -# fg the default text colour, for anything no role has coloured +# fg the default text color, for anything no role has colored # spinner the spinner's shape, one of the names iontk ships # spinner_color what it is drawn in, including in the bubbles widget -# tabs the rotation tabs take their colour from; none of them may be a -# status colour, since a tab is identity rather than state -# tab_mode how that rotation is read, which the theme editor's "tab colours" +# tabs the rotation tabs take their color from; none of them may be a +# status color, since a tab is identity rather than state +# tab_mode how that rotation is read, which the theme editor's "tab colors" # page sets. One of: -# rotate a colour per position in the bar (the default, and +# rotate a color per position in the bar (the default, and # what iontk has always done) -# type one colour for shells, one for rich-data tabs +# type one color for shells, one for rich-data tabs # both a rotation of its own per kind # shared one rotation, counted within the kind -- so the first -# shell and the first data tab are the same colour +# shell and the first data tab are the same color # tab_shell, tab_data # the per-kind rotations the type and both modes draw from. Unset # borrows the rotation, offset by kind so the two do not start on -# the same colour. The element list's tab is always the first -# colour of the rotation, in every mode -# mono true drops colour entirely for bold, faint, italic and reverse +# the same color. The element list's tab is always the first +# color of the rotation, in every mode +# mono true drops color entirely for bold, faint, italic and reverse # # bg and fg go out as OSC 11 and 10, which only take a hex: an index written # there is resolved to xterm's value for it, not the terminal's own. # # Every scheme here sets a background except **classic** and **mono**, and # those two leave it out on purpose: classic is the default and is the -# terminal's own sixteen colours, so it must not repaint the terminal of -# someone who never chose a scheme, and mono's whole premise is no colour at +# terminal's own sixteen colors, so it must not repaint the terminal of +# someone who never chose a scheme, and mono's whole premise is no color at # all. Everything else carries the background its palette was drawn for -- the # blurbs were already describing one. # -# A colour is an ANSI index 0-255 or a #rrggbb. An index under 16 is remapped by +# A color is an ANSI index 0-255 or a #rrggbb. An index under 16 is remapped by # the terminal's own theme, which is why classic stays there; anything else is # emitted as asked and downsampled by the renderer to what the terminal has. # "dim" is not in the table: it is the faint attribute, and a scheme that made @@ -55,7 +55,7 @@ themes: - name: classic - blurb: your terminal's own 16 colours + blurb: your terminal's own 16 colors ok: "2" err: "1" warn: "3" @@ -87,7 +87,7 @@ themes: tabs: ["15", "12", "14", "13", "4", "6", "7"] - name: mono - blurb: "no colour: bold, faint, reverse" + blurb: "no color: bold, faint, reverse" spinner: line mono: true @@ -109,7 +109,7 @@ themes: tabs: ["#d8dee9", "#81a1c1", "#88c0d0", "#b48ead", "#5e81ac", "#8fbcbb", "#eceff4"] # The six below are after vim's own schemes rather than ports of them: vim - # colours syntax groups and iontk colours roles, so what carries over is the + # colors syntax groups and iontk colors roles, so what carries over is the # palette and the mood, not a mapping. - name: desert blurb: wheat and khaki on warm grey, after vim's desert @@ -216,7 +216,7 @@ themes: # catppuccin mocha, lifted from the palette the user's own nvim loads # (lemo-o/nvim -> catppuccin-mocha). Roles follow catppuccin's own use of it: - # green/red/yellow for state, blue for the "doing" colour, mauve for the + # green/red/yellow for state, blue for the "doing" color, mauve for the # selection, peach for the one that means "about to go". - name: "Ben's Theme" blurb: catppuccin mocha, from Ben's nvim