feat: support multiple terminal fonts (#17257)

Fixes: https://github.com/coder/coder/issues/15024
This commit is contained in:
Marcin Tojek
2025-04-07 14:30:10 +02:00
committed by GitHub
parent 30f41cdd42
commit 743d308eb3
29 changed files with 815 additions and 230 deletions
+1
View File
@@ -42,6 +42,7 @@
"@emotion/styled": "11.14.0",
"@fastly/performance-observer-polyfill": "2.0.0",
"@fontsource-variable/inter": "5.1.1",
"@fontsource/fira-code": "5.2.5",
"@fontsource/ibm-plex-mono": "5.1.1",
"@monaco-editor/react": "4.6.0",
"@mui/icons-material": "5.16.14",
+8
View File
@@ -40,6 +40,9 @@ importers:
'@fontsource-variable/inter':
specifier: 5.1.1
version: 5.1.1
'@fontsource/fira-code':
specifier: 5.2.5
version: 5.2.5
'@fontsource/ibm-plex-mono':
specifier: 5.1.1
version: 5.1.1
@@ -1040,6 +1043,9 @@ packages:
'@fontsource-variable/inter@5.1.1':
resolution: {integrity: sha512-OpXFTmiH6tHkYijMvQTycFKBLK4X+SRV6tet1m4YOUH7SzIIlMqDja+ocDtiCA72UthBH/vF+3ZtlMr2rN/wIw==, tarball: https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.1.1.tgz}
'@fontsource/fira-code@5.2.5':
resolution: {integrity: sha512-Rn9PJoyfRr5D6ukEhZpzhpD+rbX2rtoz9QjkOuGxqFxrL69fQvhadMUBxQIOuTF4sTTkPRSKlAEpPjTKaI12QA==, tarball: https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.5.tgz}
'@fontsource/ibm-plex-mono@5.1.1':
resolution: {integrity: sha512-1aayqPe/ZkD3MlvqpmOHecfA3f2B8g+fAEkgvcCd3lkPP0pS1T0xG5Zmn2EsJQqr1JURtugPUH+5NqvKyfFZMQ==, tarball: https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.1.1.tgz}
@@ -7012,6 +7018,8 @@ snapshots:
'@fontsource-variable/inter@5.1.1': {}
'@fontsource/fira-code@5.2.5': {}
'@fontsource/ibm-plex-mono@5.1.1': {}
'@humanwhocodes/config-array@0.11.14':
+12 -1
View File
@@ -428,6 +428,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
orgIDs := []uuid.UUID{}
eg.Go(func() error {
var err error
@@ -436,13 +437,22 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
})
eg.Go(func() error {
var err error
themePreference, err = h.opts.Database.GetUserAppearanceSettings(ctx, apiKey.UserID)
themePreference, err = h.opts.Database.GetUserThemePreference(ctx, apiKey.UserID)
if errors.Is(err, sql.ErrNoRows) {
themePreference = ""
return nil
}
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
})
eg.Go(func() error {
memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
@@ -471,6 +481,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
defer wg.Done()
userAppearance, err := json.Marshal(codersdk.UserAppearanceSettings{
ThemePreference: themePreference,
TerminalFont: codersdk.TerminalFontName(terminalFont),
})
if err == nil {
state.UserAppearance = html.EscapeString(string(userAppearance))
+1
View File
@@ -251,6 +251,7 @@ export const updateAppearanceSettings = (
// more responsive.
queryClient.setQueryData(myAppearanceKey, {
theme_preference: patch.theme_preference,
terminal_font: patch.terminal_font,
});
},
onSuccess: async () =>
+11
View File
@@ -2657,6 +2657,15 @@ export interface TemplateVersionsByTemplateRequest extends Pagination {
readonly include_archived: boolean;
}
// From codersdk/users.go
export type TerminalFontName = "fira-code" | "ibm-plex-mono" | "";
export const TerminalFontNames: TerminalFontName[] = [
"fira-code",
"ibm-plex-mono",
"",
];
// From codersdk/workspacebuilds.go
export type TimingStage =
| "apply"
@@ -2790,6 +2799,7 @@ export interface UpdateTemplateMeta {
// From codersdk/users.go
export interface UpdateUserAppearanceSettingsRequest {
readonly theme_preference: string;
readonly terminal_font: TerminalFontName;
}
// From codersdk/notifications.go
@@ -2906,6 +2916,7 @@ export interface UserActivityInsightsResponse {
// From codersdk/users.go
export interface UserAppearanceSettings {
readonly theme_preference: string;
readonly terminal_font: TerminalFontName;
}
// From codersdk/insights.go
@@ -17,6 +17,7 @@ import {
MockEntitlements,
MockExperiments,
MockUser,
MockUserAppearanceSettings,
MockWorkspace,
MockWorkspaceAgent,
} from "testHelpers/entities";
@@ -76,6 +77,7 @@ const meta = {
key: getAuthorizationKey({ checks: permissionChecks }),
data: { editWorkspaceProxies: true },
},
{ key: ["me", "appearance"], data: MockUserAppearanceSettings },
],
chromatic: { delay: 300 },
},
@@ -106,6 +108,38 @@ export const Starting: Story = {
},
};
export const FontFiraCode: Story = {
decorators: [withWebSocket],
parameters: {
...meta.parameters,
webSocket: [
{
event: "message",
// Copied and pasted this from browser
data: "➜ codergit:(bq/refactor-web-term-notifications) ✗",
},
],
queries: [
...meta.parameters.queries.filter(
(q) =>
!(
Array.isArray(q.key) &&
q.key[0] === "me" &&
q.key[1] === "appearance"
),
),
{
key: ["me", "appearance"],
data: {
...MockUserAppearanceSettings,
terminal_font: "fira-code",
},
},
createWorkspaceWithAgent("ready"),
],
},
};
export const Ready: Story = {
decorators: [withWebSocket],
parameters: {
+17 -3
View File
@@ -7,18 +7,20 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import { deploymentConfig } from "api/queries/deployment";
import { appearanceSettings } from "api/queries/users";
import {
workspaceByOwnerAndName,
workspaceUsage,
} from "api/queries/workspaces";
import { useProxy } from "contexts/ProxyContext";
import { ThemeOverride } from "contexts/ThemeProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import themes from "theme";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { DEFAULT_TERMINAL_FONT, terminalFonts } from "theme/constants";
import { pageTitle } from "utils/page";
import { openMaybePortForwardedURL } from "utils/portForward";
import { terminalWebsocketUrl } from "utils/terminal";
@@ -100,6 +102,13 @@ const TerminalPage: FC = () => {
handleWebLinkRef.current = handleWebLink;
}, [handleWebLink]);
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const currentTerminalFont =
appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT;
// Create the terminal!
const fitAddonRef = useRef<FitAddon>();
useEffect(() => {
@@ -110,7 +119,7 @@ const TerminalPage: FC = () => {
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: MONOSPACE_FONT_FAMILY,
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
theme: {
background: theme.palette.background.default,
@@ -150,7 +159,12 @@ const TerminalPage: FC = () => {
window.removeEventListener("resize", listener);
terminal.dispose();
};
}, [config.isLoading, renderer, theme.palette.background.default]);
}, [
config.isLoading,
renderer,
theme.palette.background.default,
currentTerminalFont,
]);
// Updates the reconnection token into the URL if necessary.
useEffect(() => {
@@ -18,6 +18,6 @@ type Story = StoryObj<typeof AppearanceForm>;
export const Example: Story = {
args: {
initialValues: { theme_preference: "" },
initialValues: { theme_preference: "", terminal_font: "" },
},
};
@@ -1,12 +1,23 @@
import type { Interpolation } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import { visuallyHidden } from "@mui/utils";
import type { UpdateUserAppearanceSettingsRequest } from "api/typesGenerated";
import {
type TerminalFontName,
TerminalFontNames,
type UpdateUserAppearanceSettingsRequest,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { PreviewBadge } from "components/Badges/Badges";
import { Stack } from "components/Stack/Stack";
import { ThemeOverride } from "contexts/ThemeProvider";
import type { FC } from "react";
import themes, { DEFAULT_THEME, type Theme } from "theme";
import { DEFAULT_TERMINAL_FONT, terminalFontLabels } from "theme/constants";
import { Section } from "../Section";
export interface AppearanceFormProps {
isUpdating?: boolean;
@@ -22,43 +33,107 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
initialValues,
}) => {
const currentTheme = initialValues.theme_preference || DEFAULT_THEME;
const currentTerminalFont =
initialValues.terminal_font || DEFAULT_TERMINAL_FONT;
const onChangeTheme = async (theme: string) => {
if (isUpdating) {
return;
}
await onSubmit({
theme_preference: theme,
terminal_font: currentTerminalFont,
});
};
await onSubmit({ theme_preference: theme });
const onChangeTerminalFont = async (terminalFont: TerminalFontName) => {
if (isUpdating) {
return;
}
await onSubmit({
theme_preference: currentTheme,
terminal_font: terminalFont,
});
};
return (
<form>
{Boolean(error) && <ErrorAlert error={error} />}
<Stack direction="row" wrap="wrap">
<AutoThemePreviewButton
displayName="Auto"
active={currentTheme === "auto"}
themes={[themes.dark, themes.light]}
onSelect={() => onChangeTheme("auto")}
/>
<ThemePreviewButton
displayName="Dark"
active={currentTheme === "dark"}
theme={themes.dark}
onSelect={() => onChangeTheme("dark")}
/>
<ThemePreviewButton
displayName="Light"
active={currentTheme === "light"}
theme={themes.light}
onSelect={() => onChangeTheme("light")}
/>
</Stack>
<Section
title={
<Stack direction="row" alignItems="center">
<span>Theme</span>
{isUpdating && <CircularProgress size={16} />}
</Stack>
}
layout="fluid"
>
<Stack direction="row" wrap="wrap">
<AutoThemePreviewButton
displayName="Auto"
active={currentTheme === "auto"}
themes={[themes.dark, themes.light]}
onSelect={() => onChangeTheme("auto")}
/>
<ThemePreviewButton
displayName="Dark"
active={currentTheme === "dark"}
theme={themes.dark}
onSelect={() => onChangeTheme("dark")}
/>
<ThemePreviewButton
displayName="Light"
active={currentTheme === "light"}
theme={themes.light}
onSelect={() => onChangeTheme("light")}
/>
</Stack>
</Section>
<div css={{ marginBottom: 48 }}></div>
<Section
title={
<Stack direction="row" alignItems="center">
<span>Terminal Font</span>
{isUpdating && <CircularProgress size={16} />}
</Stack>
}
layout="fluid"
>
<FormControl>
<RadioGroup
aria-labelledby="fonts-radio-buttons-group-label"
defaultValue={currentTerminalFont}
name="fonts-radio-buttons-group"
onChange={(_, value) =>
onChangeTerminalFont(toTerminalFontName(value))
}
>
{TerminalFontNames.filter((name) => name !== "").map((name) => (
<FormControlLabel
key={name}
value={name}
control={<Radio />}
label={
<div css={{ fontFamily: terminalFontLabels[name] }}>
{terminalFontLabels[name]}
</div>
}
/>
))}
</RadioGroup>
</FormControl>
</Section>
</form>
);
};
export function toTerminalFontName(value: string): TerminalFontName {
return TerminalFontNames.includes(value as TerminalFontName)
? (value as TerminalFontName)
: "";
}
interface AutoThemePreviewButtonProps extends Omit<ThemePreviewProps, "theme"> {
themes: [Theme, Theme];
onSelect?: () => void;
@@ -12,13 +12,14 @@ describe("appearance page", () => {
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUser,
theme_preference: "dark",
terminal_font: "fira-code",
});
const dark = await screen.findByText("Dark");
await userEvent.click(dark);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toBeCalledTimes(0);
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(0);
});
it("changes theme to light", async () => {
@@ -26,6 +27,7 @@ describe("appearance page", () => {
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUser,
terminal_font: "ibm-plex-mono",
theme_preference: "light",
});
@@ -33,9 +35,30 @@ describe("appearance page", () => {
await userEvent.click(light);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith({
terminal_font: "ibm-plex-mono",
theme_preference: "light",
});
});
it("changes font to fira code", async () => {
renderWithAuth(<AppearancePage />);
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUser,
terminal_font: "fira-code",
theme_preference: "dark",
});
const ibmPlex = await screen.findByText("Fira Code");
await userEvent.click(ibmPlex);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith({
terminal_font: "fira-code",
theme_preference: "dark",
});
});
});
@@ -1,13 +1,10 @@
import CircularProgress from "@mui/material/CircularProgress";
import { updateAppearanceSettings } from "api/queries/users";
import { appearanceSettings } from "api/queries/users";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Section } from "../Section";
import { AppearanceForm } from "./AppearanceForm";
export const AppearancePage: FC = () => {
@@ -31,26 +28,15 @@ export const AppearancePage: FC = () => {
return (
<>
<Section
title={
<Stack direction="row" alignItems="center">
<span>Theme</span>
{updateAppearanceSettingsMutation.isLoading && (
<CircularProgress size={16} />
)}
</Stack>
}
layout="fluid"
>
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isLoading}
error={updateAppearanceSettingsMutation.error}
initialValues={{
theme_preference: appearanceSettingsQuery.data.theme_preference,
}}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
/>
</Section>
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isLoading}
error={updateAppearanceSettingsMutation.error}
initialValues={{
theme_preference: appearanceSettingsQuery.data.theme_preference,
terminal_font: appearanceSettingsQuery.data.terminal_font,
}}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
/>
</>
);
};
+1
View File
@@ -536,6 +536,7 @@ export const SuspendedMockUser: TypesGen.User = {
export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
theme_preference: "dark",
terminal_font: "",
};
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
+16
View File
@@ -1,7 +1,23 @@
import type { TerminalFontName } from "api/typesGenerated";
export const borderRadius = 8;
export const MONOSPACE_FONT_FAMILY =
"'IBM Plex Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'Liberation Mono', 'Monaco', 'Courier New', Courier, monospace";
export const BODY_FONT_FAMILY = `"Inter Variable", system-ui, sans-serif`;
export const terminalFonts: Record<TerminalFontName, string> = {
"fira-code": MONOSPACE_FONT_FAMILY.replace("IBM Plex Mono", "Fira Code"),
"ibm-plex-mono": MONOSPACE_FONT_FAMILY,
"": MONOSPACE_FONT_FAMILY,
};
export const terminalFontLabels: Record<TerminalFontName, string> = {
"fira-code": "Fira Code",
"ibm-plex-mono": "IBM Plex Mono",
"": "", // needed for enum completeness, otherwise fails the build
};
export const DEFAULT_TERMINAL_FONT = "ibm-plex-mono";
export const navHeight = 62;
export const containerWidth = 1380;
export const containerWidthMedium = 1080;
+3
View File
@@ -3,3 +3,6 @@ import "@fontsource/ibm-plex-mono/400.css";
import "@fontsource/ibm-plex-mono/600.css";
// Main body copy font
import "@fontsource-variable/inter";
// Alternative font for Terminal
import "@fontsource/fira-code/400.css";
import "@fontsource/fira-code/600.css";