Files
paseo/packages/app/src/hooks/use-settings/storage.ts
T
Mohamed BoudraandGitHub 4bda2dfea9 Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
2026-07-21 04:41:19 +08:00

355 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
const LEGACY_SETTINGS_KEY = "@paseo:settings";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
export type WorkspaceTitleSource = "title" | "branch";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
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 const DEFAULT_UI_FONT_SIZE = 16; // == FONT_SIZE.base
export const MIN_UI_FONT_SIZE = 11;
export const MAX_UI_FONT_SIZE = 24;
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 interface AppSettings {
theme: ThemeName | "auto";
language: AppLanguage;
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
vimKeybindings: boolean;
}
export interface Settings extends AppSettings {
manageBuiltInDaemon: boolean;
releaseChannel: ReleaseChannel;
}
type StoredAppSettings = Partial<AppSettings> & { compactToolCalls?: unknown };
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
language: "system",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
vimKeybindings: false,
};
export const DEFAULT_APP_SETTINGS: Settings = {
...DEFAULT_CLIENT_SETTINGS,
manageBuiltInDaemon: true,
releaseChannel: "stable",
};
export interface KeyValueStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: 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: Partial<AppSettings>;
deps: SettingsDeps;
}): Promise<void> {
const storedCurrent =
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage(input.deps));
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
}
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
try {
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
if (stored) {
return normalizeAppSettings(JSON.parse(stored));
}
const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY);
if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = {
...DEFAULT_CLIENT_SETTINGS,
...pickAppSettingsFromLegacy(legacyParsed),
} satisfies AppSettings;
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
return next;
}
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
return DEFAULT_CLIENT_SETTINGS;
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
}
}
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 stored =
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as StoredAppSettings)
: {};
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) };
}
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
if (stored.toolCallDetailLevel !== undefined) {
if (
typeof stored.toolCallDetailLevel === "string" &&
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
) {
return stored.toolCallDetailLevel;
}
// COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values
// deliberately follow the unknown-value fallback. Remove after 2027-01-14.
return "overview";
}
if (typeof stored.compactToolCalls === "boolean") {
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
return stored.compactToolCalls ? "overview" : "detailed";
}
return null;
}
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
}
const language = parseAppLanguage(stored.language);
if (language !== null) {
result.language = language;
}
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior;
}
if (
typeof stored.serviceUrlBehavior === "string" &&
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior;
}
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
}
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
if (uiFontFamily !== null) {
result.uiFontFamily = uiFontFamily;
}
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
if (monoFontFamily !== null) {
result.monoFontFamily = monoFontFamily;
}
const uiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: MIN_UI_FONT_SIZE,
max: MAX_UI_FONT_SIZE,
});
if (uiFontSize !== null) {
result.uiFontSize = uiFontSize;
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
});
if (codeFontSize !== null) {
result.codeFontSize = codeFontSize;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (typeof stored.vimKeybindings === "boolean") {
result.vimKeybindings = stored.vimKeybindings;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
if (toolCallDetailLevel !== null) {
result.toolCallDetailLevel = toolCallDetailLevel;
}
return result;
}
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
return result;
}
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 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
}
return trimmed; // quotes/commas are legit in stacks
}
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 (typeof stored.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
}
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
result.releaseChannel = stored.releaseChannel;
}
return Object.keys(result).length > 0 ? result : null;
}
async function loadRendererSettingsPayload(
storage: KeyValueStorage,
): Promise<Record<string, unknown> | null> {
const current = await storage.getItem(APP_SETTINGS_KEY);
if (current) {
return JSON.parse(current) as Record<string, unknown>;
}
const legacy = await storage.getItem(LEGACY_SETTINGS_KEY);
if (!legacy) {
return null;
}
return JSON.parse(legacy) as Record<string, unknown>;
}