From 024132e8a49aba761cc99cbbf1c8148e8157150f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 14 May 2026 11:44:05 +0700 Subject: [PATCH] feat: add theme_mode, theme_light, theme_dark to UserAppearanceSettings (#25076) Part 1: Backend portion of a change broken into 2 PRs. Part 2: #25077 Adds three new UserAppearanceSettings fields (theme_mode, theme_light, theme_dark) on top of the existing theme_preference and terminal_font. Replaces GetUserThemePreference and GetUserTerminalFont with a single GetUserAppearanceSettings aggregate query. The PUT handler is wrapped in db.InTx so sync-mode's mode + slot writes can never half-apply. --- coderd/apidoc/docs.go | 61 +++ coderd/apidoc/swagger.json | 50 +++ coderd/database/dbauthz/dbauthz.go | 66 ++-- coderd/database/dbauthz/dbauthz_test.go | 43 ++- coderd/database/dbmetrics/querymetrics.go | 48 ++- coderd/database/dbmock/dbmock.go | 90 +++-- coderd/database/querier.go | 6 +- coderd/database/queries.sql.go | 156 ++++++-- coderd/database/queries/users.sql | 68 +++- coderd/users.go | 146 ++++--- coderd/users_test.go | 359 ++++++++++++++++++ codersdk/users.go | 48 ++- docs/reference/api/schemas.md | 50 ++- docs/reference/api/users.md | 9 + site/site.go | 43 +-- site/site_test.go | 66 ++++ site/src/api/queries/users.ts | 3 + site/src/api/typesGenerated.ts | 44 +++ .../AppearancePage/AppearanceForm.stories.tsx | 8 +- .../AppearancePage/AppearanceForm.tsx | 6 + .../AppearancePage/AppearancePage.test.tsx | 56 ++- .../AppearancePage/AppearancePage.tsx | 3 + site/src/testHelpers/entities.ts | 3 + 23 files changed, 1209 insertions(+), 223 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b0a0f2b45e..f75ffe1bbc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -23089,6 +23089,19 @@ const docTemplate = `{ "TerminalFontJetBrainsMono" ] }, + "codersdk.ThemeMode": { + "type": "string", + "enum": [ + "", + "sync", + "single" + ], + "x-enum-varnames": [ + "ThemeModeUnset", + "ThemeModeSync", + "ThemeModeSingle" + ] + }, "codersdk.ThinkingDisplayMode": { "type": "string", "enum": [ @@ -23420,6 +23433,42 @@ const docTemplate = `{ "terminal_font": { "$ref": "#/definitions/codersdk.TerminalFontName" }, + "theme_dark": { + "description": "ThemeDark is required when ThemeMode is \"sync\". In \"single\" mode\nan empty value means \"preserve the previously persisted slot\"\nrather than \"clear the slot\", so partial updates that send only\none slot keep the other intact.", + "type": "string", + "enum": [ + "light", + "light-protan-deuter", + "light-tritan", + "dark", + "dark-protan-deuter", + "dark-tritan" + ] + }, + "theme_light": { + "description": "ThemeLight is required when ThemeMode is \"sync\". In \"single\"\nmode an empty value means \"preserve the previously persisted\nslot\" rather than \"clear the slot\", so partial updates that send\nonly one slot keep the other intact.", + "type": "string", + "enum": [ + "light", + "light-protan-deuter", + "light-tritan", + "dark", + "dark-protan-deuter", + "dark-tritan" + ] + }, + "theme_mode": { + "description": "ThemeMode is optional for backward compatibility. When empty,\nthe server leaves theme_mode, theme_light, and theme_dark\nunchanged so older CLI clients do not erase sync-mode settings.\nLegacy auto preferences are the exception: they clear theme_mode\nso clients can migrate the old sync-with-system setting.", + "enum": [ + "sync", + "single" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ThemeMode" + } + ] + }, "theme_preference": { "type": "string" } @@ -23842,7 +23891,19 @@ const docTemplate = `{ "terminal_font": { "$ref": "#/definitions/codersdk.TerminalFontName" }, + "theme_dark": { + "description": "Ignored when ThemeMode is \"single\"", + "type": "string" + }, + "theme_light": { + "description": "Ignored when ThemeMode is \"single\"", + "type": "string" + }, + "theme_mode": { + "$ref": "#/definitions/codersdk.ThemeMode" + }, "theme_preference": { + "description": "ThemePreference is the legacy single-field appearance setting. In\n\"single\" mode it mirrors the active theme. In \"sync\" mode modern\nclients normally mirror the active OS slot, but older clients can\nupdate only this field, so it may diverge from ThemeLight or\nThemeDark until a modern client saves the full appearance state\nagain.", "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9dbe821356..be824246a8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -21238,6 +21238,11 @@ "TerminalFontJetBrainsMono" ] }, + "codersdk.ThemeMode": { + "type": "string", + "enum": ["", "sync", "single"], + "x-enum-varnames": ["ThemeModeUnset", "ThemeModeSync", "ThemeModeSingle"] + }, "codersdk.ThinkingDisplayMode": { "type": "string", "enum": ["auto", "preview", "always_expanded", "always_collapsed"], @@ -21559,6 +21564,39 @@ "terminal_font": { "$ref": "#/definitions/codersdk.TerminalFontName" }, + "theme_dark": { + "description": "ThemeDark is required when ThemeMode is \"sync\". In \"single\" mode\nan empty value means \"preserve the previously persisted slot\"\nrather than \"clear the slot\", so partial updates that send only\none slot keep the other intact.", + "type": "string", + "enum": [ + "light", + "light-protan-deuter", + "light-tritan", + "dark", + "dark-protan-deuter", + "dark-tritan" + ] + }, + "theme_light": { + "description": "ThemeLight is required when ThemeMode is \"sync\". In \"single\"\nmode an empty value means \"preserve the previously persisted\nslot\" rather than \"clear the slot\", so partial updates that send\nonly one slot keep the other intact.", + "type": "string", + "enum": [ + "light", + "light-protan-deuter", + "light-tritan", + "dark", + "dark-protan-deuter", + "dark-tritan" + ] + }, + "theme_mode": { + "description": "ThemeMode is optional for backward compatibility. When empty,\nthe server leaves theme_mode, theme_light, and theme_dark\nunchanged so older CLI clients do not erase sync-mode settings.\nLegacy auto preferences are the exception: they clear theme_mode\nso clients can migrate the old sync-with-system setting.", + "enum": ["sync", "single"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ThemeMode" + } + ] + }, "theme_preference": { "type": "string" } @@ -21950,7 +21988,19 @@ "terminal_font": { "$ref": "#/definitions/codersdk.TerminalFontName" }, + "theme_dark": { + "description": "Ignored when ThemeMode is \"single\"", + "type": "string" + }, + "theme_light": { + "description": "Ignored when ThemeMode is \"single\"", + "type": "string" + }, + "theme_mode": { + "$ref": "#/definitions/codersdk.ThemeMode" + }, "theme_preference": { + "description": "ThemePreference is the legacy single-field appearance setting. In\n\"single\" mode it mirrors the active theme. In \"sync\" mode modern\nclients normally mirror the active OS slot, but older clients can\nupdate only this field, so it may diverge from ThemeLight or\nThemeDark until a modern client saves the full appearance state\nagain.", "type": "string" } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ee43dc228b..83c391068b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4357,6 +4357,17 @@ func (q *querier) GetUserAgentChatSendShortcut(ctx context.Context, userID uuid. return q.db.GetUserAgentChatSendShortcut(ctx, userID) } +func (q *querier) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (database.GetUserAppearanceSettingsRow, error) { + u, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return database.GetUserAppearanceSettingsRow{}, err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { + return database.GetUserAppearanceSettingsRow{}, err + } + return q.db.GetUserAppearanceSettings(ctx, userID) +} + func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg) } @@ -4544,28 +4555,6 @@ func (q *querier) GetUserTaskNotificationAlertDismissed(ctx context.Context, use return q.db.GetUserTaskNotificationAlertDismissed(ctx, userID) } -func (q *querier) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { - u, err := q.db.GetUserByID(ctx, userID) - if err != nil { - return "", err - } - if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { - return "", err - } - return q.db.GetUserTerminalFont(ctx, userID) -} - -func (q *querier) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) { - u, err := q.db.GetUserByID(ctx, userID) - if err != nil { - return "", err - } - if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { - return "", err - } - return q.db.GetUserThemePreference(ctx, userID) -} - func (q *querier) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { user, err := q.db.GetUserByID(ctx, userID) if err != nil { @@ -7244,6 +7233,39 @@ func (q *querier) UpdateUserTerminalFont(ctx context.Context, arg database.Updat return q.db.UpdateUserTerminalFont(ctx, arg) } +func (q *querier) UpdateUserThemeDark(ctx context.Context, arg database.UpdateUserThemeDarkParams) (database.UserConfig, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.UserConfig{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { + return database.UserConfig{}, err + } + return q.db.UpdateUserThemeDark(ctx, arg) +} + +func (q *querier) UpdateUserThemeLight(ctx context.Context, arg database.UpdateUserThemeLightParams) (database.UserConfig, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.UserConfig{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { + return database.UserConfig{}, err + } + return q.db.UpdateUserThemeLight(ctx, arg) +} + +func (q *querier) UpdateUserThemeMode(ctx context.Context, arg database.UpdateUserThemeModeParams) (database.UserConfig, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.UserConfig{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { + return database.UserConfig{}, err + } + return q.db.UpdateUserThemeMode(ctx, arg) +} + func (q *querier) UpdateUserThemePreference(ctx context.Context, arg database.UpdateUserThemePreferenceParams) (database.UserConfig, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8226234644..11fdb3e47a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2701,11 +2701,18 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().GetUserWorkspaceBuildParameters(gomock.Any(), arg).Return([]database.GetUserWorkspaceBuildParametersRow{}, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionReadPersonal).Returns([]database.GetUserWorkspaceBuildParametersRow{}) })) - s.Run("GetUserThemePreference", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("GetUserAppearanceSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) + settings := database.GetUserAppearanceSettingsRow{ + ThemePreference: "dark", + ThemeMode: "sync", + ThemeLight: "light", + ThemeDark: "dark", + TerminalFont: "geist-mono", + } dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() - dbm.EXPECT().GetUserThemePreference(gomock.Any(), u.ID).Return("light", nil).AnyTimes() - check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("light") + dbm.EXPECT().GetUserAppearanceSettings(gomock.Any(), u.ID).Return(settings, nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns(settings) })) s.Run("UpdateUserThemePreference", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) @@ -2715,12 +2722,6 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserThemePreference(gomock.Any(), arg).Return(uc, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) })) - s.Run("GetUserTerminalFont", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - u := testutil.Fake(s.T(), faker, database.User{}) - dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() - dbm.EXPECT().GetUserTerminalFont(gomock.Any(), u.ID).Return("ibm-plex-mono", nil).AnyTimes() - check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("ibm-plex-mono") - })) s.Run("UpdateUserTerminalFont", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) uc := database.UserConfig{UserID: u.ID, Key: "terminal_font", Value: "ibm-plex-mono"} @@ -2729,6 +2730,30 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserTerminalFont(gomock.Any(), arg).Return(uc, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) })) + s.Run("UpdateUserThemeMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + uc := database.UserConfig{UserID: u.ID, Key: "theme_mode", Value: "sync"} + arg := database.UpdateUserThemeModeParams{UserID: u.ID, ThemeMode: uc.Value} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserThemeMode(gomock.Any(), arg).Return(uc, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) + })) + s.Run("UpdateUserThemeLight", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + uc := database.UserConfig{UserID: u.ID, Key: "theme_light", Value: "light"} + arg := database.UpdateUserThemeLightParams{UserID: u.ID, ThemeLight: uc.Value} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserThemeLight(gomock.Any(), arg).Return(uc, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) + })) + s.Run("UpdateUserThemeDark", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + uc := database.UserConfig{UserID: u.ID, Key: "theme_dark", Value: "dark"} + arg := database.UpdateUserThemeDarkParams{UserID: u.ID, ThemeDark: uc.Value} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserThemeDark(gomock.Any(), arg).Return(uc, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) + })) s.Run("GetUserTaskNotificationAlertDismissed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 95697f97fa..12191ac07e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2801,6 +2801,14 @@ func (m queryMetricsStore) GetUserAgentChatSendShortcut(ctx context.Context, use return r0, r1 } +func (m queryMetricsStore) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (database.GetUserAppearanceSettingsRow, error) { + start := time.Now() + r0, r1 := m.s.GetUserAppearanceSettings(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserAppearanceSettings").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAppearanceSettings").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { start := time.Now() r0, r1 := m.s.GetUserByEmailOrUsername(ctx, arg) @@ -2969,22 +2977,6 @@ func (m queryMetricsStore) GetUserTaskNotificationAlertDismissed(ctx context.Con return r0, r1 } -func (m queryMetricsStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { - start := time.Now() - r0, r1 := m.s.GetUserTerminalFont(ctx, userID) - m.queryLatencies.WithLabelValues("GetUserTerminalFont").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserTerminalFont").Inc() - return r0, r1 -} - -func (m queryMetricsStore) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) { - start := time.Now() - r0, r1 := m.s.GetUserThemePreference(ctx, userID) - m.queryLatencies.WithLabelValues("GetUserThemePreference").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserThemePreference").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { start := time.Now() r0, r1 := m.s.GetUserThinkingDisplayMode(ctx, userID) @@ -5177,6 +5169,30 @@ func (m queryMetricsStore) UpdateUserTerminalFont(ctx context.Context, arg datab return r0, r1 } +func (m queryMetricsStore) UpdateUserThemeDark(ctx context.Context, arg database.UpdateUserThemeDarkParams) (database.UserConfig, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserThemeDark(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserThemeDark").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserThemeDark").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateUserThemeLight(ctx context.Context, arg database.UpdateUserThemeLightParams) (database.UserConfig, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserThemeLight(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserThemeLight").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserThemeLight").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateUserThemeMode(ctx context.Context, arg database.UpdateUserThemeModeParams) (database.UserConfig, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserThemeMode(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserThemeMode").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserThemeMode").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateUserThemePreference(ctx context.Context, arg database.UpdateUserThemePreferenceParams) (database.UserConfig, error) { start := time.Now() r0, r1 := m.s.UpdateUserThemePreference(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index dab6b4edc3..1eab730283 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -5238,6 +5238,21 @@ func (mr *MockStoreMockRecorder) GetUserAgentChatSendShortcut(ctx, userID any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAgentChatSendShortcut", reflect.TypeOf((*MockStore)(nil).GetUserAgentChatSendShortcut), ctx, userID) } +// GetUserAppearanceSettings mocks base method. +func (m *MockStore) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (database.GetUserAppearanceSettingsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAppearanceSettings", ctx, userID) + ret0, _ := ret[0].(database.GetUserAppearanceSettingsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserAppearanceSettings indicates an expected call of GetUserAppearanceSettings. +func (mr *MockStoreMockRecorder) GetUserAppearanceSettings(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAppearanceSettings", reflect.TypeOf((*MockStore)(nil).GetUserAppearanceSettings), ctx, userID) +} + // GetUserByEmailOrUsername mocks base method. func (m *MockStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { m.ctrl.T.Helper() @@ -5553,36 +5568,6 @@ func (mr *MockStoreMockRecorder) GetUserTaskNotificationAlertDismissed(ctx, user return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTaskNotificationAlertDismissed", reflect.TypeOf((*MockStore)(nil).GetUserTaskNotificationAlertDismissed), ctx, userID) } -// GetUserTerminalFont mocks base method. -func (m *MockStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserTerminalFont", ctx, userID) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUserTerminalFont indicates an expected call of GetUserTerminalFont. -func (mr *MockStoreMockRecorder) GetUserTerminalFont(ctx, userID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTerminalFont", reflect.TypeOf((*MockStore)(nil).GetUserTerminalFont), ctx, userID) -} - -// GetUserThemePreference mocks base method. -func (m *MockStore) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserThemePreference", ctx, userID) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUserThemePreference indicates an expected call of GetUserThemePreference. -func (mr *MockStoreMockRecorder) GetUserThemePreference(ctx, userID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserThemePreference", reflect.TypeOf((*MockStore)(nil).GetUserThemePreference), ctx, userID) -} - // GetUserThinkingDisplayMode mocks base method. func (m *MockStore) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { m.ctrl.T.Helper() @@ -9753,6 +9738,51 @@ func (mr *MockStoreMockRecorder) UpdateUserTerminalFont(ctx, arg any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserTerminalFont", reflect.TypeOf((*MockStore)(nil).UpdateUserTerminalFont), ctx, arg) } +// UpdateUserThemeDark mocks base method. +func (m *MockStore) UpdateUserThemeDark(ctx context.Context, arg database.UpdateUserThemeDarkParams) (database.UserConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserThemeDark", ctx, arg) + ret0, _ := ret[0].(database.UserConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserThemeDark indicates an expected call of UpdateUserThemeDark. +func (mr *MockStoreMockRecorder) UpdateUserThemeDark(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserThemeDark", reflect.TypeOf((*MockStore)(nil).UpdateUserThemeDark), ctx, arg) +} + +// UpdateUserThemeLight mocks base method. +func (m *MockStore) UpdateUserThemeLight(ctx context.Context, arg database.UpdateUserThemeLightParams) (database.UserConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserThemeLight", ctx, arg) + ret0, _ := ret[0].(database.UserConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserThemeLight indicates an expected call of UpdateUserThemeLight. +func (mr *MockStoreMockRecorder) UpdateUserThemeLight(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserThemeLight", reflect.TypeOf((*MockStore)(nil).UpdateUserThemeLight), ctx, arg) +} + +// UpdateUserThemeMode mocks base method. +func (m *MockStore) UpdateUserThemeMode(ctx context.Context, arg database.UpdateUserThemeModeParams) (database.UserConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserThemeMode", ctx, arg) + ret0, _ := ret[0].(database.UserConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserThemeMode indicates an expected call of UpdateUserThemeMode. +func (mr *MockStoreMockRecorder) UpdateUserThemeMode(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserThemeMode", reflect.TypeOf((*MockStore)(nil).UpdateUserThemeMode), ctx, arg) +} + // UpdateUserThemePreference mocks base method. func (m *MockStore) UpdateUserThemePreference(ctx context.Context, arg database.UpdateUserThemePreferenceParams) (database.UserConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 67cbadc88f..cb6b243f5c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -700,6 +700,7 @@ type sqlcQuerier interface { // simultaneously. GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) GetUserAgentChatSendShortcut(ctx context.Context, userID uuid.UUID) (string, error) + GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (GetUserAppearanceSettingsRow, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error) @@ -762,8 +763,6 @@ type sqlcQuerier interface { // The time range is inclusively defined by the start_time and end_time parameters. GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) - GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) - GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. @@ -1217,6 +1216,9 @@ type sqlcQuerier interface { UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) + UpdateUserThemeDark(ctx context.Context, arg UpdateUserThemeDarkParams) (UserConfig, error) + UpdateUserThemeLight(ctx context.Context, arg UpdateUserThemeLightParams) (UserConfig, error) + UpdateUserThemeMode(ctx context.Context, arg UpdateUserThemeModeParams) (UserConfig, error) UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) UpdateUserThinkingDisplayMode(ctx context.Context, arg UpdateUserThinkingDisplayModeParams) (string, error) UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b4f8402abf..8befc53e36 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25659,6 +25659,47 @@ func (q *sqlQuerier) GetUserAgentChatSendShortcut(ctx context.Context, userID uu return agent_chat_send_shortcut, err } +const getUserAppearanceSettings = `-- name: GetUserAppearanceSettings :one +SELECT + COALESCE(MAX(value) FILTER (WHERE key = 'theme_preference'), '')::text AS theme_preference, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_mode'), '')::text AS theme_mode, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_light'), '')::text AS theme_light, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_dark'), '')::text AS theme_dark, + COALESCE(MAX(value) FILTER (WHERE key = 'terminal_font'), '')::text AS terminal_font +FROM + user_configs +WHERE + user_id = $1 + AND key IN ( + 'theme_preference', + 'theme_mode', + 'theme_light', + 'theme_dark', + 'terminal_font' + ) +` + +type GetUserAppearanceSettingsRow struct { + ThemePreference string `db:"theme_preference" json:"theme_preference"` + ThemeMode string `db:"theme_mode" json:"theme_mode"` + ThemeLight string `db:"theme_light" json:"theme_light"` + ThemeDark string `db:"theme_dark" json:"theme_dark"` + TerminalFont string `db:"terminal_font" json:"terminal_font"` +} + +func (q *sqlQuerier) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (GetUserAppearanceSettingsRow, error) { + row := q.db.QueryRowContext(ctx, getUserAppearanceSettings, userID) + var i GetUserAppearanceSettingsRow + err := row.Scan( + &i.ThemePreference, + &i.ThemeMode, + &i.ThemeLight, + &i.ThemeDark, + &i.TerminalFont, + ) + return i, err +} + const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros @@ -25863,40 +25904,6 @@ func (q *sqlQuerier) GetUserTaskNotificationAlertDismissed(ctx context.Context, return task_notification_alert_dismissed, err } -const getUserTerminalFont = `-- name: GetUserTerminalFont :one -SELECT - value as terminal_font -FROM - user_configs -WHERE - user_id = $1 - AND key = 'terminal_font' -` - -func (q *sqlQuerier) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { - row := q.db.QueryRowContext(ctx, getUserTerminalFont, userID) - var terminal_font string - err := row.Scan(&terminal_font) - return terminal_font, err -} - -const getUserThemePreference = `-- name: GetUserThemePreference :one -SELECT - value as theme_preference -FROM - user_configs -WHERE - user_id = $1 - AND key = 'theme_preference' -` - -func (q *sqlQuerier) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) { - row := q.db.QueryRowContext(ctx, getUserThemePreference, userID) - var theme_preference string - err := row.Scan(&theme_preference) - return theme_preference, err -} - const getUserThinkingDisplayMode = `-- name: GetUserThinkingDisplayMode :one SELECT value AS thinking_display_mode @@ -26902,6 +26909,87 @@ func (q *sqlQuerier) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserT return i, err } +const updateUserThemeDark = `-- name: UpdateUserThemeDark :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'theme_dark', $2) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'theme_dark' +RETURNING user_id, key, value +` + +type UpdateUserThemeDarkParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + ThemeDark string `db:"theme_dark" json:"theme_dark"` +} + +func (q *sqlQuerier) UpdateUserThemeDark(ctx context.Context, arg UpdateUserThemeDarkParams) (UserConfig, error) { + row := q.db.QueryRowContext(ctx, updateUserThemeDark, arg.UserID, arg.ThemeDark) + var i UserConfig + err := row.Scan(&i.UserID, &i.Key, &i.Value) + return i, err +} + +const updateUserThemeLight = `-- name: UpdateUserThemeLight :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'theme_light', $2) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'theme_light' +RETURNING user_id, key, value +` + +type UpdateUserThemeLightParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + ThemeLight string `db:"theme_light" json:"theme_light"` +} + +func (q *sqlQuerier) UpdateUserThemeLight(ctx context.Context, arg UpdateUserThemeLightParams) (UserConfig, error) { + row := q.db.QueryRowContext(ctx, updateUserThemeLight, arg.UserID, arg.ThemeLight) + var i UserConfig + err := row.Scan(&i.UserID, &i.Key, &i.Value) + return i, err +} + +const updateUserThemeMode = `-- name: UpdateUserThemeMode :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'theme_mode', $2) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'theme_mode' +RETURNING user_id, key, value +` + +type UpdateUserThemeModeParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + ThemeMode string `db:"theme_mode" json:"theme_mode"` +} + +func (q *sqlQuerier) UpdateUserThemeMode(ctx context.Context, arg UpdateUserThemeModeParams) (UserConfig, error) { + row := q.db.QueryRowContext(ctx, updateUserThemeMode, arg.UserID, arg.ThemeMode) + var i UserConfig + err := row.Scan(&i.UserID, &i.Key, &i.Value) + return i, err +} + const updateUserThemePreference = `-- name: UpdateUserThemePreference :one INSERT INTO user_configs (user_id, key, value) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 88ab44fc5e..2e93da49ba 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -125,14 +125,24 @@ SET WHERE id = $1; --- name: GetUserThemePreference :one +-- name: GetUserAppearanceSettings :one SELECT - value as theme_preference + COALESCE(MAX(value) FILTER (WHERE key = 'theme_preference'), '')::text AS theme_preference, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_mode'), '')::text AS theme_mode, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_light'), '')::text AS theme_light, + COALESCE(MAX(value) FILTER (WHERE key = 'theme_dark'), '')::text AS theme_dark, + COALESCE(MAX(value) FILTER (WHERE key = 'terminal_font'), '')::text AS terminal_font FROM user_configs WHERE user_id = @user_id - AND key = 'theme_preference'; + AND key IN ( + 'theme_preference', + 'theme_mode', + 'theme_light', + 'theme_dark', + 'terminal_font' + ); -- name: UpdateUserThemePreference :one INSERT INTO @@ -148,15 +158,6 @@ WHERE user_configs.user_id = @user_id AND user_configs.key = 'theme_preference' RETURNING *; --- name: GetUserTerminalFont :one -SELECT - value as terminal_font -FROM - user_configs -WHERE - user_id = @user_id - AND key = 'terminal_font'; - -- name: UpdateUserTerminalFont :one INSERT INTO user_configs (user_id, key, value) @@ -171,6 +172,48 @@ WHERE user_configs.user_id = @user_id AND user_configs.key = 'terminal_font' RETURNING *; +-- name: UpdateUserThemeMode :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'theme_mode', @theme_mode) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = @theme_mode +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'theme_mode' +RETURNING *; + +-- name: UpdateUserThemeLight :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'theme_light', @theme_light) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = @theme_light +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'theme_light' +RETURNING *; + +-- name: UpdateUserThemeDark :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'theme_dark', @theme_dark) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = @theme_dark +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'theme_dark' +RETURNING *; + -- name: GetUserChatCustomPrompt :one SELECT value as chat_custom_prompt @@ -304,7 +347,6 @@ WHERE user_configs.user_id = @user_id AND user_configs.key = 'preference_thinking_display_mode' RETURNING value AS thinking_display_mode; - -- name: GetUserCodeDiffDisplayMode :one SELECT value AS code_diff_display_mode diff --git a/coderd/users.go b/coderd/users.go index 87ea63c49e..839b6c6ab6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1124,35 +1124,38 @@ func (api *API) userAppearanceSettings(rw http.ResponseWriter, r *http.Request) user = httpmw.UserParam(r) ) - themePreference, err := api.Database.GetUserThemePreference(ctx, user.ID) + settings, err := api.Database.GetUserAppearanceSettings(ctx, user.ID) if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error reading user settings.", - Detail: err.Error(), - }) - return - } - - themePreference = "" + writeUserSettingsReadError(ctx, rw, err) + return } - terminalFont, err := api.Database.GetUserTerminalFont(ctx, user.ID) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error reading user settings.", - Detail: err.Error(), - }) - return - } + httpapi.Write(ctx, rw, http.StatusOK, userAppearanceSettingsFromRow(settings)) +} - terminalFont = "" +func userAppearanceSettingsFromRow(settings database.GetUserAppearanceSettingsRow) codersdk.UserAppearanceSettings { + return codersdk.UserAppearanceSettings{ + ThemePreference: settings.ThemePreference, + ThemeMode: codersdk.ThemeMode(settings.ThemeMode), + ThemeLight: settings.ThemeLight, + ThemeDark: settings.ThemeDark, + TerminalFont: codersdk.TerminalFontName(settings.TerminalFont), } +} - httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAppearanceSettings{ - ThemePreference: themePreference, - TerminalFont: codersdk.TerminalFontName(terminalFont), +func isLegacyAutoThemePreference(themePreference string) bool { + switch themePreference { + case "auto", "auto-protan-deuter", "auto-tritan": + return true + default: + return false + } +} + +func writeUserSettingsReadError(ctx context.Context, rw http.ResponseWriter, err error) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading user settings.", + Detail: err.Error(), }) } @@ -1184,34 +1187,89 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques return } - updatedThemePreference, err := api.Database.UpdateUserThemePreference(ctx, database.UpdateUserThemePreferenceParams{ - UserID: user.ID, - ThemePreference: params.ThemePreference, - }) + // theme_mode is optional for backward compatibility. Older CLI + // clients do not know about theme_mode or the sync slots, so an + // omitted mode must leave those fields untouched instead of replacing + // them with single-mode defaults. Legacy auto values are the exception: + // the old UI used them to mean sync-with-system, so clearing theme_mode + // lets modern clients migrate them on read. + themeModeProvided := params.ThemeMode != codersdk.ThemeModeUnset + updateThemeMode := themeModeProvided + isSyncMode := params.ThemeMode == codersdk.ThemeModeSync + isSingleMode := params.ThemeMode == codersdk.ThemeModeSingle + updateThemeLight := isSyncMode || (isSingleMode && params.ThemeLight != "") + updateThemeDark := isSyncMode || (isSingleMode && params.ThemeDark != "") + themeMode := params.ThemeMode + if !updateThemeMode && isLegacyAutoThemePreference(params.ThemePreference) { + updateThemeMode = true + themeMode = codersdk.ThemeModeUnset + } + + var updatedSettings database.GetUserAppearanceSettingsRow + + err := api.Database.InTx(func(tx database.Store) error { + _, err := tx.UpdateUserThemePreference(ctx, database.UpdateUserThemePreferenceParams{ + UserID: user.ID, + ThemePreference: params.ThemePreference, + }) + if err != nil { + return xerrors.Errorf("update user theme preference: %w", err) + } + + if updateThemeMode { + _, err = tx.UpdateUserThemeMode(ctx, database.UpdateUserThemeModeParams{ + UserID: user.ID, + ThemeMode: string(themeMode), + }) + if err != nil { + return xerrors.Errorf("update user theme mode: %w", err) + } + } + + if updateThemeLight { + _, err = tx.UpdateUserThemeLight(ctx, database.UpdateUserThemeLightParams{ + UserID: user.ID, + ThemeLight: params.ThemeLight, + }) + if err != nil { + return xerrors.Errorf("update user theme light: %w", err) + } + } + + if updateThemeDark { + _, err = tx.UpdateUserThemeDark(ctx, database.UpdateUserThemeDarkParams{ + UserID: user.ID, + ThemeDark: params.ThemeDark, + }) + if err != nil { + return xerrors.Errorf("update user theme dark: %w", err) + } + } + + _, err = tx.UpdateUserTerminalFont(ctx, database.UpdateUserTerminalFontParams{ + UserID: user.ID, + TerminalFont: string(params.TerminalFont), + }) + if err != nil { + return xerrors.Errorf("update user terminal font: %w", err) + } + + updatedSettings, err = tx.GetUserAppearanceSettings(ctx, user.ID) + if err != nil { + return xerrors.Errorf("get updated user appearance settings: %w", err) + } + + return nil + }, nil) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating user theme preference.", + Message: "Internal error updating user appearance settings.", Detail: err.Error(), }) return } - updatedTerminalFont, err := api.Database.UpdateUserTerminalFont(ctx, database.UpdateUserTerminalFontParams{ - UserID: user.ID, - TerminalFont: string(params.TerminalFont), - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating user terminal font.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAppearanceSettings{ - ThemePreference: updatedThemePreference.Value, - TerminalFont: codersdk.TerminalFontName(updatedTerminalFont.Value), - }) + httpapi.Write(ctx, rw, http.StatusOK, userAppearanceSettingsFromRow(updatedSettings)) } // @Summary Get user preference settings diff --git a/coderd/users_test.go b/coderd/users_test.go index 16383ead2f..93f3438066 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1818,6 +1818,365 @@ func TestUserTerminalFont(t *testing.T) { }) } +func TestUserThemeMode(t *testing.T) { + t.Parallel() + + adminClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + + t.Run("defaults to empty", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + // A fresh user has never written any theme_* key. The GET handler + // should return empty strings rather than error out. + require.Equal(t, codersdk.ThemeModeUnset, initial.ThemeMode) + require.Equal(t, "", initial.ThemeLight) + require.Equal(t, "", initial.ThemeDark) + }) + + t.Run("sync mode roundtrip", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ThemeModeSync, updated.ThemeMode) + require.Equal(t, "light-tritan", updated.ThemeLight) + require.Equal(t, "dark-tritan", updated.ThemeDark) + + // Fetched values should match. + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, codersdk.ThemeModeSync, fetched.ThemeMode) + require.Equal(t, "light-tritan", fetched.ThemeLight) + require.Equal(t, "dark-tritan", fetched.ThemeDark) + }) + + t.Run("sync mode accepts any concrete theme per slot", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "dark-tritan", + ThemeDark: "light-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ThemeModeSync, updated.ThemeMode) + require.Equal(t, "dark-tritan", updated.ThemeLight) + require.Equal(t, "light-tritan", updated.ThemeDark) + }) + + t.Run("empty theme_mode is accepted for back-compat", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // A concrete legacy preference plus an unset mode is enough for + // modern clients to treat the user as single mode. The server does + // not write the new fields for old clients because doing so would + // erase existing sync settings. + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + require.Equal(t, "dark", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeUnset, updated.ThemeMode) + require.Equal(t, "", updated.ThemeLight) + require.Equal(t, "", updated.ThemeDark) + }) + + t.Run("omitted theme fields preserve sync settings", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark", + TerminalFont: codersdk.TerminalFontFiraCode, + }) + require.NoError(t, err) + require.Equal(t, "dark", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeSync, updated.ThemeMode) + require.Equal(t, "light-tritan", updated.ThemeLight) + require.Equal(t, "dark-tritan", updated.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, updated.TerminalFont) + + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, "dark", fetched.ThemePreference) + require.Equal(t, codersdk.ThemeModeSync, fetched.ThemeMode) + require.Equal(t, "light-tritan", fetched.ThemeLight) + require.Equal(t, "dark-tritan", fetched.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, fetched.TerminalFont) + }) + + t.Run("single mode with omitted slots preserves sync settings", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark", + ThemeMode: codersdk.ThemeModeSingle, + TerminalFont: codersdk.TerminalFontFiraCode, + }) + require.NoError(t, err) + require.Equal(t, "dark", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, updated.ThemeMode) + require.Equal(t, "light-tritan", updated.ThemeLight) + require.Equal(t, "dark-tritan", updated.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, updated.TerminalFont) + + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, "dark", fetched.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, fetched.ThemeMode) + require.Equal(t, "light-tritan", fetched.ThemeLight) + require.Equal(t, "dark-tritan", fetched.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, fetched.TerminalFont) + }) + + t.Run("single mode with explicit slots updates slots", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "light-tritan", + ThemeMode: codersdk.ThemeModeSingle, + ThemeLight: "dark-tritan", + ThemeDark: "light-protan-deuter", + TerminalFont: codersdk.TerminalFontFiraCode, + }) + require.NoError(t, err) + require.Equal(t, "light-tritan", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, updated.ThemeMode) + require.Equal(t, "dark-tritan", updated.ThemeLight) + require.Equal(t, "light-protan-deuter", updated.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, updated.TerminalFont) + + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, "light-tritan", fetched.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, fetched.ThemeMode) + require.Equal(t, "dark-tritan", fetched.ThemeLight) + require.Equal(t, "light-protan-deuter", fetched.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, fetched.TerminalFont) + }) + + t.Run("single mode with one explicit slot updates only that slot", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "light", + ThemeMode: codersdk.ThemeModeSingle, + ThemeLight: "light-protan-deuter", + TerminalFont: codersdk.TerminalFontFiraCode, + }) + require.NoError(t, err) + require.Equal(t, "light", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, updated.ThemeMode) + require.Equal(t, "light-protan-deuter", updated.ThemeLight) + require.Equal(t, "dark-tritan", updated.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, updated.TerminalFont) + + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, "light", fetched.ThemePreference) + require.Equal(t, codersdk.ThemeModeSingle, fetched.ThemeMode) + require.Equal(t, "light-protan-deuter", fetched.ThemeLight) + require.Equal(t, "dark-tritan", fetched.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, fetched.TerminalFont) + }) + + t.Run("legacy auto with omitted theme_mode clears mode", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontGeistMono, + }) + require.NoError(t, err) + + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "auto", + TerminalFont: codersdk.TerminalFontFiraCode, + }) + require.NoError(t, err) + require.Equal(t, "auto", updated.ThemePreference) + require.Equal(t, codersdk.ThemeModeUnset, updated.ThemeMode) + require.Equal(t, "light-tritan", updated.ThemeLight) + require.Equal(t, "dark-tritan", updated.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, updated.TerminalFont) + + fetched, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, "auto", fetched.ThemePreference) + require.Equal(t, codersdk.ThemeModeUnset, fetched.ThemeMode) + require.Equal(t, "light-tritan", fetched.ThemeLight) + require.Equal(t, "dark-tritan", fetched.ThemeDark) + require.Equal(t, codersdk.TerminalFontFiraCode, fetched.TerminalFont) + }) + + t.Run("invalid theme_mode is rejected", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark", + ThemeMode: codersdk.ThemeMode("wizard"), + TerminalFont: codersdk.TerminalFontGeistMono, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("invalid theme slots are rejected", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + for _, tc := range []struct { + name string + themeMode codersdk.ThemeMode + themeLight string + themeDark string + }{ + { + name: "arbitrary light slot", + themeMode: codersdk.ThemeModeSync, + themeLight: "../../etc/passwd", + themeDark: "dark", + }, + { + name: "arbitrary dark slot", + themeMode: codersdk.ThemeModeSync, + themeLight: "light", + themeDark: "xss-payload", + }, + { + name: "empty light slot in sync mode", + themeMode: codersdk.ThemeModeSync, + themeLight: "", + themeDark: "dark", + }, + { + name: "empty dark slot in sync mode", + themeMode: codersdk.ThemeModeSync, + themeLight: "light", + themeDark: "", + }, + { + name: "arbitrary light slot in single mode", + themeMode: codersdk.ThemeModeSingle, + themeLight: "../../etc/passwd", + }, + { + name: "arbitrary dark slot with omitted mode", + themeDark: "xss-payload", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ + ThemePreference: "dark", + ThemeMode: tc.themeMode, + ThemeLight: tc.themeLight, + ThemeDark: tc.themeDark, + TerminalFont: codersdk.TerminalFontGeistMono, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + } + }) +} + func TestUserTaskNotificationAlertDismissed(t *testing.T) { t.Parallel() diff --git a/codersdk/users.go b/codersdk/users.go index 81407739cf..0bb9f3f046 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -252,14 +252,54 @@ const ( TerminalFontJetBrainsMono TerminalFontName = "jetbrains-mono" ) +type ThemeMode string + +const ( + // ThemeModeUnset is the server-side default when the user has never + // set a theme_mode. It is also stored for legacy auto preferences so + // clients can migrate the old sync-with-system setting. Clients should + // inspect ThemePreference for legacy auto values before treating unset + // mode as ThemeModeSingle for backward compatibility with PR #24672. + ThemeModeUnset ThemeMode = "" + ThemeModeSync ThemeMode = "sync" + ThemeModeSingle ThemeMode = "single" +) + type UserAppearanceSettings struct { - ThemePreference string `json:"theme_preference"` - TerminalFont TerminalFontName `json:"terminal_font"` + // ThemePreference is the legacy single-field appearance setting. In + // "single" mode it mirrors the active theme. In "sync" mode modern + // clients normally mirror the active OS slot, but older clients can + // update only this field, so it may diverge from ThemeLight or + // ThemeDark until a modern client saves the full appearance state + // again. + ThemePreference string `json:"theme_preference"` + ThemeMode ThemeMode `json:"theme_mode"` + // Ignored when ThemeMode is "single" + ThemeLight string `json:"theme_light"` + // Ignored when ThemeMode is "single" + ThemeDark string `json:"theme_dark"` + TerminalFont TerminalFontName `json:"terminal_font"` } type UpdateUserAppearanceSettingsRequest struct { - ThemePreference string `json:"theme_preference" validate:"required"` - TerminalFont TerminalFontName `json:"terminal_font" validate:"required"` + ThemePreference string `json:"theme_preference" validate:"required"` + // ThemeMode is optional for backward compatibility. When empty, + // the server leaves theme_mode, theme_light, and theme_dark + // unchanged so older CLI clients do not erase sync-mode settings. + // Legacy auto preferences are the exception: they clear theme_mode + // so clients can migrate the old sync-with-system setting. + ThemeMode ThemeMode `json:"theme_mode" validate:"omitempty,oneof=sync single"` + // ThemeLight is required when ThemeMode is "sync". In "single" + // mode an empty value means "preserve the previously persisted + // slot" rather than "clear the slot", so partial updates that send + // only one slot keep the other intact. + ThemeLight string `json:"theme_light" validate:"required_if=ThemeMode sync,omitempty,oneof=light light-protan-deuter light-tritan dark dark-protan-deuter dark-tritan"` + // ThemeDark is required when ThemeMode is "sync". In "single" mode + // an empty value means "preserve the previously persisted slot" + // rather than "clear the slot", so partial updates that send only + // one slot keep the other intact. + ThemeDark string `json:"theme_dark" validate:"required_if=ThemeMode sync,omitempty,oneof=light light-protan-deuter light-tritan dark dark-protan-deuter dark-tritan"` + TerminalFont TerminalFontName `json:"terminal_font" validate:"required"` } type UserPreferenceSettings struct { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 21f4030928..1e87c428f4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12518,6 +12518,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |-------------------------------------------------------------------------------------| | ``, `fira-code`, `geist-mono`, `ibm-plex-mono`, `jetbrains-mono`, `source-code-pro` | +## codersdk.ThemeMode + +```json +"" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|----------------------| +| ``, `single`, `sync` | + ## codersdk.ThinkingDisplayMode ```json @@ -12846,16 +12860,30 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { "terminal_font": "", + "theme_dark": "light", + "theme_light": "light", + "theme_mode": "sync", "theme_preference": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------------|----------|--------------|-------------| -| `terminal_font` | [codersdk.TerminalFontName](#codersdkterminalfontname) | true | | | -| `theme_preference` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `terminal_font` | [codersdk.TerminalFontName](#codersdkterminalfontname) | true | | | +| `theme_dark` | string | false | | Theme dark is required when ThemeMode is "sync". In "single" mode an empty value means "preserve the previously persisted slot" rather than "clear the slot", so partial updates that send only one slot keep the other intact. | +| `theme_light` | string | false | | Theme light is required when ThemeMode is "sync". In "single" mode an empty value means "preserve the previously persisted slot" rather than "clear the slot", so partial updates that send only one slot keep the other intact. | +| `theme_mode` | [codersdk.ThemeMode](#codersdkthememode) | false | | Theme mode is optional for backward compatibility. When empty, the server leaves theme_mode, theme_light, and theme_dark unchanged so older CLI clients do not erase sync-mode settings. Legacy auto preferences are the exception: they clear theme_mode so clients can migrate the old sync-with-system setting. | +| `theme_preference` | string | true | | | + +#### Enumerated Values + +| Property | Value(s) | +|---------------|---------------------------------------------------------------------------------------------| +| `theme_dark` | `dark`, `dark-protan-deuter`, `dark-tritan`, `light`, `light-protan-deuter`, `light-tritan` | +| `theme_light` | `dark`, `dark-protan-deuter`, `dark-tritan`, `light`, `light-protan-deuter`, `light-tritan` | +| `theme_mode` | `single`, `sync` | ## codersdk.UpdateUserNotificationPreferences @@ -13344,16 +13372,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "terminal_font": "", + "theme_dark": "string", + "theme_light": "string", + "theme_mode": "", "theme_preference": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------------|----------|--------------|-------------| -| `terminal_font` | [codersdk.TerminalFontName](#codersdkterminalfontname) | false | | | -| `theme_preference` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `terminal_font` | [codersdk.TerminalFontName](#codersdkterminalfontname) | false | | | +| `theme_dark` | string | false | | Ignored when ThemeMode is "single" | +| `theme_light` | string | false | | Ignored when ThemeMode is "single" | +| `theme_mode` | [codersdk.ThemeMode](#codersdkthememode) | false | | | +| `theme_preference` | string | false | | Theme preference is the legacy single-field appearance setting. In "single" mode it mirrors the active theme. In "sync" mode modern clients normally mirror the active OS slot, but older clients can update only this field, so it may diverge from ThemeLight or ThemeDark until a modern client saves the full appearance state again. | ## codersdk.UserLatency diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 4d89c70f5f..6a8d06b40e 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -547,6 +547,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/appearance \ ```json { "terminal_font": "", + "theme_dark": "string", + "theme_light": "string", + "theme_mode": "", "theme_preference": "string" } ``` @@ -578,6 +581,9 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \ ```json { "terminal_font": "", + "theme_dark": "light", + "theme_light": "light", + "theme_mode": "sync", "theme_preference": "string" } ``` @@ -596,6 +602,9 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \ ```json { "terminal_font": "", + "theme_dark": "string", + "theme_light": "string", + "theme_mode": "", "theme_preference": "string" } ``` diff --git a/site/site.go b/site/site.go index 11c94aab9d..9ba43f79f9 100644 --- a/site/site.go +++ b/site/site.go @@ -354,6 +354,16 @@ func execTmpl(tmpl *template.Template, state htmlState) ([]byte, error) { return buf.Bytes(), err } +func userAppearanceSettingsFromRow(settings database.GetUserAppearanceSettingsRow) codersdk.UserAppearanceSettings { + return codersdk.UserAppearanceSettings{ + ThemePreference: settings.ThemePreference, + ThemeMode: codersdk.ThemeMode(settings.ThemeMode), + ThemeLight: settings.ThemeLight, + ThemeDark: settings.ThemeDark, + TerminalFont: codersdk.TerminalFontName(settings.TerminalFont), + } +} + // renderWithState will render the file using the given nonce if the file exists // as a template. If it does not, it will return an error. func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) { @@ -396,8 +406,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht var eg errgroup.Group var user database.User - var themePreference string - var terminalFont string + var userAppearance codersdk.UserAppearanceSettings orgIDs := []uuid.UUID{} var userOrgs []database.Organization eg.Go(func() error { @@ -406,22 +415,12 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht return err }) eg.Go(func() error { - var err error - themePreference, err = h.opts.Database.GetUserThemePreference(ctx, apiKey.UserID) - if errors.Is(err, sql.ErrNoRows) { - themePreference = "" - return nil + settings, err := h.opts.Database.GetUserAppearanceSettings(ctx, apiKey.UserID) + if err != nil { + return err } - return err - }) - eg.Go(func() error { - var err error - terminalFont, err = h.opts.Database.GetUserTerminalFont(ctx, apiKey.UserID) - if errors.Is(err, sql.ErrNoRows) { - terminalFont = "" - return nil - } - return err + userAppearance = userAppearanceSettingsFromRow(settings) + return nil }) eg.Go(func() error { memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID}) @@ -446,7 +445,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht }) err := eg.Wait() if err == nil { - h.populateHTMLState(ctx, &state, af, actor, user, orgIDs, userOrgs, themePreference, terminalFont) + h.populateHTMLState(ctx, &state, af, actor, user, orgIDs, userOrgs, userAppearance) } return execTmpl(tmpl, state) @@ -463,8 +462,7 @@ func (h *Handler) populateHTMLState( user database.User, orgIDs []uuid.UUID, userOrgs []database.Organization, - themePreference string, - terminalFont string, + userAppearance codersdk.UserAppearanceSettings, ) { var wg sync.WaitGroup wg.Go(func() { @@ -474,10 +472,7 @@ func (h *Handler) populateHTMLState( } }) wg.Go(func() { - data, err := json.Marshal(codersdk.UserAppearanceSettings{ - ThemePreference: themePreference, - TerminalFont: codersdk.TerminalFontName(terminalFont), - }) + data, err := json.Marshal(userAppearance) if err == nil { state.UserAppearance = html.EscapeString(string(data)) } diff --git a/site/site_test.go b/site/site_test.go index 757e9b7997..ef72ee0bc4 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -81,6 +81,72 @@ func TestInjection(t *testing.T) { require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got) } +func TestInjectionUserAppearance(t *testing.T) { + t.Parallel() + + siteFS := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("{{ .UserAppearance }}"), + }, + } + db, _ := dbtestutil.NewDB(t) + handler, err := site.New(&site.Options{ + Telemetry: telemetry.NewNoop(), + Database: db, + SiteFS: siteFS, + }) + require.NoError(t, err) + + user := dbgen.User(t, db, database.User{}) + ctx := context.Background() + _, err = db.UpdateUserThemePreference(ctx, database.UpdateUserThemePreferenceParams{ + UserID: user.ID, + ThemePreference: "dark-tritan", + }) + require.NoError(t, err) + _, err = db.UpdateUserThemeMode(ctx, database.UpdateUserThemeModeParams{ + UserID: user.ID, + ThemeMode: string(codersdk.ThemeModeSync), + }) + require.NoError(t, err) + _, err = db.UpdateUserThemeLight(ctx, database.UpdateUserThemeLightParams{ + UserID: user.ID, + ThemeLight: "light-tritan", + }) + require.NoError(t, err) + _, err = db.UpdateUserThemeDark(ctx, database.UpdateUserThemeDarkParams{ + UserID: user.ID, + ThemeDark: "dark-tritan", + }) + require.NoError(t, err) + _, err = db.UpdateUserTerminalFont(ctx, database.UpdateUserTerminalFontParams{ + UserID: user.ID, + TerminalFont: string(codersdk.TerminalFontFiraCode), + }) + require.NoError(t, err) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, token) + rw := httptest.NewRecorder() + + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + var got codersdk.UserAppearanceSettings + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &got) + require.NoError(t, err) + require.Equal(t, codersdk.UserAppearanceSettings{ + ThemePreference: "dark-tritan", + ThemeMode: codersdk.ThemeModeSync, + ThemeLight: "light-tritan", + ThemeDark: "dark-tritan", + TerminalFont: codersdk.TerminalFontFiraCode, + }, got) +} + func TestRenderPermissionsResolvesMe(t *testing.T) { t.Parallel() diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 27ffe57d52..3c35e072cb 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -289,6 +289,9 @@ export const updateAppearanceSettings = ( // more responsive. queryClient.setQueryData(myAppearanceKey, { theme_preference: patch.theme_preference, + theme_mode: patch.theme_mode, + theme_light: patch.theme_light, + theme_dark: patch.theme_dark, terminal_font: patch.terminal_font, }); }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9a2d698c3d..8b43c2b317 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7908,6 +7908,11 @@ export const TerminalFontNames: TerminalFontName[] = [ "", ]; +// From codersdk/users.go +export type ThemeMode = "single" | "sync" | ""; + +export const ThemeModes: ThemeMode[] = ["single", "sync", ""]; + // From codersdk/users.go export type ThinkingDisplayMode = | "always_collapsed" @@ -8398,6 +8403,28 @@ export interface UpdateTemplateMeta { // From codersdk/users.go export interface UpdateUserAppearanceSettingsRequest { readonly theme_preference: string; + /** + * ThemeMode is optional for backward compatibility. When empty, + * the server leaves theme_mode, theme_light, and theme_dark + * unchanged so older CLI clients do not erase sync-mode settings. + * Legacy auto preferences are the exception: they clear theme_mode + * so clients can migrate the old sync-with-system setting. + */ + readonly theme_mode: ThemeMode; + /** + * ThemeLight is required when ThemeMode is "sync". In "single" + * mode an empty value means "preserve the previously persisted + * slot" rather than "clear the slot", so partial updates that send + * only one slot keep the other intact. + */ + readonly theme_light: string; + /** + * ThemeDark is required when ThemeMode is "sync". In "single" mode + * an empty value means "preserve the previously persisted slot" + * rather than "clear the slot", so partial updates that send only + * one slot keep the other intact. + */ + readonly theme_dark: string; readonly terminal_font: TerminalFontName; } @@ -8699,7 +8726,24 @@ export interface UserActivityInsightsResponse { // From codersdk/users.go export interface UserAppearanceSettings { + /** + * ThemePreference is the legacy single-field appearance setting. In + * "single" mode it mirrors the active theme. In "sync" mode modern + * clients normally mirror the active OS slot, but older clients can + * update only this field, so it may diverge from ThemeLight or + * ThemeDark until a modern client saves the full appearance state + * again. + */ readonly theme_preference: string; + readonly theme_mode: ThemeMode; + /** + * Ignored when ThemeMode is "single" + */ + readonly theme_light: string; + /** + * Ignored when ThemeMode is "single" + */ + readonly theme_dark: string; readonly terminal_font: TerminalFontName; } diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx index f4b605c98c..aa732c8a45 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx @@ -18,6 +18,12 @@ type Story = StoryObj; export const Example: Story = { args: { - initialValues: { theme_preference: "", terminal_font: "" }, + initialValues: { + theme_preference: "", + theme_mode: "", + theme_light: "", + theme_dark: "", + terminal_font: "", + }, }, }; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index aa299b6173..d8d107e773 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -52,6 +52,9 @@ export const AppearanceForm: FC = ({ } await onSubmit({ theme_preference: theme, + theme_mode: "", + theme_light: "", + theme_dark: "", terminal_font: currentTerminalFont, }); }; @@ -62,6 +65,9 @@ export const AppearanceForm: FC = ({ } await onSubmit({ theme_preference: currentTheme, + theme_mode: "", + theme_light: "", + theme_dark: "", terminal_font: terminalFont, }); }; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx index c6e1d4fb13..92114ed306 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx @@ -12,6 +12,9 @@ describe("appearance page", () => { vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({ ...MockUserOwner, theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", terminal_font: "fira-code", }); @@ -29,6 +32,9 @@ describe("appearance page", () => { ...MockUserOwner, terminal_font: "geist-mono", theme_preference: "light", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", }); const light = await screen.findByText("Light"); @@ -36,10 +42,12 @@ describe("appearance page", () => { // Check if the API was called correctly expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith({ - terminal_font: "geist-mono", - theme_preference: "light", - }); + expect(API.updateAppearanceSettings).toHaveBeenCalledWith( + expect.objectContaining({ + terminal_font: "geist-mono", + theme_preference: "light", + }), + ); }); it("changes font to fira code", async () => { @@ -49,6 +57,9 @@ describe("appearance page", () => { ...MockUserOwner, terminal_font: "fira-code", theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", }); const firaCode = await screen.findByText("Fira Code"); @@ -56,10 +67,12 @@ describe("appearance page", () => { // Check if the API was called correctly expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith({ - terminal_font: "fira-code", - theme_preference: "dark", - }); + expect(API.updateAppearanceSettings).toHaveBeenCalledWith( + expect.objectContaining({ + terminal_font: "fira-code", + theme_preference: "dark", + }), + ); }); it("changes font to fira code, then back to geist mono", async () => { @@ -71,11 +84,17 @@ describe("appearance page", () => { ...MockUserOwner, terminal_font: "fira-code", theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", }) .mockResolvedValueOnce({ ...MockUserOwner, terminal_font: "geist-mono", theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", }); // when @@ -84,10 +103,12 @@ describe("appearance page", () => { // then expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith({ - terminal_font: "fira-code", - theme_preference: "dark", - }); + expect(API.updateAppearanceSettings).toHaveBeenCalledWith( + expect.objectContaining({ + terminal_font: "fira-code", + theme_preference: "dark", + }), + ); // when const geistMono = await screen.findByText("Geist Mono"); @@ -95,9 +116,12 @@ describe("appearance page", () => { // then expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(2); - expect(API.updateAppearanceSettings).toHaveBeenNthCalledWith(2, { - terminal_font: "geist-mono", - theme_preference: "dark", - }); + expect(API.updateAppearanceSettings).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + terminal_font: "geist-mono", + theme_preference: "dark", + }), + ); }); }); diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx index e7288d4c09..d84cc8ff91 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx @@ -34,6 +34,9 @@ const AppearancePage: FC = () => { error={updateAppearanceSettingsMutation.error} initialValues={{ theme_preference: appearanceSettingsQuery.data.theme_preference, + theme_mode: appearanceSettingsQuery.data.theme_mode, + theme_light: appearanceSettingsQuery.data.theme_light, + theme_dark: appearanceSettingsQuery.data.theme_dark, terminal_font: appearanceSettingsQuery.data.terminal_font, }} onSubmit={updateAppearanceSettingsMutation.mutateAsync} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7a2e5cb132..fcdb91cb35 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -562,6 +562,9 @@ export const SuspendedMockUser: TypesGen.User = { export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = { theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", terminal_font: "", };