Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion collab-electron/packages/components/src/Terminal/TerminalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { resolveTerminalFontFamily } from "@collab/shared/terminal-font";
import { getTheme } from "./theme";
import "@xterm/xterm/css/xterm.css";
import "./TerminalTab.css";
Expand Down Expand Up @@ -35,14 +36,15 @@ function TerminalTab({
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let isDisposed = false;

const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;

const term = new Terminal({
theme: getTheme(),
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: resolveTerminalFontFamily(undefined),
fontSize: 12,
fontWeight: "300",
fontWeightBold: "500",
Expand All @@ -59,6 +61,26 @@ function TerminalTab({
term.open(container);
fitRef.current = fit;

const applyFontFamily = (value: unknown) => {
const nextFontFamily = resolveTerminalFontFamily(value);
if (nextFontFamily === term.options.fontFamily) return;
term.options.fontFamily = nextFontFamily;
requestAnimationFrame(() => fit.fit());
};

void window.api.getPref("terminalFontFamily")
.then((value) => {
if (isDisposed) return;
applyFontFamily(value);
})
.catch(() => {});

const offPrefChanged = window.api.onPrefChanged((key, value) => {
if (key === "terminalFontFamily") {
applyFontFamily(value);
}
});

const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
Expand Down Expand Up @@ -373,6 +395,7 @@ function TerminalTab({
mediaQuery.addEventListener("change", onThemeChange);

return () => {
isDisposed = true;
if (flushTimer !== undefined) {
clearTimeout(flushTimer);
flushData();
Expand All @@ -386,6 +409,7 @@ function TerminalTab({
container.removeEventListener("dragover", handleDragOver);
container.removeEventListener("drop", handleDrop);
window.api.offPtyData(sessionId, handleData);
offPrefChanged?.();
offShellBlur();
term.dispose();
fitRef.current = null;
Expand Down
47 changes: 47 additions & 0 deletions collab-electron/packages/shared/src/terminal-font.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import {
composeTerminalFontFamily,
DEFAULT_TERMINAL_FONT_FAMILY,
resolveTerminalFontFamily,
} from "./terminal-font";

describe("resolveTerminalFontFamily", () => {
test("returns the default stack for missing values", () => {
expect(resolveTerminalFontFamily(undefined)).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
expect(resolveTerminalFontFamily(null)).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("returns the default stack for blank strings", () => {
expect(resolveTerminalFontFamily("")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
expect(resolveTerminalFontFamily(" ")).toBe(
DEFAULT_TERMINAL_FONT_FAMILY,
);
});

test("preserves a user-provided font stack", () => {
expect(resolveTerminalFontFamily('Monaspace Neon, monospace')).toBe(
"Monaspace Neon, monospace",
);
});

test("appends fallbacks for a single family", () => {
const resolved = resolveTerminalFontFamily("FiraCode Nerd Font Mono");
expect(resolved.startsWith('"FiraCode Nerd Font Mono",')).toBe(true);
expect(resolved).toContain("Menlo");
expect(resolved).toContain('"Segoe UI Emoji"');
});
});

describe("composeTerminalFontFamily", () => {
test("avoids duplicating the primary family when it is already in the fallback stack", () => {
const resolved = composeTerminalFontFamily("Menlo");
const parts = resolved.split(",").map((part) => part.trim());
expect(parts.filter((part) => part === "Menlo").length).toBe(1);
});
});
72 changes: 72 additions & 0 deletions collab-electron/packages/shared/src/terminal-font.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const TERMINAL_FALLBACK_FONT_FAMILIES = [
"Menlo",
"Monaco",
'"Courier New"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Apple Color Emoji"',
'"Noto Color Emoji"',
"monospace",
];

export const TERMINAL_FONT_SUGGESTIONS = [
"FiraCode Nerd Font Mono",
"FiraCode Nerd Font",
"JetBrainsMono Nerd Font Mono",
"CaskaydiaMono Nerd Font Mono",
"MesloLGS NF",
"Hack Nerd Font Mono",
"SF Mono",
"Consolas",
];

export const DEFAULT_TERMINAL_FONT_FAMILY = TERMINAL_FALLBACK_FONT_FAMILIES.join(
", ",
);

function normalizeFontFamilyToken(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
}

function quoteFontFamily(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (/^['"].*['"]$/.test(trimmed)) return trimmed;
if (/^[A-Za-z0-9_-]+$/.test(trimmed)) return trimmed;
return `"${trimmed.replace(/"/g, '\\"')}"`;
}

export function composeTerminalFontFamily(
primaryFontFamily?: string | null,
): string {
const requested = typeof primaryFontFamily === "string"
? primaryFontFamily.trim()
: "";

if (!requested) {
return DEFAULT_TERMINAL_FONT_FAMILY;
}

if (requested.includes(",")) {
return requested;
}

const primary = quoteFontFamily(requested);
const seen = new Set<string>([normalizeFontFamilyToken(primary)]);
const families = [primary];

for (const fallback of TERMINAL_FALLBACK_FONT_FAMILIES) {
const normalized = normalizeFontFamilyToken(fallback);
if (seen.has(normalized)) continue;
seen.add(normalized);
families.push(fallback);
}

return families.join(", ");
}

export function resolveTerminalFontFamily(value: unknown): string {
return composeTerminalFontFamily(
typeof value === "string" ? value : null,
);
}
4 changes: 4 additions & 0 deletions collab-electron/packages/shared/src/window-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export interface CollabApi {
getDeviceId: () => Promise<string>;
getPref: (key: string) => Promise<unknown>;
setPref: (key: string, value: unknown) => Promise<void>;
onPrefChanged: (
cb: (key: string, value: unknown) => void,
) => Unsubscribe;
listTerminalTargets: () => Promise<TerminalTargetOption[]>;
getWorkspacePref: (key: string, workspacePath: string) => Promise<unknown>;
setWorkspacePref: (
Expand Down Expand Up @@ -266,6 +269,7 @@ export interface CollabApi {
backend?: "tmux" | "sidecar";
} | null>;
notifyPtySessionId: (sessionId: string) => void;
notifyCwdChanged: (sessionId: string, cwd: string) => void;
onPtyData: (sessionId: string, cb: PtyDataCb) => void;
offPtyData: (sessionId: string, cb: PtyDataCb) => void;
onPtyExit: (sessionId: string, cb: PtyExitCb) => void;
Expand Down
10 changes: 7 additions & 3 deletions collab-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ function applyZoomToAll(level: number): void {
}
}

function sendToAllWebContents(channel: string, ...args: unknown[]): void {
for (const wc of webContentsModule.getAllWebContents()) {
if (!wc.isDestroyed()) wc.send(channel, ...args);
}
}

function buildAppMenu(): void {
const isMac = process.platform === "darwin";
const fullScreenAccelerator = isMac ? "Ctrl+Cmd+F" : "F11";
Expand Down Expand Up @@ -548,9 +554,7 @@ ipcMain.handle(
"pref:set",
(_event, key: string, value: unknown) => {
setPref(config, key, value);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("pref:changed", key, value);
}
sendToAllWebContents("pref:changed", key, value);
},
);

Expand Down
6 changes: 6 additions & 0 deletions collab-electron/src/preload/universal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ contextBridge.exposeInMainWorld("api", {
getPref: (key: string) => ipcRenderer.invoke("pref:get", key),
setPref: (key: string, value: unknown) =>
ipcRenderer.invoke("pref:set", key, value),
onPrefChanged: (cb: (key: string, value: unknown) => void) => {
const handler = (_event: unknown, key: string, value: unknown) =>
cb(key, value);
ipcRenderer.on("pref:changed", handler);
return () => ipcRenderer.removeListener("pref:changed", handler);
},
listTerminalTargets: () =>
ipcRenderer.invoke("terminal:list-targets"),
getWorkspacePref: (key: string, workspacePath: string) =>
Expand Down
81 changes: 81 additions & 0 deletions collab-electron/src/windows/settings/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
Monitor,
Terminal,
} from "@phosphor-icons/react";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
TERMINAL_FONT_SUGGESTIONS,
} from "@collab/shared/terminal-font";

type ThemeMode = "light" | "dark" | "system";

Expand Down Expand Up @@ -424,6 +428,8 @@ function MacTerminalPane() {
))}
</div>
</div>

<TerminalFontField />
</div>
);
}
Expand Down Expand Up @@ -473,6 +479,81 @@ function WindowsTerminalPane() {
))}
</div>
</div>

<TerminalFontField />
</div>
);
}

function TerminalFontField() {
const [value, setValue] = useState("");

useEffect(() => {
api.getPref("terminalFontFamily")
.then((pref) => setValue(typeof pref === "string" ? pref : ""))
.catch(() => {});
}, []);

async function commit(nextValue: string) {
const trimmed = nextValue.trim();
setValue(trimmed);
await api.setPref("terminalFontFamily", trimmed || null);
}

return (
<div className="space-y-2">
<div className="space-y-0.5">
<p className="text-sm font-medium">Terminal font family</p>
<p className="text-xs text-muted-foreground">
Enter a font family like FiraCode Nerd Font Mono or a full CSS stack.
Single-family entries keep the built-in fallback stack behind them.
Open terminals update immediately.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
list="terminal-font-suggestions"
value={value}
placeholder="e.g. FiraCode Nerd Font Mono"
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => { void commit(event.target.value); }}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
className="w-full rounded-md px-3 py-2 text-sm font-mono"
style={{
backgroundColor:
"color-mix(in srgb, var(--foreground) 4%, transparent)",
border:
"1px solid color-mix(in srgb, var(--foreground) 12%, transparent)",
color: "var(--foreground)",
}}
/>
<datalist id="terminal-font-suggestions">
{TERMINAL_FONT_SUGGESTIONS.map((fontFamily) => (
<option key={fontFamily} value={fontFamily} />
))}
</datalist>
<button
type="button"
onClick={() => { void commit(""); }}
className="rounded-md px-3 py-2 text-xs font-medium cursor-pointer"
style={{
backgroundColor:
"color-mix(in srgb, var(--foreground) 8%, transparent)",
color: "var(--foreground)",
}}
>
Reset
</button>
</div>
<p className="text-[11px] text-muted-foreground font-mono">
Default: {DEFAULT_TERMINAL_FONT_FAMILY}
</p>
</div>
);
}
Expand Down
Loading