mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* refactor(terminal): remove the WebView terminal renderer Native uses the native grid renderer only; web keeps xterm. The legacy renderer setting, its diagnostics toggle, the esbuild WebView bundle and its CI drift check are gone. * test(terminal): target the visible terminal surface in the file paste spec A second page opening a terminal in the same workspace adds a retained, hidden surface to the first page, so the unscoped locator matched two textareas and failed strict mode. * test(server): wait for the shortstat warm instead of polling 500ms The background warm shells out to git; on Windows CI that regularly outlasts the 20x25ms poll and fails the test. Use the completion callback the warm already exposes and keep the non-blocking check.
616 lines
22 KiB
TypeScript
616 lines
22 KiB
TypeScript
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||
import type { QueryClient } from "@tanstack/react-query";
|
||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||
import type { AppLanguage } from "@/i18n/locales";
|
||
import type { SidebarNavPreference } from "@/sidebar-nav/model";
|
||
import {
|
||
DEFAULT_USAGE_PREFERENCES,
|
||
UsagePreferencesSchema,
|
||
type UsagePreferences,
|
||
} from "@/usage/preferences";
|
||
import {
|
||
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||
type SidebarChecksDisplay,
|
||
} from "@/components/sidebar/display-preferences/checks-display";
|
||
import {
|
||
DEFAULT_SIDEBAR_ROW_ITEMS,
|
||
isChecksHiddenByLegacyRowItem,
|
||
type SidebarRowItems,
|
||
} from "@/components/sidebar/display-preferences/row-items";
|
||
import { isNative } from "@/constants/platform";
|
||
import {
|
||
DEFAULT_CONTENT_MAX_WIDTH,
|
||
FONT_SIZE,
|
||
PLUGIN_THEME_PREFERENCE,
|
||
THEME_OPTIONS,
|
||
type ThemePreference,
|
||
} from "@/styles/theme";
|
||
import { z } from "zod";
|
||
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
|
||
import { migrateAppSettings } from "./migrations";
|
||
|
||
export { APP_SETTINGS_KEY } from "./keys";
|
||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||
|
||
export type SendBehavior = ActiveTurnBehavior | "queue";
|
||
export type ReleaseChannel = "stable" | "beta";
|
||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||
export type WorkspaceTitleSource = "title" | "branch";
|
||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||
|
||
const ThemePreferenceSchema = z.enum([
|
||
...THEME_OPTIONS.map((option) => option.name),
|
||
PLUGIN_THEME_PREFERENCE,
|
||
]);
|
||
/** Where the theme picker lands when the persisted preference cannot be honoured. */
|
||
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
|
||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||
export function defaultUiBaseFontSize(native: boolean): number {
|
||
return native ? 15 : FONT_SIZE.base;
|
||
}
|
||
|
||
export const DEFAULT_UI_BASE_FONT_SIZE = defaultUiBaseFontSize(isNative);
|
||
export const MIN_UI_BASE_FONT_SIZE = 10;
|
||
export const MAX_UI_BASE_FONT_SIZE = 21;
|
||
export function defaultContentFontSize(native: boolean): number {
|
||
return native ? 16 : FONT_SIZE.content;
|
||
}
|
||
|
||
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
|
||
export const MIN_CONTENT_FONT_SIZE = 10;
|
||
export const MAX_CONTENT_FONT_SIZE = 21;
|
||
export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
|
||
export const MIN_CODE_FONT_SIZE = 9;
|
||
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
|
||
export const MAX_FONT_FAMILY_LENGTH = 200;
|
||
export { DEFAULT_CONTENT_MAX_WIDTH };
|
||
export const MIN_CONTENT_MAX_WIDTH = 600;
|
||
export const MAX_CONTENT_MAX_WIDTH = 4000;
|
||
|
||
export interface AppSettings {
|
||
theme: ThemePreference;
|
||
/** Which contributed theme `theme: "plugin"` selects. */
|
||
pluginThemeId: string | null;
|
||
language: AppLanguage;
|
||
sendBehavior: SendBehavior;
|
||
serviceUrlBehavior: ServiceUrlBehavior;
|
||
terminalScrollbackLines: number;
|
||
uiFontFamily: string; // "" = platform default UI stack
|
||
monoFontFamily: string; // "" = platform default mono stack
|
||
uiBaseFontSize: number; // clamped px, platform default 14 or 15
|
||
contentFontSize: number; // clamped px, platform default 15 or 16
|
||
codeFontSize: number; // clamped px, default 12
|
||
/** Max width of chat and markdown content in px; null follows the current default. */
|
||
contentMaxWidth: number | null;
|
||
syntaxTheme: SyntaxThemeId; // default "one"
|
||
workspaceTitleSource: WorkspaceTitleSource;
|
||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||
sidebarRowItems: SidebarRowItems;
|
||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||
sidebarNavItems: SidebarNavPreference[];
|
||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||
sidebarFooterItems: SidebarNavPreference[];
|
||
/** How usage reads and which windows the sidebar summary shows. */
|
||
usage: UsagePreferences;
|
||
autoExpandReasoning: boolean;
|
||
toolCallDetailLevel: ToolCallDetailLevel;
|
||
chatOutlineEnabled: boolean;
|
||
vimKeybindings: boolean;
|
||
filePanelAssociations: Record<string, string>;
|
||
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
||
openInSidePane: OpenInSidePanePreferences;
|
||
pullRequestOpenLocation: PullRequestOpenLocation;
|
||
}
|
||
|
||
export type AppSettingsUpdate =
|
||
| Partial<AppSettings>
|
||
| ((current: AppSettings) => Partial<AppSettings>);
|
||
|
||
export interface OpenInSidePanePreferences {
|
||
explorerFiles: boolean;
|
||
diffs: boolean;
|
||
chatFiles: boolean;
|
||
diffFiles: boolean;
|
||
subagents: boolean;
|
||
}
|
||
|
||
export const DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES: OpenInSidePanePreferences = {
|
||
explorerFiles: false,
|
||
diffs: false,
|
||
chatFiles: false,
|
||
diffFiles: false,
|
||
subagents: false,
|
||
};
|
||
|
||
export interface Settings extends AppSettings {
|
||
manageBuiltInDaemon: boolean;
|
||
releaseChannel: ReleaseChannel;
|
||
}
|
||
|
||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||
theme: DEFAULT_THEME_PREFERENCE,
|
||
pluginThemeId: null,
|
||
language: "system",
|
||
sendBehavior: "steer",
|
||
serviceUrlBehavior: "ask",
|
||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||
uiFontFamily: "",
|
||
monoFontFamily: "",
|
||
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||
contentFontSize: DEFAULT_CONTENT_FONT_SIZE,
|
||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||
contentMaxWidth: null,
|
||
syntaxTheme: "one",
|
||
workspaceTitleSource: "title",
|
||
sidebarWorkspaceTrailing: "timestamp",
|
||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||
sidebarNavItems: [],
|
||
sidebarFooterItems: [],
|
||
usage: DEFAULT_USAGE_PREFERENCES,
|
||
autoExpandReasoning: false,
|
||
toolCallDetailLevel: "detailed",
|
||
chatOutlineEnabled: true,
|
||
vimKeybindings: false,
|
||
filePanelAssociations: {},
|
||
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||
pullRequestOpenLocation: "explorer",
|
||
};
|
||
|
||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||
...DEFAULT_CLIENT_SETTINGS,
|
||
manageBuiltInDaemon: true,
|
||
releaseChannel: "stable",
|
||
};
|
||
|
||
function clampedNumber(min: number, max: number) {
|
||
return z
|
||
.unknown()
|
||
.transform((value) => parseClampedFontSize(value, { min, max }))
|
||
.pipe(z.number());
|
||
}
|
||
|
||
function sanitizedFontFamily() {
|
||
return z.unknown().transform(sanitizeFontFamily).pipe(z.string());
|
||
}
|
||
|
||
const SidebarRowItemsSchema = z
|
||
.looseObject({
|
||
branch: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.branch),
|
||
project: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.project),
|
||
host: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.host),
|
||
changeRequest: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.changeRequest),
|
||
services: z.boolean().optional().catch(undefined),
|
||
labels: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.labels),
|
||
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
|
||
checks: z.boolean().optional().catch(undefined),
|
||
// COMPAT(sidebarRowItemsScripts): migrated in v0.3.0, remove after 2027-08-05.
|
||
scripts: z.boolean().optional().catch(undefined),
|
||
})
|
||
.catch(DEFAULT_SIDEBAR_ROW_ITEMS);
|
||
|
||
type StoredAppSettingsFallback = AppSettings & {
|
||
uiFontSize?: number;
|
||
compactToolCalls?: boolean;
|
||
manageBuiltInDaemon?: boolean;
|
||
releaseChannel?: ReleaseChannel;
|
||
needsWrite: boolean;
|
||
};
|
||
|
||
const DEFAULT_STORED_APP_SETTINGS = {
|
||
...DEFAULT_CLIENT_SETTINGS,
|
||
needsWrite: false,
|
||
} satisfies StoredAppSettingsFallback;
|
||
|
||
const StoredAppSettingsSchema = z
|
||
.looseObject({
|
||
theme: ThemePreferenceSchema.catch(DEFAULT_THEME_PREFERENCE),
|
||
pluginThemeId: z.string().nullable().catch(null),
|
||
language: z
|
||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||
.catch("system"),
|
||
sendBehavior: z.enum(["interrupt", "steer", "queue"]).catch("steer"),
|
||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).catch("ask"),
|
||
terminalScrollbackLines: clampedNumber(
|
||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
|
||
uiFontFamily: sanitizedFontFamily().catch(""),
|
||
monoFontFamily: sanitizedFontFamily().catch(""),
|
||
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
|
||
.optional()
|
||
.catch(undefined),
|
||
contentFontSize: clampedNumber(MIN_CONTENT_FONT_SIZE, MAX_CONTENT_FONT_SIZE)
|
||
.optional()
|
||
.catch(DEFAULT_CONTENT_FONT_SIZE),
|
||
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
|
||
uiFontSize: clampedNumber(11, 24).optional().catch(undefined),
|
||
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
|
||
DEFAULT_CODE_FONT_SIZE,
|
||
),
|
||
contentMaxWidth: z
|
||
.null()
|
||
.or(clampedNumber(MIN_CONTENT_MAX_WIDTH, MAX_CONTENT_MAX_WIDTH))
|
||
.catch(null),
|
||
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
|
||
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
|
||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"),
|
||
sidebarRowItems: SidebarRowItemsSchema,
|
||
sidebarChecksDisplay: z
|
||
.enum(["iconAndText", "icon", "none"])
|
||
.optional()
|
||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||
usage: UsagePreferencesSchema,
|
||
autoExpandReasoning: z.boolean().catch(false),
|
||
toolCallDetailLevel: z
|
||
.enum(["overview", "detailed"])
|
||
.or(z.literal("concise").transform(() => "overview" as const))
|
||
.optional()
|
||
.catch("detailed"),
|
||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||
chatOutlineEnabled: z.boolean().catch(true),
|
||
vimKeybindings: z.boolean().catch(false),
|
||
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
|
||
openInSidePane: z
|
||
.object({
|
||
explorerFiles: z.boolean().catch(false),
|
||
diffs: z.boolean().optional(),
|
||
// COMPAT(diffDestinationPreference): legacy split preferences, remove after 2027-02-26.
|
||
explorerChanges: z.boolean().optional(),
|
||
changesLinks: z.boolean().optional(),
|
||
chatFiles: z.boolean().catch(false),
|
||
diffFiles: z.boolean().catch(false),
|
||
subagents: z.boolean().catch(false),
|
||
// COMPAT(pullRequestOpenLocation): legacy side-pane toggle, remove after 2027-02-26.
|
||
pullRequests: z.boolean().optional(),
|
||
})
|
||
.transform(({ explorerChanges, changesLinks, pullRequests, ...preferences }) => ({
|
||
...preferences,
|
||
diffs: preferences.diffs ?? explorerChanges ?? changesLinks ?? false,
|
||
legacyPullRequestsInSidePane: pullRequests,
|
||
}))
|
||
.catch({
|
||
...DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||
legacyPullRequestsInSidePane: undefined,
|
||
}),
|
||
pullRequestOpenLocation: z.enum(["main", "side", "explorer"]).optional(),
|
||
// COMPAT(explorerSidebarRouting): replaced by source-specific side-pane preferences in v0.6.
|
||
openSupportingTabsInSidePanel: z.boolean().optional().catch(undefined),
|
||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||
manageBuiltInDaemon: z.boolean().optional().catch(undefined),
|
||
releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined),
|
||
})
|
||
.transform((stored) => {
|
||
const { legacyPullRequestsInSidePane, ...openInSidePane } = stored.openInSidePane;
|
||
const needsWrite =
|
||
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
||
stored.contentFontSize === undefined;
|
||
const uiBaseFontSize =
|
||
stored.uiBaseFontSize ??
|
||
(stored.uiFontSize === undefined
|
||
? DEFAULT_UI_BASE_FONT_SIZE
|
||
: Math.round((FONT_SIZE.base * stored.uiFontSize) / 16));
|
||
const sidebarChecksDisplay =
|
||
stored.sidebarChecksDisplay ??
|
||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||
? "none"
|
||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||
const toolCallDetailLevel =
|
||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||
return {
|
||
...stored,
|
||
openInSidePane,
|
||
pullRequestOpenLocation:
|
||
stored.pullRequestOpenLocation ?? (legacyPullRequestsInSidePane ? "side" : "explorer"),
|
||
uiBaseFontSize,
|
||
contentFontSize: stored.contentFontSize ?? uiBaseFontSize,
|
||
sidebarChecksDisplay,
|
||
sidebarRowItems: {
|
||
...stored.sidebarRowItems,
|
||
services:
|
||
stored.sidebarRowItems.services ??
|
||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||
},
|
||
toolCallDetailLevel,
|
||
needsWrite,
|
||
};
|
||
})
|
||
.catch(DEFAULT_STORED_APP_SETTINGS);
|
||
|
||
type StoredAppSettings = z.output<typeof StoredAppSettingsSchema>;
|
||
export type PersistedAppSettings = Omit<StoredAppSettings, "needsWrite">;
|
||
|
||
export interface KeyValueStorage {
|
||
getItem(key: string): Promise<string | null>;
|
||
setItem(key: string, value: string): Promise<void>;
|
||
removeItem(key: string): Promise<void>;
|
||
}
|
||
|
||
export interface DesktopSettingsBridge {
|
||
isElectron(): boolean;
|
||
loadDesktopSettings(): Promise<DesktopSettings>;
|
||
migrateLegacyDesktopSettings(input: {
|
||
manageBuiltInDaemon?: boolean;
|
||
releaseChannel?: ReleaseChannel;
|
||
}): Promise<void>;
|
||
}
|
||
|
||
export interface SettingsDeps {
|
||
storage: KeyValueStorage;
|
||
desktop: DesktopSettingsBridge;
|
||
}
|
||
|
||
export async function saveAppSettings(input: {
|
||
queryClient: QueryClient;
|
||
updates: AppSettingsUpdate;
|
||
deps: SettingsDeps;
|
||
}): Promise<void> {
|
||
const storedCurrent =
|
||
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||
(await loadAppSettingsFromStorage(input.deps));
|
||
const current = normalizeAppSettings(storedCurrent);
|
||
const updates = typeof input.updates === "function" ? input.updates(current) : input.updates;
|
||
const next = { ...current, ...updates };
|
||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||
await writeAppSettings(
|
||
input.deps.storage,
|
||
(await readSettingsObject(input.deps.storage, APP_SETTINGS_KEY)) ??
|
||
StoredAppSettingsSchema.parse({}),
|
||
next,
|
||
);
|
||
}
|
||
|
||
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
||
try {
|
||
const read = await readAppSettings(deps);
|
||
if (read.needsWrite) {
|
||
await writeAppSettings(deps.storage, read.stored, read.settings);
|
||
}
|
||
const { needsWrite: _needsWrite, ...stored } = read.stored;
|
||
return await migrateAppSettings(read.settings, deps.storage, stored, { native: isNative });
|
||
} catch (error) {
|
||
console.error("[AppSettings] Failed to load settings:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reads whichever of the settings blobs exists, without migrating. `needsWrite` covers the reads
|
||
* that produce settings the stored blob does not already spell out.
|
||
*/
|
||
async function readAppSettings(
|
||
deps: SettingsDeps,
|
||
): Promise<{ settings: AppSettings; needsWrite: boolean; stored: StoredAppSettings }> {
|
||
const stored = await readSettingsObject(deps.storage, APP_SETTINGS_KEY);
|
||
if (stored) {
|
||
return {
|
||
settings: normalizeAppSettings(stored),
|
||
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
|
||
needsWrite: stored.needsWrite,
|
||
stored,
|
||
};
|
||
}
|
||
|
||
const legacyStored = await readSettingsObject(deps.storage, LEGACY_SETTINGS_KEY);
|
||
if (legacyStored) {
|
||
return {
|
||
settings: {
|
||
...DEFAULT_CLIENT_SETTINGS,
|
||
...pickAppSettingsFromLegacy(legacyStored),
|
||
} satisfies AppSettings,
|
||
needsWrite: true,
|
||
stored: legacyStored,
|
||
};
|
||
}
|
||
|
||
const defaultStored = StoredAppSettingsSchema.parse({});
|
||
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true, stored: defaultStored };
|
||
}
|
||
|
||
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
|
||
const legacyDesktopSettings = deps.desktop.isElectron()
|
||
? await loadLegacyDesktopSettingsFromStorage(deps.storage)
|
||
: null;
|
||
const appSettings = await loadAppSettingsFromStorage(deps);
|
||
|
||
if (!deps.desktop.isElectron()) {
|
||
return {
|
||
...DEFAULT_APP_SETTINGS,
|
||
...appSettings,
|
||
};
|
||
}
|
||
|
||
if (legacyDesktopSettings) {
|
||
await deps.desktop.migrateLegacyDesktopSettings(legacyDesktopSettings);
|
||
}
|
||
|
||
const desktopSettings = await deps.desktop.loadDesktopSettings();
|
||
return {
|
||
...DEFAULT_APP_SETTINGS,
|
||
...appSettings,
|
||
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
|
||
releaseChannel: desktopSettings.releaseChannel,
|
||
};
|
||
}
|
||
|
||
export function normalizeAppSettings(value: unknown): AppSettings {
|
||
const {
|
||
needsWrite: _needsWrite,
|
||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||
releaseChannel: _releaseChannel,
|
||
compactToolCalls: _compactToolCalls,
|
||
uiFontSize: _uiFontSize,
|
||
...settings
|
||
} = StoredAppSettingsSchema.parse(value);
|
||
return settings;
|
||
}
|
||
|
||
function pickAppSettingsFromLegacy(legacy: StoredAppSettings): AppSettings {
|
||
const settings = normalizeAppSettings(legacy);
|
||
return {
|
||
...settings,
|
||
// The legacy key rendered content on the interface ramp. Freeze that
|
||
// rendered value into the new independent preference during migration.
|
||
contentFontSize: legacy.uiBaseFontSize,
|
||
};
|
||
}
|
||
|
||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||
let numericValue = NaN;
|
||
if (typeof value === "number") {
|
||
numericValue = value;
|
||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||
numericValue = Number(value);
|
||
}
|
||
if (!Number.isFinite(numericValue)) {
|
||
return null;
|
||
}
|
||
return Math.min(
|
||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||
Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)),
|
||
);
|
||
}
|
||
|
||
export function parseContentMaxWidth(value: unknown): number | null {
|
||
return parseClampedFontSize(value, { min: MIN_CONTENT_MAX_WIDTH, max: MAX_CONTENT_MAX_WIDTH });
|
||
}
|
||
|
||
export function resolveContentMaxWidth(settings: Pick<AppSettings, "contentMaxWidth">): number {
|
||
return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH;
|
||
}
|
||
|
||
export function parseClampedFontSize(
|
||
value: unknown,
|
||
bounds: { min: number; max: number },
|
||
): number | null {
|
||
let numericValue = NaN;
|
||
if (typeof value === "number") {
|
||
numericValue = value;
|
||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||
numericValue = Number(value);
|
||
}
|
||
if (!Number.isFinite(numericValue)) {
|
||
return null;
|
||
}
|
||
return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue)));
|
||
}
|
||
|
||
export function sanitizeFontFamily(value: unknown): string | null {
|
||
if (typeof value !== "string") {
|
||
return null;
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed.length === 0) {
|
||
return ""; // explicit empty = default
|
||
}
|
||
if (trimmed.length > MAX_FONT_FAMILY_LENGTH) {
|
||
return null;
|
||
}
|
||
if (/[;{}<>]/.test(trimmed)) {
|
||
return null; // would break the web CSS font-family declaration
|
||
}
|
||
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
|
||
return null; // control chars would corrupt the font-family string
|
||
}
|
||
// Unistyles serializes theme values into one shared stylesheet. An unfinished
|
||
// string, escape, comment, or block consumes the declarations that follow.
|
||
let quote: string | null = null;
|
||
let escaped = false;
|
||
for (const char of trimmed) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (char === "\\") {
|
||
escaped = true;
|
||
} else if (quote !== null) {
|
||
if (char === quote) quote = null;
|
||
} else if (char === "'" || char === '"') {
|
||
quote = char;
|
||
} else if (/[()[\]/*]/.test(char)) {
|
||
return null;
|
||
}
|
||
}
|
||
return quote === null && !escaped ? trimmed : null;
|
||
}
|
||
|
||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||
manageBuiltInDaemon?: boolean;
|
||
releaseChannel?: ReleaseChannel;
|
||
} | null> {
|
||
const stored = await loadRendererSettingsPayload(storage);
|
||
if (!stored) {
|
||
return null;
|
||
}
|
||
|
||
const result: {
|
||
manageBuiltInDaemon?: boolean;
|
||
releaseChannel?: ReleaseChannel;
|
||
} = {};
|
||
|
||
if (stored.manageBuiltInDaemon !== undefined) {
|
||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||
}
|
||
if (stored.releaseChannel !== undefined) {
|
||
result.releaseChannel = stored.releaseChannel;
|
||
}
|
||
|
||
return Object.keys(result).length > 0 ? result : null;
|
||
}
|
||
|
||
async function loadRendererSettingsPayload(
|
||
storage: KeyValueStorage,
|
||
): Promise<StoredAppSettings | null> {
|
||
const current = await readSettingsObject(storage, APP_SETTINGS_KEY);
|
||
if (current) {
|
||
return current;
|
||
}
|
||
|
||
return readSettingsObject(storage, LEGACY_SETTINGS_KEY);
|
||
}
|
||
|
||
async function readSettingsObject(
|
||
storage: KeyValueStorage,
|
||
key: string,
|
||
): Promise<StoredAppSettings | null> {
|
||
const raw = await storage.getItem(key);
|
||
if (raw === null) {
|
||
return null;
|
||
}
|
||
|
||
let decoded: unknown;
|
||
try {
|
||
decoded = JSON.parse(raw);
|
||
} catch {
|
||
console.warn(`[AppSettings] Removing corrupt ${key}: invalid JSON.`);
|
||
await storage.removeItem(key);
|
||
return null;
|
||
}
|
||
return StoredAppSettingsSchema.parse(decoded);
|
||
}
|
||
|
||
async function writeAppSettings(
|
||
storage: KeyValueStorage,
|
||
stored: StoredAppSettings,
|
||
settings: AppSettings,
|
||
): Promise<void> {
|
||
const { needsWrite: _needsWrite, ...persistedStored } = stored;
|
||
const storedSidebarRowItems = persistedStored.sidebarRowItems;
|
||
await storage.setItem(
|
||
APP_SETTINGS_KEY,
|
||
JSON.stringify({
|
||
...persistedStored,
|
||
...settings,
|
||
sidebarRowItems: { ...storedSidebarRowItems, ...settings.sidebarRowItems },
|
||
}),
|
||
);
|
||
}
|