diff --git a/knip.json b/knip.json index 5f93aa1438..170eb68f56 100644 --- a/knip.json +++ b/knip.json @@ -48,6 +48,7 @@ "src/**/*.{ts,tsx}!", "../src/shared/*.ts!", "!src/utils/test-utils.tsx!", + "!src/**/__tests__/**!", "!src/vite-plugins/**!", "!src/**/*.visual.tsx!", "!src/**/*.visual.{fixture,i18n}.{ts,tsx}!" diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index f2b2e7e8e5..175fe04e80 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -51,3 +51,41 @@ describe("collapseCommandOutputByDefault global setting", () => { expect(globalSettingsSchema.safeParse({ collapseCommandOutputByDefault: "true" }).success).toBe(false) }) }) + +describe("appearance global settings", () => { + const bounds = { + interfaceFontScale: { min: 50, max: 200 }, + terminalFontSize: { min: 8, max: 32 }, + editorFontSize: { min: 8, max: 32 }, + spacingScale: { min: 50, max: 200 }, + } as const + const keys = Object.keys(bounds) as Array + + it.each(keys)("%s is a persisted global setting", (key) => { + expect(GLOBAL_SETTINGS_KEYS).toContain(key) + }) + + it.each(keys)("%s accepts the whole range, including both bounds", (key) => { + const { min, max } = bounds[key] + for (const value of [min, Math.round((min + max) / 2), max]) { + expect(globalSettingsSchema.parse({ [key]: value })).toEqual({ [key]: value }) + } + }) + + it.each(keys)("%s accepts null (a saved reset) and an unset value", (key) => { + expect(globalSettingsSchema.parse({ [key]: null })).toEqual({ [key]: null }) + expect(globalSettingsSchema.parse({})).not.toHaveProperty(key) + }) + + it.each(keys)("%s rejects out-of-range, fractional and non-numeric values", (key) => { + const { min, max } = bounds[key] + for (const value of [min - 1, max + 1, 0, -1, min + 0.5, Number.NaN, "100"]) { + expect(globalSettingsSchema.safeParse({ [key]: value }).success).toBe(false) + } + }) + + it("keeps all four values together", () => { + const all = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 } + expect(globalSettingsSchema.parse(all)).toEqual(all) + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..d74616bf21 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -280,6 +280,22 @@ export const globalSettingsSchema = z.object({ * When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`. */ chatFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Scale (percent) applied to Zoo Code's interface text. `null`/unset = 100. + */ + interfaceFontScale: z.number().int().min(50).max(200).nullish(), + /** + * Font size (px) for command/terminal output shown in chat. `null`/unset inherits. + */ + terminalFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Font size (px) for code blocks and diffs shown in chat. `null`/unset inherits. + */ + editorFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Scale (percent) applied to chat row padding. `null`/unset = 100. + */ + spacingScale: z.number().int().min(50).max(200).nullish(), /** * Controls the keyboard behavior for sending messages in the chat input. * - "send": Enter sends message, Shift+Enter creates newline (default) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index dc407325d9..3918bec7dd 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -50,6 +50,7 @@ export interface ExtensionMessage { | typeof VsCodeLmModelsMessageType.vsCodeLmModels | typeof VsCodeLmModelsMessageType.githubCopilotModels | typeof VsCodeLmModelsMessageType.githubCopilotSignInResult + | "settingsSaveResult" | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -339,6 +340,10 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "chatFontSize" + | "interfaceFontScale" + | "terminalFontSize" + | "editorFontSize" + | "spacingScale" | "enterBehavior" | "includeCurrentTime" | "includeCurrentCost" diff --git a/src/core/config/__tests__/appearanceSettingWrites.spec.ts b/src/core/config/__tests__/appearanceSettingWrites.spec.ts new file mode 100644 index 0000000000..62a3d7d420 --- /dev/null +++ b/src/core/config/__tests__/appearanceSettingWrites.spec.ts @@ -0,0 +1,180 @@ +import { + APPEARANCE_SETTING_KEYS, + beginSettingWrite, + finishSettingWrites, + isAppearanceSetting, + performSettingWrite, + undoSettingWrite, +} from "../appearanceSettingWrites" +import type { ContextProxy } from "../ContextProxy" + +describe("appearanceSettingWrites", () => { + let stored: Map + // Only the two methods the module uses. The cast says so: the real ContextProxy has far more. + let contextProxy: ContextProxy + + beforeEach(() => { + stored = new Map([["interfaceFontScale", 100]]) + contextProxy = { + getValue: (key: string) => stored.get(key), + setValue: async (key: string, value: unknown) => { + stored.set(key, value) + }, + } as unknown as ContextProxy + }) + + /** Stores the value, as the write that was just recorded would have. */ + const store = (key: string, value: unknown) => stored.set(key, value) + + describe("which settings are tracked", () => { + it.each(APPEARANCE_SETTING_KEYS)("tracks %s", (key) => { + expect(isAppearanceSetting(key)).toBe(true) + }) + + it.each(["soundEnabled", "language", "", "TimeFormat"])("does not track %j", (key) => { + expect(isAppearanceSetting(key)).toBe(false) + }) + }) + + describe("two saves writing the same setting", () => { + it("a failed save does not undo a later save that wrote the same value afterwards", async () => { + const save = beginSettingWrite("interfaceFontScale", 100, 120) + store("interfaceFontScale", 120) + const other = beginSettingWrite("interfaceFontScale", 120, 120) + store("interfaceFontScale", 120) + finishSettingWrites([other]) + + await undoSettingWrite(contextProxy, save) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it("a failed earlier save does not undo a later save that wrote the same value afterwards", async () => { + const other = beginSettingWrite("interfaceFontScale", 100, 150) + store("interfaceFontScale", 150) + const save = beginSettingWrite("interfaceFontScale", 150, 150) + store("interfaceFontScale", 150) + finishSettingWrites([save]) + + await undoSettingWrite(contextProxy, other) + + expect(stored.get("interfaceFontScale")).toBe(150) + }) + + it("when both fail, each restores what was stored before either of them", async () => { + const save = beginSettingWrite("interfaceFontScale", 100, 120) + store("interfaceFontScale", 120) + const other = beginSettingWrite("interfaceFontScale", 120, 150) + store("interfaceFontScale", 150) + + await undoSettingWrite(contextProxy, save) // superseded by the other save: nothing changes yet + expect(stored.get("interfaceFontScale")).toBe(150) + + await undoSettingWrite(contextProxy, other) + expect(stored.get("interfaceFontScale")).toBe(100) + }) + }) + + describe("performSettingWrite", () => { + it("reads what the write replaces when it starts", async () => { + const write = beginSettingWrite("interfaceFontScale", "stale", 120) + store("interfaceFontScale", 150) // an earlier write reached storage after this one registered + + await performSettingWrite(contextProxy, write) + + expect(write.previous).toBe(150) + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it("keeps the value handed over by a write that was rolled back before this one started", async () => { + const first = beginSettingWrite("interfaceFontScale", 100, 150) + store("interfaceFontScale", 150) + const second = beginSettingWrite("interfaceFontScale", 150, 120) + + await undoSettingWrite(contextProxy, first) // superseded: hands 100 to the second write + await performSettingWrite(contextProxy, second) + expect(second.previous).toBe(100) + + await undoSettingWrite(contextProxy, second) + expect(stored.get("interfaceFontScale")).toBe(100) + }) + }) + + describe("a write that finished between two that fail", () => { + it("keeps its value when the earlier one fails and then the later one", async () => { + const first = beginSettingWrite("interfaceFontScale", 100, 150) + await performSettingWrite(contextProxy, first) + const second = beginSettingWrite("interfaceFontScale", 150, 120) + await performSettingWrite(contextProxy, second) + const third = beginSettingWrite("interfaceFontScale", 120, 80) // registered while the second is pending + finishSettingWrites([second]) // committed, between the other two + await performSettingWrite(contextProxy, third) + + await undoSettingWrite(contextProxy, first) // superseded: the committed value is not its to restore + await undoSettingWrite(contextProxy, third) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it("keeps its value for a later write that has not started when the earlier one fails", async () => { + const first = beginSettingWrite("interfaceFontScale", 100, 150) + await performSettingWrite(contextProxy, first) + const second = beginSettingWrite("interfaceFontScale", 150, 120) + await performSettingWrite(contextProxy, second) + const third = beginSettingWrite("interfaceFontScale", 120, 80) // queued, not started + + await undoSettingWrite(contextProxy, first) + finishSettingWrites([second]) + await performSettingWrite(contextProxy, third) + await undoSettingWrite(contextProxy, third) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it("leaves what a write read when it started, if it had started when the earlier one finished", async () => { + const first = beginSettingWrite("interfaceFontScale", 100, 150) + await performSettingWrite(contextProxy, first) + const second = beginSettingWrite("interfaceFontScale", 150, 120) + await performSettingWrite(contextProxy, second) + const third = beginSettingWrite("interfaceFontScale", 120, 80) + await performSettingWrite(contextProxy, third) // started: it read 120 + + finishSettingWrites([second]) + await undoSettingWrite(contextProxy, third) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + }) + + it("puts a failed write back to what was stored before it", async () => { + const write = beginSettingWrite("interfaceFontScale", 100, 120) + store("interfaceFontScale", 120) + + await undoSettingWrite(contextProxy, write) + + expect(stored.get("interfaceFontScale")).toBe(100) + }) + + it("leaves a value alone that was changed by something else after the write", async () => { + const write = beginSettingWrite("interfaceFontScale", 100, 120) + store("interfaceFontScale", 150) + + await undoSettingWrite(contextProxy, write) + + expect(stored.get("interfaceFontScale")).toBe(150) + }) + + it("does not link a new write to one that has finished", async () => { + const first = beginSettingWrite("interfaceFontScale", 100, 120) + store("interfaceFontScale", 120) + finishSettingWrites([first]) + const second = beginSettingWrite("interfaceFontScale", 120, 150) + store("interfaceFontScale", 150) + + await undoSettingWrite(contextProxy, second) + + // Back to the finished write's value, not past it. + expect(stored.get("interfaceFontScale")).toBe(120) + expect(second.prev).toBeUndefined() + }) +}) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 90db81d8a1..35bd28865d 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -19,6 +19,12 @@ import { } from "../importExport" import { ProviderSettingsManager } from "../ProviderSettingsManager" import { ContextProxy } from "../ContextProxy" +import { + beginSettingWrite, + performSettingWrite, + settingWriteBookkeepingSize, + undoSettingWrite, +} from "../appearanceSettingWrites" import { CustomModesManager } from "../CustomModesManager" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -274,6 +280,155 @@ describe("importExport", () => { expect(mockContextProxy.setValues).not.toHaveBeenCalled() }) + describe("appearance settings go through the write chain of the saves", () => { + const stored = new Map() + + const importWith = (globalSettings: Record) => { + ;(fs.readFile as Mock).mockResolvedValue( + JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + globalSettings, + }), + ) + return importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + } + + beforeEach(() => { + stored.clear() + stored.set("interfaceFontScale", 100) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.import.mockResolvedValue(undefined) + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + mockContextProxy.setProviderSettings.mockResolvedValue(undefined) + mockContextProxy.getValue.mockImplementation(((key: string) => + stored.get(key)) as unknown as typeof mockContextProxy.getValue) + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + stored.set(key, value) + }) as unknown as typeof mockContextProxy.setValue) + mockContextProxy.setValues.mockResolvedValue(undefined) + }) + + it("stores an appearance value as a recorded write, and the rest through setValues", async () => { + const result = await importWith({ interfaceFontScale: 120, mode: "code" }) + + expect(result).toMatchObject({ success: true }) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps an imported value when a save that wrote the same value fails afterwards", async () => { + const save = beginSettingWrite("interfaceFontScale", 100, 120) + await performSettingWrite(mockContextProxy as unknown as ContextProxy, save) + + await importWith({ interfaceFontScale: 120 }) // the same value, while the save is pending + await undoSettingWrite(mockContextProxy as unknown as ContextProxy, save) // the save fails + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it("undoes a write that failed, so an earlier save still pending keeps its own rollback", async () => { + const proxy = mockContextProxy as unknown as ContextProxy + const save = beginSettingWrite("interfaceFontScale", 100, 150) + await performSettingWrite(proxy, save) // a save has stored 150 and is still pending + + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + if (value === 120) throw new Error("storage unavailable") // the import's write fails + stored.set(key, value) + }) as unknown as typeof mockContextProxy.setValue) + const result = await importWith({ interfaceFontScale: 120 }) + expect(result).toEqual({ success: false, error: "storage unavailable" }) + + await undoSettingWrite(proxy, save) // the save fails afterwards + + expect(stored.get("interfaceFontScale")).toBe(100) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps the appearance values that were stored when the other settings fail to store", async () => { + mockContextProxy.setValues.mockRejectedValue(new Error("other settings failed")) + + const result = await importWith({ interfaceFontScale: 120, mode: "code" }) + + expect(result).toEqual({ success: false, error: "other settings failed" }) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("puts back a value that was stored before its write failed, and still reports the failure", async () => { + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + stored.set(key, value) + if (value === 120) throw new Error("failed after storing") + }) as unknown as typeof mockContextProxy.setValue) + + const result = await importWith({ interfaceFontScale: 120 }) + + expect(result).toEqual({ success: false, error: "failed after storing" }) + expect(stored.get("interfaceFontScale")).toBe(100) + }) + + it("puts back an unset value as unset when the write failed after storing", async () => { + stored.delete("interfaceFontScale") // cold start: nothing was ever stored for it + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + stored.set(key, value) + if (value === 120) throw new Error("failed after storing") + }) as unknown as typeof mockContextProxy.setValue) + + const result = await importWith({ interfaceFontScale: 120 }) + + expect(result).toEqual({ success: false, error: "failed after storing" }) + expect(stored.get("interfaceFontScale")).toBeUndefined() + expect(mockContextProxy.setValue).toHaveBeenCalledWith("interfaceFontScale", undefined) + }) + + it("reports the failure even when putting a value back fails as well", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + if (value === 100) throw new Error("restore failed") + stored.set(key, value) + if (value === 120) throw new Error("failed after storing") + }) as unknown as typeof mockContextProxy.setValue) + + const result = await importWith({ interfaceFontScale: 120 }) + + expect(result).toEqual({ success: false, error: "failed after storing" }) + expect(consoleError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith( + 'Failed to restore setting "interfaceFontScale" after import failure:', + expect.objectContaining({ message: "restore failed" }), + ) + consoleError.mockRestore() + }) + + it("leaves nothing in the chain, and reports the failure, when a write fails", async () => { + mockContextProxy.setValue.mockRejectedValue(new Error("storage unavailable")) + + const result = await importWith({ interfaceFontScale: 120 }) + + expect(result).toEqual({ success: false, error: "storage unavailable" }) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("writes everything through setValues when the file has no appearance value", async () => { + await importWith({ mode: "code" }) + + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("interfaceFontScale", expect.anything()) + }) + }) + it("should import settings successfully from a valid file", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/appearanceSettingWrites.ts b/src/core/config/appearanceSettingWrites.ts new file mode 100644 index 0000000000..ad6c37fad0 --- /dev/null +++ b/src/core/config/appearanceSettingWrites.ts @@ -0,0 +1,148 @@ +import type { RooCodeSettings } from "@roo-code/types" + +import type { ContextProxy } from "./ContextProxy" + +/** The appearance settings, whose writes are tracked so a failed save can be put back without undoing another one. */ +export const APPEARANCE_SETTING_KEYS = [ + "interfaceFontScale", + "terminalFontSize", + "editorFontSize", + "spacingScale", +] as const + +export type AppearanceSettingKey = (typeof APPEARANCE_SETTING_KEYS)[number] + +export const isAppearanceSetting = (key: string): key is AppearanceSettingKey => + (APPEARANCE_SETTING_KEYS as readonly string[]).includes(key) + +/** One write of an appearance setting by a save, linked to the writes of other saves that overlap it. */ +export interface SettingWrite { + key: AppearanceSettingKey + /** What was stored when this write was made. With overlapping writes, this can be another write's pending value. */ + previous: unknown + /** `previous` was handed over by a write that was rolled back, so it is not read again when this write starts. */ + inherited?: boolean + /** Its store has been issued, so `previous` has been read. */ + started?: boolean + wrote: unknown + /** The write before this one that has not finished yet, and the one after it. */ + prev?: SettingWrite + next?: SettingWrite + /** The save has finished: it succeeded, or it has been rolled back. */ + done: boolean +} + +/** + * The latest write of each appearance setting. The store is shared by every provider (sidebar, editor tab), so saves + * can overlap, and the writes to one key form a chain. Rolling a write back puts the key + * back only while it is the latest one and the stored value is still what it wrote, so another write's value is kept + * even when it is the same value. A write that is rolled back while a newer one is pending is taken out of the chain, + * so that if the newer one fails too, it restores what was stored before either of them. + */ +const lastWriteOfSetting = new Map() + +/** Records a write that is about to be made, as the latest one of its key. */ +export const beginSettingWrite = (key: AppearanceSettingKey, previous: unknown, wrote: unknown): SettingWrite => { + const before = lastWriteOfSetting.get(key) + const write: SettingWrite = { key, previous, wrote, prev: before && !before.done ? before : undefined, done: false } + if (write.prev) write.prev.next = write + lastWriteOfSetting.set(key, write) + return write +} + +/** + * The writes to one key, one at a time, from every save and rollback, across providers. A write is only + * started once the one before it has reached durable storage, so a slower write cannot complete after a newer one + * and overwrite it, and a rollback cannot land after a newer write has started. A write that fails does not hold up + * the ones behind it. + */ +const settingWriteQueues = new Map>() + +const queuedSettingWrite = (key: AppearanceSettingKey, write: () => Promise): Promise => { + const run = (settingWriteQueues.get(key) ?? Promise.resolve()).then(async () => { + await write() + }) + const tail = run.catch(() => undefined) + settingWriteQueues.set(key, tail) + void tail.then(() => { + if (settingWriteQueues.get(key) === tail) settingWriteQueues.delete(key) + }) + return run +} + +/** @internal test hook: how much is kept about writes. It is nothing once the saves are over. */ +export const settingWriteBookkeepingSize = () => ({ + latestWrites: lastWriteOfSetting.size, + queuedKeys: settingWriteQueues.size, +}) + +/** + * Stores a recorded write, in line behind the writes of its key. What it replaces is read when it starts, after the + * writes before it have reached storage, unless a rolled-back write handed its own value over. + */ +export const performSettingWrite = (contextProxy: ContextProxy, write: SettingWrite): Promise => + queuedSettingWrite(write.key, () => { + if (!write.inherited) write.previous = contextProxy.getValue(write.key as keyof RooCodeSettings) + write.started = true + return contextProxy.setValue(write.key as keyof RooCodeSettings, write.wrote as never) + }) + +/** Takes a write out of its chain, joining the writes on either side of it. */ +const unlinkSettingWrite = (write: SettingWrite) => { + if (write.prev) write.prev.next = write.next + if (write.next) write.next.prev = write.prev + write.prev = undefined + write.next = undefined +} + +/** + * The save that made these writes succeeded. Nothing is kept about them once they are over. The value is committed, + * so it is what a later write that has not started yet replaces, and the writes on either side of it no longer hand + * values to each other: an earlier write that fails now has nothing left to put back behind it. + */ +export const finishSettingWrites = (writes: SettingWrite[]) => { + for (const write of writes) { + write.done = true + if (lastWriteOfSetting.get(write.key) === write) lastWriteOfSetting.delete(write.key) + + const { next } = write + if (next && !next.done) { + if (!next.started) { + next.previous = write.wrote + next.inherited = true + } + next.prev = undefined + } + if (write.prev) write.prev.next = undefined + write.prev = undefined + write.next = undefined + } +} + +/** Puts a failed write back, unless another write has superseded it (see `lastWriteOfSetting`). */ +export const undoSettingWrite = async (contextProxy: ContextProxy, write: SettingWrite) => { + const { key } = write + write.done = true + + if (lastWriteOfSetting.get(key) !== write) { + // A newer write exists. Make it restore the value from before this one if it fails as well. + if (write.next && !write.next.done) { + write.next.previous = write.previous + write.next.inherited = true + } + unlinkSettingWrite(write) + return + } + + // The write before this one is pending again: it is the latest, and the value now stored is its value. + const { prev } = write + if (prev && !prev.done) lastWriteOfSetting.set(key, prev) + else lastWriteOfSetting.delete(key) + unlinkSettingWrite(write) + + await queuedSettingWrite(key, async () => { + if (contextProxy.getValue(key as keyof RooCodeSettings) === write.wrote) { + await contextProxy.setValue(key as keyof RooCodeSettings, write.previous as never) + } + }) +} diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 9882039ad3..4c0701dc42 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -19,6 +19,14 @@ import { TelemetryService } from "@roo-code/telemetry" import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" +import { + APPEARANCE_SETTING_KEYS, + beginSettingWrite, + finishSettingWrites, + isAppearanceSetting, + performSettingWrite, + undoSettingWrite, +} from "./appearanceSettingWrites" import { downgradeLegacyRooConfig, ROUTER_REMOVAL_IMPORT_WARNING } from "./routerRemoval" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { t } from "../../i18n" @@ -228,7 +236,7 @@ export async function importSettingsFromPath( // They will be imported automatically with the config - no special handling needed await providerSettingsManager.import(providerProfiles) - await contextProxy.setValues(sanitizedGlobalSettings) + await setValuesThroughWriteChain(contextProxy, sanitizedGlobalSettings) // Set the current provider. const currentProviderName = providerProfiles.currentApiConfigName @@ -264,6 +272,46 @@ export async function importSettingsFromPath( } } +/** + * Writes global settings. The appearance values go through the same per-key write chain as saves, so a save that + * fails and rolls back does not undo a value the import stored meanwhile, even when it is the same value. The import + * is not rolled back as a whole, as before: a write that succeeded is final. A write that failed is undone, not + * finished, so it leaves the chain the way a failed save does and an earlier save that is still pending keeps its own + * rollback. + */ +async function setValuesThroughWriteChain(contextProxy: ContextProxy, settings: GlobalSettings) { + const keys = APPEARANCE_SETTING_KEYS.filter((key) => key in settings) + if (keys.length === 0) return contextProxy.setValues(settings) + + const writes = keys.map((key) => beginSettingWrite(key, contextProxy.getValue(key), settings[key])) + const others = Object.fromEntries( + Object.entries(settings).filter(([key]) => !isAppearanceSetting(key)), + ) as GlobalSettings + + // Every write is awaited before any is finished or undone, so none is still running when it leaves the chain. + const [othersResult, ...writeResults] = await Promise.allSettled([ + contextProxy.setValues(others), + ...writes.map((write) => performSettingWrite(contextProxy, write)), + ]) + + let failure = othersResult.status === "rejected" ? othersResult : undefined + for (const [index, write] of writes.entries()) { + const result = writeResults[index] + if (result.status === "fulfilled") { + finishSettingWrites([write]) + continue + } + failure ??= result + try { + await undoSettingWrite(contextProxy, write) + } catch (restoreError) { + console.error(`Failed to restore setting "${write.key}" after import failure:`, restoreError) + } + } + + if (failure) throw failure.reason +} + /** * Import settings from a file using a file dialog * @param options - Import options containing managers and proxy diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 39c6184d92..6bdf28ba33 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2860,6 +2860,10 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, enterBehavior, cloudUserInfo, cloudIsAuthenticated, @@ -3041,6 +3045,10 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, @@ -3274,6 +3282,10 @@ export class ClineProvider historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, chatFontSize: stateValues.chatFontSize, + interfaceFontScale: stateValues.interfaceFontScale, + terminalFontSize: stateValues.terminalFontSize, + editorFontSize: stateValues.editorFontSize, + spacingScale: stateValues.spacingScale, enterBehavior: stateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 779313d385..8f448635a9 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2119,6 +2119,88 @@ describe("ClineProvider", () => { expect(state.allowedReadFiles).toEqual([]) }) + describe("appearance settings", () => { + const keys = ["interfaceFontScale", "terminalFontSize", "editorFontSize", "spacingScale"] as const + const values = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 } + + /** Saves through the real `updateSettings` handler, as the settings view does. */ + const save = async (updatedSettings: Record) => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = vi.mocked(mockWebviewView.webview.onDidReceiveMessage).mock.calls[0][0] + await messageHandler({ type: "updateSettings", updatedSettings }) + } + + test.each(keys)("%s is unset when it was never saved, in both states", async (key) => { + await provider.resolveWebviewView(mockWebviewView) + + expect((await provider.getState())[key]).toBeUndefined() + expect((await provider.getStateToPostToWebview())[key]).toBeUndefined() + }) + + test.each(keys)("%s that was stored is returned by both states", async (key) => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue(key, values[key]) + + expect((await provider.getState())[key]).toBe(values[key]) + expect((await provider.getStateToPostToWebview())[key]).toBe(values[key]) + }) + + test("all four are returned together, each with its own value", async () => { + await provider.resolveWebviewView(mockWebviewView) + for (const key of keys) await provider.contextProxy.setValue(key, values[key]) + + expect(await provider.getState()).toMatchObject(values) + expect(await provider.getStateToPostToWebview()).toMatchObject(values) + }) + + test.each(keys)("a reset of %s (null) is kept, not replaced by a default", async (key) => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue(key, values[key]) + await provider.contextProxy.setValue(key, null) + + expect((await provider.getState())[key]).toBeNull() + expect((await provider.getStateToPostToWebview())[key]).toBeNull() + }) + + test("saving through updateSettings stores the values and tells the open webview", async () => { + mockPostMessage.mockClear() + + await save(values) + + for (const key of keys) expect(mockContext.globalState.update).toHaveBeenCalledWith(key, values[key]) + expect(await provider.getStateToPostToWebview()).toMatchObject(values) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "state", state: expect.objectContaining(values) }), + ) + }) + + test("saving a reset stores null and tells the open webview", async () => { + await save(values) + mockPostMessage.mockClear() + + await save({ interfaceFontScale: null, terminalFontSize: null, editorFontSize: null, spacingScale: null }) + + expect(await provider.getStateToPostToWebview()).toMatchObject({ + interfaceFontScale: null, + terminalFontSize: null, + editorFontSize: null, + spacingScale: null, + }) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ interfaceFontScale: null, spacingScale: null }), + }), + ) + }) + + test("a value the webview should not be able to store is refused, and nothing changes", async () => { + await save({ interfaceFontScale: 999 }) + + expect((await provider.getState()).interfaceFontScale).toBeUndefined() + }) + }) + test("getState returns the saved allowed write files", async () => { await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"]) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..659e60e7a3 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -82,6 +82,7 @@ import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" import * as copilotProvider from "../../../api/providers/github-copilot" import { webviewMessageHandler } from "../webviewMessageHandler" +import { settingWriteBookkeepingSize } from "../../config/appearanceSettingWrites" import { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" import type { ClineProvider } from "../ClineProvider" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" @@ -1853,6 +1854,748 @@ describe("webviewMessageHandler - message dialog preferences", () => { }) }) +describe("webviewMessageHandler - the appearance settings are saved all-or-nothing", () => { + const stored: Record = {} + + type WriteHook = (key: string, value: unknown) => Promise | void + + /** + * Makes the write of one key fail. `beforeThrow` can hold the failure back (to let another save run) or + * change what is stored, as a store whose cache was already updated when the write failed. + */ + const failWriteOf = (key: string, value: unknown, beforeThrow?: WriteHook) => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (k: string, v: unknown) => { + if (k === key && v === value) { + await beforeThrow?.(k, v) + throw new Error("storage unavailable") + } + return real(k as never, v as never) + }) + } + + const save = (updatedSettings: Record, requestId: string | null = "save-1") => + webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: requestId ?? undefined, + updatedSettings, + }) + + beforeEach(() => { + vi.clearAllMocks() + for (const key of Object.keys(stored)) delete stored[key] + Object.assign(stored, { interfaceFontScale: 100, terminalFontSize: 12, soundEnabled: false }) + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => stored[key] as never) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + stored[key] = value + }) + vi.mocked(mockClineProvider.postStateToWebview).mockResolvedValue(undefined as never) + }) + + it("stores every setting and confirms the save by request id", async () => { + await save({ interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 }) + + expect(stored).toMatchObject({ + interfaceFontScale: 120, + terminalFontSize: 14, + editorFontSize: 15, + spacingScale: 80, + }) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("puts back the values already stored when a later one fails, and reports the failure", async () => { + failWriteOf("editorFontSize", 15) + + await save({ interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15 }) + + expect(stored).toMatchObject({ interfaceFontScale: 100, terminalFontSize: 12 }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: false, + error: "storage unavailable", + }) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Save failed")) + // The webview is re-synced with what is really stored: after the values were put back, not before. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + const lastWrite = Math.max(...vi.mocked(mockClineProvider.contextProxy.setValue).mock.invocationCallOrder) + expect(vi.mocked(mockClineProvider.postStateToWebview).mock.invocationCallOrder[0]).toBeGreaterThan(lastWrite) + }) + + it("puts back the value whose own write failed after it had changed the stored value", async () => { + // As a store whose cache is updated before its write to disk fails. + failWriteOf("terminalFontSize", 14, (key, value) => { + stored[key] = value + }) + + await save({ terminalFontSize: 14 }) + + expect(stored.terminalFontSize).toBe(12) + }) + + it("restores in reverse order, so the first stored value is restored last", async () => { + const order: string[] = [] + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + order.push(key) + if (key === "spacingScale" && value === 80) throw new Error("boom") + return real(key as never, value as never) + }) + + await save({ interfaceFontScale: 120, terminalFontSize: 14, spacingScale: 80 }) + + expect(stored).toMatchObject({ interfaceFontScale: 100, terminalFontSize: 12 }) + // Three writes forward (the last one fails and never changed the stored value, so there is nothing to put + // back for it), then the restores in the opposite order. + expect(order).toEqual([ + "interfaceFontScale", + "terminalFontSize", + "spacingScale", + "terminalFontSize", + "interfaceFontScale", + ]) + }) + + it("clears a value again that had no stored value before the save", async () => { + failWriteOf("terminalFontSize", 14) + + await save({ editorFontSize: 15, terminalFontSize: 14 }) + + expect(stored.editorFontSize).toBeUndefined() + }) + + describe("two saves at once", () => { + it("a failed save does not undo another save's different value", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("terminalFontSize", 14, () => held) + + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + await save({ interfaceFontScale: 150 }, "save-b") + + releaseFailure() + await a + + expect(stored.interfaceFontScale).toBe(150) + }) + + it("a failed save does not undo another save that wrote the same value", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("terminalFontSize", 14, () => held) + + // Save A stores interfaceFontScale 120, then waits at its failing write. + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + + // Save B stores the same value for the same key, and finishes. + await save({ interfaceFontScale: 120 }, "save-b") + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-b", + success: true, + }) + + // Save A now fails and rolls back. B's write of the same value must stand. + releaseFailure() + await a + + expect(stored.interfaceFontScale).toBe(120) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "save-a", success: false }), + ) + }) + + /** Two saves that both write the same key and then fail, each held at its own failing write. */ + const twoFailingSaves = async (releaseFirst: "a" | "b") => { + const gates: Record<"a" | "b", () => void> = { a: () => {}, b: () => {} } + const heldA = new Promise((resolve) => (gates.a = resolve)) + const heldB = new Promise((resolve) => (gates.b = resolve)) + failWriteOf("terminalFontSize", 14, () => heldA) + failWriteOf("editorFontSize", 15, () => heldB) + + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + const b = save({ interfaceFontScale: 150, editorFontSize: 15 }, "save-b") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(150)) + + const [first, second] = releaseFirst === "a" ? (["a", "b"] as const) : (["b", "a"] as const) + gates[first]() + await (first === "a" ? a : b) + const afterFirst = stored.interfaceFontScale + gates[second]() + await (second === "a" ? a : b) + return { afterFirst, final: stored.interfaceFontScale } + } + + it("puts back the value from before both saves when both fail, the earlier one failing first", async () => { + const { afterFirst, final } = await twoFailingSaves("a") + + // The later save is still pending with 150, so the first failure leaves it. Then it restores 100. + expect(afterFirst).toBe(150) + expect(final).toBe(100) + }) + + it("puts back the value from before both saves when both fail, the later one failing first", async () => { + const { afterFirst, final } = await twoFailingSaves("b") + + // The later save restores the earlier one's pending value, and the earlier one then restores 100. + expect(afterFirst).toBe(120) + expect(final).toBe(100) + }) + + it("puts back the original value when three overlapping saves all fail, the middle one first", async () => { + const release: Record<"a" | "b" | "c", () => void> = { a: () => {}, b: () => {}, c: () => {} } + const gate = (name: "a" | "b" | "c") => new Promise((resolve) => (release[name] = resolve)) + const gates = { a: gate("a"), b: gate("b"), c: gate("c") } + failWriteOf("terminalFontSize", 14, () => gates.a) + failWriteOf("editorFontSize", 15, () => gates.b) + failWriteOf("spacingScale", 80, () => gates.c) + + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + const b = save({ interfaceFontScale: 150, editorFontSize: 15 }, "save-b") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(150)) + const c = save({ interfaceFontScale: 180, spacingScale: 80 }, "save-c") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(180)) + + // The middle save fails first: the newest is still pending, so the value stays. + release.b() + await b + expect(stored.interfaceFontScale).toBe(180) + + // The newest fails: it goes back to the oldest save's pending value, not to the middle one's. + release.c() + await c + expect(stored.interfaceFontScale).toBe(120) + + // The oldest fails: back to the value from before all three. + release.a() + await a + expect(stored.interfaceFontScale).toBe(100) + }) + + it("keeps the earlier save's value when the later one fails and the earlier one succeeds", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("editorFontSize", 15, () => held) + + const later = (async () => { + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + return save({ interfaceFontScale: 150, editorFontSize: 15 }, "save-b") + })() + // The earlier save writes first and finishes while the later one is held. + await save({ interfaceFontScale: 120 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(150)) + + releaseFailure() + await later + + expect(stored.interfaceFontScale).toBe(120) + }) + + it("keeps a value changed outside a save, such as an import, when a failed save rolls back", async () => { + failWriteOf("terminalFontSize", 14, () => { + stored.interfaceFontScale = 90 // an import writes the same key meanwhile + }) + + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + + expect(stored.interfaceFontScale).toBe(90) + }) + + it("a later failure of the other save still puts its own writes back", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("editorFontSize", 15, () => held) + + // Save A holds at its failing write, and has not touched spacingScale. + const a = save({ terminalFontSize: 14, editorFontSize: 15 }, "save-a") + await vi.waitFor(() => expect(stored.terminalFontSize).toBe(14)) + // Save B writes a different key and finishes. + await save({ spacingScale: 80 }, "save-b") + + releaseFailure() + await a + + expect(stored.terminalFontSize).toBe(12) // A's own write is put back + expect(stored.spacingScale).toBe(80) // B's is untouched + }) + }) + + describe("writes to one setting are ordered, down to durable storage", () => { + /** + * A store whose writes reach durable storage later than they are started: each write holds until it is + * released, and the events record when it started and when it reached storage. + */ + const slowStore = () => { + const events: string[] = [] + const gates = new Map void>() + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation( + async (key: string, value: unknown) => { + events.push(`start ${key}=${String(value)}`) + await new Promise((resolve) => gates.set(`${key}=${String(value)}`, resolve)) + stored[key] = value + events.push(`end ${key}=${String(value)}`) + }, + ) + const release = async (what: string) => { + await vi.waitFor(() => expect(gates.has(what)).toBe(true)) + gates.get(what)!() + gates.delete(what) + } + return { events, release } + } + + it("does not start a newer save's write of a key before the older one has reached storage", async () => { + const { events, release } = slowStore() + + const a = save({ interfaceFontScale: 120 }, "save-a") + await vi.waitFor(() => expect(events).toContain("start interfaceFontScale=120")) + const b = save({ interfaceFontScale: 150 }, "save-b") + // Give the second save every chance to start its write: it must wait for the first. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(events).not.toContain("start interfaceFontScale=150") + + await release("interfaceFontScale=120") + await vi.waitFor(() => expect(events).toContain("start interfaceFontScale=150")) + await release("interfaceFontScale=150") + await Promise.all([a, b]) + + // The newer value reached storage last, so it is the one that stays, and both saves succeeded. + expect(events).toEqual([ + "start interfaceFontScale=120", + "end interfaceFontScale=120", + "start interfaceFontScale=150", + "end interfaceFontScale=150", + ]) + expect(stored.interfaceFontScale).toBe(150) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-b", + success: true, + }) + }) + + it("a failed save restores what the save before it wrote, even if that was still queued when it registered", async () => { + const { events, release } = slowStore() + + // Q is writing, P waits behind it, and A registers while P has not started its write. + const q = save({ interfaceFontScale: 110 }, "save-q") + await vi.waitFor(() => expect(events).toContain("start interfaceFontScale=110")) + const p = save({ interfaceFontScale: 130 }, "save-p") + await new Promise((resolve) => setTimeout(resolve, 10)) + failWriteOf("terminalFontSize", 14) + const a = save({ interfaceFontScale: 150, terminalFontSize: 14 }, "save-a") + await new Promise((resolve) => setTimeout(resolve, 10)) + + await release("interfaceFontScale=110") + await release("interfaceFontScale=130") + await release("interfaceFontScale=150") + // A fails on terminalFontSize, and its restore of the interface font scale is a write too. + await release("interfaceFontScale=130") + await Promise.all([q, p, a]) + + // P succeeded, so A's failure puts back P's value, not Q's. + expect(stored.interfaceFontScale).toBe(130) + }) + + it("does not make writes of different keys wait for each other", async () => { + const { events, release } = slowStore() + + const a = save({ interfaceFontScale: 120 }, "save-a") + await vi.waitFor(() => expect(events).toContain("start interfaceFontScale=120")) + const b = save({ terminalFontSize: 14 }, "save-b") + + await vi.waitFor(() => expect(events).toContain("start terminalFontSize=14")) + await release("terminalFontSize=14") + await b + expect(stored.terminalFontSize).toBe(14) + + await release("interfaceFontScale=120") + await a + }) + + it("lets the next write through when one has failed", async () => { + let first = true + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation( + async (key: string, value: unknown) => { + if (first && key === "interfaceFontScale") { + first = false + throw new Error("storage unavailable") + } + return real(key as never, value as never) + }, + ) + + await save({ interfaceFontScale: 120 }, "save-a") + await save({ interfaceFontScale: 150 }, "save-b") + + expect(stored.interfaceFontScale).toBe(150) + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith( + expect.objectContaining({ requestId: "save-b", success: true }), + ) + }) + + it("also queues the write that puts a value back, behind the writes before it", async () => { + const events: string[] = [] + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + let releaseRestore!: () => void + const restoreHeld = new Promise((resolve) => (releaseRestore = resolve)) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation( + async (key: string, value: unknown) => { + events.push(`start ${key}=${String(value)}`) + if (key === "terminalFontSize" && value === 14) throw new Error("storage unavailable") + // Putting interfaceFontScale back is slow to reach storage. + if (key === "interfaceFontScale" && value === 100) await restoreHeld + await real(key as never, value as never) + events.push(`end ${key}=${String(value)}`) + }, + ) + + // Save A writes interfaceFontScale, then fails: its restore of 100 is held. + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(events).toContain("start interfaceFontScale=100")) + // Save B writes the same key while the restore is still on its way to storage. + const b = save({ interfaceFontScale: 150 }, "save-b") + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(events).not.toContain("start interfaceFontScale=150") + + releaseRestore() + await Promise.all([a, b]) + + expect(events.indexOf("end interfaceFontScale=100")).toBeLessThan( + events.indexOf("start interfaceFontScale=150"), + ) + expect(stored.interfaceFontScale).toBe(150) + }) + }) + + describe("nothing is kept about writes once the saves are over", () => { + const settled = () => new Promise((resolve) => setTimeout(resolve, 0)) + + it("keeps nothing after a save that succeeded", async () => { + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + await settled() + + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps nothing after a save that failed and was rolled back", async () => { + failWriteOf("terminalFontSize", 14) + + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + await settled() + + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps nothing after overlapping saves, whichever way they end", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("editorFontSize", 15, () => held) + + // The older save succeeds first; the newer one then fails. + const newer = (async () => { + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + return save({ interfaceFontScale: 150, editorFontSize: 15 }, "save-b") + })() + await save({ interfaceFontScale: 120 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(150)) + releaseFailure() + await newer + await settled() + + expect(stored.interfaceFontScale).toBe(120) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps nothing after three overlapping saves that all fail", async () => { + const release: Record<"a" | "b" | "c", () => void> = { a: () => {}, b: () => {}, c: () => {} } + const gate = (name: "a" | "b" | "c") => new Promise((resolve) => (release[name] = resolve)) + const gates = { a: gate("a"), b: gate("b"), c: gate("c") } + failWriteOf("terminalFontSize", 14, () => gates.a) + failWriteOf("editorFontSize", 15, () => gates.b) + failWriteOf("spacingScale", 80, () => gates.c) + + const a = save({ interfaceFontScale: 120, terminalFontSize: 14 }, "save-a") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(120)) + const b = save({ interfaceFontScale: 150, editorFontSize: 15 }, "save-b") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(150)) + const c = save({ interfaceFontScale: 180, spacingScale: 80 }, "save-c") + await vi.waitFor(() => expect(stored.interfaceFontScale).toBe(180)) + release.b() + await b + release.c() + await c + release.a() + await a + await settled() + + expect(stored.interfaceFontScale).toBe(100) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + }) + + it("keeps going and still reports the failure when a restore itself fails", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "terminalFontSize" && value === 14) throw new Error("terminalFontSize is broken") + if (key === "interfaceFontScale" && value === 100) throw new Error("cannot restore") + return real(key as never, value as never) + }) + + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Could not restore interfaceFontScale"), + ) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "save-1", success: false }), + ) + }) + + it("reports a restore failure that is not an Error", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "terminalFontSize" && value === 14) throw new Error("terminalFontSize is broken") + if (key === "interfaceFontScale" && value === 100) throw "plain restore failure" + return real(key as never, value as never) + }) + + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("plain restore failure")) + }) + + describe("values from the webview are checked before anything is done", () => { + const bounds = { + interfaceFontScale: { min: 50, max: 200 }, + terminalFontSize: { min: 8, max: 32 }, + editorFontSize: { min: 8, max: 32 }, + spacingScale: { min: 50, max: 200 }, + // Not an appearance value of the transaction, but shown by the same preview and checked in the same pass. + chatFontSize: { min: 8, max: 32 }, + } as const + const keys = Object.keys(bounds) as Array + + it.each( + keys.flatMap((key) => + [ + ["below the minimum", bounds[key].min - 1], + ["above the maximum", bounds[key].max + 1], + ["zero", 0], + ["negative", -10], + ["fractional", bounds[key].min + 0.5], + ["not a number", Number.NaN], + ["infinite", Number.POSITIVE_INFINITY], + ["a string", "20"], + ["a boolean", true], + ["an object", { value: 20 }], + ["an array", [20]], + ].map(([name, value]) => [key, name, value] as const), + ), + )("rejects %s when it is %s", async (key, _name, value) => { + await save({ [key]: value }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: false, + error: `Invalid value for ${key}`, + }) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + }) + + it("changes nothing at all when one value is invalid, not even the valid ones sent with it", async () => { + vi.mocked(ensureDcgInstalled).mockClear() + + await save({ + interfaceFontScale: 120, + terminalFontSize: 500, + soundEnabled: true, + destructiveCommandGuardEnabled: true, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(stored).toMatchObject({ interfaceFontScale: 100, soundEnabled: false }) + // No side effect ran either. + expect(ensureDcgInstalled).not.toHaveBeenCalled() + }) + + it("does not echo the invalid value back", async () => { + await save({ terminalFontSize: "x".repeat(10_000) }) + + const posted = JSON.stringify(vi.mocked(mockClineProvider.postMessageToWebview).mock.calls) + expect(posted).not.toContain("xxxxxxxxxx") + }) + + it.each(keys.flatMap((key) => [bounds[key].min, bounds[key].max].map((value) => [key, value] as const)))( + "accepts %s at its bound %s", + async (key, value) => { + await save({ [key]: value }) + + expect(stored[key]).toBe(value) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ) + }, + ) + + it.each(keys)("accepts a reset of %s (null)", async (key) => { + await save({ [key]: null }) + + expect(stored[key]).toBeNull() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ) + }) + + it("does not check settings that are not appearance values", async () => { + await save({ soundEnabled: true, writeDelayMs: -5 }) + + expect(stored).toMatchObject({ soundEnabled: true, writeDelayMs: -5 }) + }) + }) + + describe("a failure while writing another setting", () => { + it("puts the appearance values back, tells the webview, and then follows the existing error path", async () => { + failWriteOf("soundEnabled", true) + + await expect(save({ terminalFontSize: 14, soundEnabled: true })).rejects.toThrow("storage unavailable") + + expect(stored.terminalFontSize).toBe(12) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: false, + error: "storage unavailable", + }) + // No toast: the existing path did not show one for these settings. + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("does not reject for a failure of an appearance value, which is reported to the user instead", async () => { + failWriteOf("terminalFontSize", 14) + + await expect(save({ terminalFontSize: 14 })).resolves.toBeUndefined() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + }) + }) + + it("reports a non-Error failure by its text", async () => { + vi.mocked(mockClineProvider.contextProxy.setValue).mockRejectedValue("plain string failure") + + await save({ interfaceFontScale: 120 }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ success: false, error: "plain string failure" }), + ) + }) + + it("leaves every other setting as it was written: it is not put back when a later write fails", async () => { + failWriteOf("terminalFontSize", 14) + + await save({ soundEnabled: true, terminalFontSize: 14 }) + + // Not an appearance value, so it keeps its existing behaviour. The failure is still reported. + expect(stored.soundEnabled).toBe(true) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "save-1", success: false }), + ) + }) + + it("still saves and refreshes, without a result message, when the request has no id", async () => { + await save({ interfaceFontScale: 120 }, null) + + expect(stored.interfaceFontScale).toBe(120) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("does not report a failure to a request without an id either", async () => { + failWriteOf("interfaceFontScale", 120) + + await save({ interfaceFontScale: 120 }, null) + + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + it("does nothing when there are no settings to save", async () => { + await webviewMessageHandler(mockClineProvider, { type: "updateSettings", requestId: "save-6" }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each(["experiments", "customSupportPrompts"])("stores nothing for an empty %s value", async (key) => { + await save({ [key]: undefined, interfaceFontScale: 120 }) + + expect(stored).not.toHaveProperty(key) + expect(stored.interfaceFontScale).toBe(120) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "save-1", success: true }), + ) + }) + + describe("when the state refresh fails", () => { + it("keeps a stored save and still reports success", async () => { + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce(new Error("state unavailable")) + + await save({ interfaceFontScale: 120, terminalFontSize: 14 }) + + expect(stored).toMatchObject({ interfaceFontScale: 120, terminalFontSize: 14 }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: true, + }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("state unavailable")) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("logs a refresh failure that is not an Error", async () => { + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce("plain refresh failure") + + await save({ interfaceFontScale: 120 }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("plain refresh failure")) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "save-1", success: true }), + ) + }) + + it("still reports a failed save", async () => { + failWriteOf("interfaceFontScale", 120) + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce(new Error("state unavailable")) + + await save({ interfaceFontScale: 120 }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: false, + error: "storage unavailable", + }) + }) + }) +}) + describe("webviewMessageHandler - mcpEnabled", () => { let mockMcpHub: any diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..136febeda4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -30,6 +30,7 @@ import { RouterModelsMessageType, VsCodeLmModelsMessageType, isTelemetryOptedIn, + globalSettingsSchema, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -37,6 +38,14 @@ import { TelemetryService } from "@roo-code/telemetry" import { type ApiMessage } from "../task-persistence/apiMessages" import { saveTaskMessages } from "../task-persistence" +import { + beginSettingWrite, + finishSettingWrites, + isAppearanceSetting, + performSettingWrite, + undoSettingWrite, + type SettingWrite, +} from "../config/appearanceSettingWrites" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" import { ClineProvider } from "./ClineProvider" @@ -130,6 +139,20 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +/** + * What the webview may send for each appearance value: the schema the setting is defined with (an integer within + * its bounds, or null/unset for a reset). The webview is not trusted, and its own limits (the stepper clamps the + * numbers) do not bind a message that was written by hand. + */ +const APPEARANCE_VALUE_SCHEMAS = { + interfaceFontScale: globalSettingsSchema.shape.interfaceFontScale, + terminalFontSize: globalSettingsSchema.shape.terminalFontSize, + editorFontSize: globalSettingsSchema.shape.editorFontSize, + spacingScale: globalSettingsSchema.shape.spacingScale, + // Shown by the appearance preview next to these, so it is checked in the same pass. + chatFontSize: globalSettingsSchema.shape.chatFontSize, +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -737,8 +760,32 @@ export const webviewMessageHandler = async ( } break - case "updateSettings": - if (message.updatedSettings) { + case "updateSettings": { + // The appearance values are saved all-or-nothing: if a write fails, the ones this save already stored are + // put back, so the webview can keep the user's edits and let them retry. A key is recorded before it is + // written, so a write that failed after changing the stored value is put back too (see `undoSettingWrite` + // for when a key is left alone). A failure while writing any other setting is not handled here: it + // follows the existing error path, after the appearance values have been put back. + const written: SettingWrite[] = [] + let failure: string | undefined + let failedKey: string | undefined + let unhandledFailure: unknown + + try { + if (!message.updatedSettings) break + + // Checked before anything else happens, so an invalid value changes nothing: no side effect runs and + // no setting is stored, not even the valid ones sent with it. + for (const [key, schema] of Object.entries(APPEARANCE_VALUE_SCHEMAS)) { + if ( + key in message.updatedSettings && + !schema.safeParse(Reflect.get(message.updatedSettings, key)).success + ) { + failedKey = key + throw new Error(`Invalid value for ${key}`) + } + } + if (message.updatedSettings.destructiveCommandGuardEnabled === true) { try { const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") @@ -758,6 +805,8 @@ export const webviewMessageHandler = async ( } for (const [key, value] of Object.entries(message.updatedSettings)) { + failedKey = key + const previous = provider.contextProxy.getValue(key as keyof RooCodeSettings) let newValue = value if (key === "language") { @@ -867,13 +916,66 @@ export const webviewMessageHandler = async ( } } + if (isAppearanceSetting(key)) { + const write = beginSettingWrite(key, previous, newValue) + written.push(write) + await performSettingWrite(provider.contextProxy, write) + continue + } + await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } + finishSettingWrites(written) + } catch (error) { + failure = error instanceof Error ? error.message : String(error) + + for (const write of written.reverse()) { + try { + await undoSettingWrite(provider.contextProxy, write) + } catch (rollbackError) { + provider.log( + `[updateSettings] Could not restore ${write.key}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + } + + provider.log(`[updateSettings] Save failed: ${failure}`) + + if ( + failedKey !== undefined && + (isAppearanceSetting(failedKey) || failedKey in APPEARANCE_VALUE_SCHEMAS) + ) { + vscode.window.showErrorMessage(t("common:errors.settings_save_failed", { error: failure })) + } else { + // Not an appearance value: handled as it always was, once the webview has been told. + unhandledFailure = error + } + } + + // The outcome is decided above. A failed refresh must neither undo a stored save nor keep the webview + // from receiving the result: it waits for it to re-enable Save. + try { await provider.postStateToWebview() + } catch (error) { + provider.log( + `[updateSettings] Could not refresh the webview state: ${error instanceof Error ? error.message : String(error)}`, + ) } + if (message.requestId) { + await provider.postMessageToWebview({ + type: "settingsSaveResult", + requestId: message.requestId, + success: failure === undefined, + ...(failure === undefined ? {} : { error: failure }), + }) + } + + if (unhandledFailure !== undefined) throw unhandledFailure + break + } case "terminalOperation": if (message.terminalOperation) { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 579cd6b7d7..16561c5bf7 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "No es pot accedir a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta és fora de l'espai de treball", "settings_import_failed": "Ha fallat la importació de la configuració: {{error}}.", + "settings_save_failed": "No s'ha pogut desar la configuració. Alguns valors poden haver quedat parcialment canviats: {{error}}", "mistake_limit_guidance": "Això pot indicar un error en el procés de pensament del model o la incapacitat d'utilitzar una eina correctament, que es pot mitigar amb orientació de l'usuari (p. ex. \"Prova de dividir la tasca en passos més petits\").", "violated_organization_allowlist": "Ha fallat l'execució de la tasca: el perfil actual no és compatible amb la configuració de la teva organització", "condense_failed": "Ha fallat la condensació del context", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 3d8175a77e..4eac519f9b 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Zugriff auf Pfad {{path}} nicht möglich: {{error}}", "path_outside_workspace": "Pfad liegt außerhalb des Arbeitsbereichs", "settings_import_failed": "Fehler beim Importieren der Einstellungen: {{error}}.", + "settings_save_failed": "Die Einstellungen konnten nicht gespeichert werden. Einige könnten teilweise geändert geblieben sein: {{error}}", "mistake_limit_guidance": "Dies kann auf einen Fehler im Denkprozess des Modells oder die Unfähigkeit hinweisen, ein Tool richtig zu verwenden, was durch Benutzerführung behoben werden kann (z.B. \"Versuche, die Aufgabe in kleinere Schritte zu unterteilen\").", "violated_organization_allowlist": "Aufgabe konnte nicht ausgeführt werden: Das aktuelle Profil ist nicht kompatibel mit den Einstellungen deiner Organisation", "condense_failed": "Fehler beim Verdichten des Kontexts", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a7d616028f..ea4ca74c7d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Cannot access path {{path}}: {{error}}", "path_outside_workspace": "Path is outside the workspace", "settings_import_failed": "Settings import failed: {{error}}.", + "settings_save_failed": "Settings could not be saved. Some may be left partly changed: {{error}}", "mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").", "violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings", "condense_failed": "Failed to condense context", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2eb6848f11..4f4b6fd4c6 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "No se puede acceder a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta está fuera del espacio de trabajo", "settings_import_failed": "Error al importar la configuración: {{error}}.", + "settings_save_failed": "No se pudieron guardar los ajustes. Algunos pueden haber quedado parcialmente modificados: {{error}}", "mistake_limit_guidance": "Esto puede indicar un fallo en el proceso de pensamiento del modelo o la incapacidad de usar una herramienta correctamente, lo cual puede mitigarse con orientación del usuario (ej. \"Intenta dividir la tarea en pasos más pequeños\").", "violated_organization_allowlist": "Error al ejecutar la tarea: el perfil actual no es compatible con la configuración de tu organización", "condense_failed": "Error al condensar el contexto", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b6b65a88de..dcdb3c1dd3 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossible d'accéder au chemin {{path}} : {{error}}", "path_outside_workspace": "Le chemin est à l'extérieur de l'espace de travail", "settings_import_failed": "Échec de l'importation des paramètres : {{error}}", + "settings_save_failed": "Impossible d'enregistrer les paramètres. Certains peuvent être restés partiellement modifiés : {{error}}", "mistake_limit_guidance": "Cela peut indiquer un échec dans le processus de réflexion du modèle ou une incapacité à utiliser un outil correctement, ce qui peut être atténué avec des conseils de l'utilisateur (par ex. \"Essaie de diviser la tâche en étapes plus petites\").", "violated_organization_allowlist": "Échec de l'exécution de la tâche : le profil actuel n'est pas compatible avec les paramètres de votre organisation", "condense_failed": "Échec de la condensation du contexte", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f69b5adccf..d4572871f7 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "पाथ {{path}} तक पहुंच नहीं पा रहे हैं: {{error}}", "path_outside_workspace": "पथ वर्कस्पेस से बाहर है", "settings_import_failed": "सेटिंग्स इम्पोर्ट करने में विफल: {{error}}।", + "settings_save_failed": "सेटिंग्स सहेजी नहीं जा सकीं। कुछ सेटिंग्स आंशिक रूप से बदली रह सकती हैं: {{error}}", "mistake_limit_guidance": "यह मॉडल की सोच प्रक्रिया में विफलता या किसी टूल का सही उपयोग न कर पाने का संकेत हो सकता है, जिसे उपयोगकर्ता के मार्गदर्शन से ठीक किया जा सकता है (जैसे \"कार्य को छोटे चरणों में बांटने की कोशिश करें\")।", "violated_organization_allowlist": "कार्य चलाने में विफल: वर्तमान प्रोफ़ाइल आपके संगठन की सेटिंग्स के साथ संगत नहीं है", "condense_failed": "संदर्भ को संक्षिप्त करने में विफल", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index e401612bf3..85abdfaa8b 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Tidak dapat mengakses path {{path}}: {{error}}", "path_outside_workspace": "Path berada di luar workspace", "settings_import_failed": "Impor pengaturan gagal: {{error}}.", + "settings_save_failed": "Pengaturan tidak dapat disimpan. Sebagian mungkin tetap berubah sebagian: {{error}}", "mistake_limit_guidance": "Ini mungkin menunjukkan kegagalan dalam proses pemikiran model atau ketidakmampuan untuk menggunakan tool dengan benar, yang dapat diatasi dengan beberapa panduan pengguna (misalnya \"Coba bagi tugas menjadi langkah-langkah yang lebih kecil\").", "violated_organization_allowlist": "Gagal menjalankan tugas: profil saat ini tidak kompatibel dengan pengaturan organisasi kamu", "condense_failed": "Gagal mengompres konteks", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index aeb984940a..2f570174c6 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossibile accedere al percorso {{path}}: {{error}}", "path_outside_workspace": "Il percorso è fuori dall'area di lavoro", "settings_import_failed": "Importazione delle impostazioni fallita: {{error}}.", + "settings_save_failed": "Impossibile salvare le impostazioni. Alcune potrebbero essere rimaste modificate in parte: {{error}}", "mistake_limit_guidance": "Questo può indicare un fallimento nel processo di pensiero del modello o l'incapacità di utilizzare correttamente uno strumento, che può essere mitigato con la guida dell'utente (ad es. \"Prova a suddividere l'attività in passaggi più piccoli\").", "violated_organization_allowlist": "Impossibile eseguire l'attività: il profilo corrente non è compatibile con le impostazioni della tua organizzazione", "condense_failed": "Impossibile condensare il contesto", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a7ec0c4de3..2fb1c19478 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "パス {{path}} にアクセスできません:{{error}}", "path_outside_workspace": "パスはワークスペースの外にあります", "settings_import_failed": "設定のインポートに失敗しました:{{error}}", + "settings_save_failed": "設定を保存できませんでした。一部の設定が部分的に変更されたままになっている可能性があります:{{error}}", "mistake_limit_guidance": "これは、モデルの思考プロセスの失敗やツールを適切に使用できないことを示している可能性があり、ユーザーのガイダンスによって軽減できます(例:「タスクをより小さなステップに分割してみてください」)。", "violated_organization_allowlist": "タスクの実行に失敗しました: 現在のプロファイルは組織の設定と互換性がありません", "condense_failed": "コンテキストの圧縮に失敗しました", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 906652ac14..32f003633a 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "경로 {{path}}에 접근할 수 없습니다: {{error}}", "path_outside_workspace": "경로가 작업 영역 밖에 있습니다", "settings_import_failed": "설정 가져오기 실패: {{error}}.", + "settings_save_failed": "설정을 저장할 수 없습니다. 일부 설정이 부분적으로 변경된 상태로 남아 있을 수 있습니다: {{error}}", "mistake_limit_guidance": "이는 모델의 사고 과정 실패나 도구를 제대로 사용하지 못하는 것을 나타낼 수 있으며, 사용자 가이드를 통해 완화할 수 있습니다 (예: \"작업을 더 작은 단계로 나누어 시도해보세요\").", "violated_organization_allowlist": "작업 실행 실패: 현재 프로필이 조직 설정과 호환되지 않습니다", "condense_failed": "컨텍스트 압축에 실패했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 34cdbbbe89..823b12b657 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Kan pad {{path}} niet openen: {{error}}", "path_outside_workspace": "Pad is buiten de werkomgeving", "settings_import_failed": "Importeren van instellingen mislukt: {{error}}.", + "settings_save_failed": "De instellingen konden niet worden opgeslagen. Sommige zijn mogelijk gedeeltelijk gewijzigd gebleven: {{error}}", "mistake_limit_guidance": "Dit kan duiden op een fout in het denkproces van het model of het onvermogen om een tool correct te gebruiken, wat kan worden verminderd met gebruikersbegeleiding (bijv. \"Probeer de taak op te delen in kleinere stappen\").", "violated_organization_allowlist": "Taak uitvoeren mislukt: het huidige profiel is niet compatibel met de instellingen van uw organisatie", "condense_failed": "Comprimeren van context mislukt", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a743fc009..9f4b955cab 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Nie można uzyskać dostępu do ścieżki {{path}}: {{error}}", "path_outside_workspace": "Ścieżka znajduje się poza obszarem roboczym", "settings_import_failed": "Nie udało się zaimportować ustawień: {{error}}.", + "settings_save_failed": "Nie udało się zapisać ustawień. Niektóre mogły pozostać częściowo zmienione: {{error}}", "mistake_limit_guidance": "To może wskazywać na błąd w procesie myślowym modelu lub niezdolność do prawidłowego użycia narzędzia, co można złagodzić poprzez wskazówki użytkownika (np. \"Spróbuj podzielić zadanie na mniejsze kroki\").", "violated_organization_allowlist": "Nie udało się uruchomić zadania: bieżący profil nie jest kompatybilny z ustawieniami Twojej organizacji", "condense_failed": "Nie udało się skondensować kontekstu", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index eaf1cae06b..451dfa6cfe 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "Não é possível acessar o caminho {{path}}: {{error}}", "path_outside_workspace": "O caminho está fora do espaço de trabalho", "settings_import_failed": "Falha ao importar configurações: {{error}}", + "settings_save_failed": "Não foi possível salvar as configurações. Algumas podem ter ficado parcialmente alteradas: {{error}}", "mistake_limit_guidance": "Isso pode indicar uma falha no processo de pensamento do modelo ou incapacidade de usar uma ferramenta adequadamente, o que pode ser mitigado com orientação do usuário (ex. \"Tente dividir a tarefa em etapas menores\").", "violated_organization_allowlist": "Falha ao executar a tarefa: o perfil atual não é compatível com as configurações da sua organização", "condense_failed": "Falha ao condensar o contexto", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6f48365ec1..f26457612a 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Невозможно получить доступ к пути {{path}}: {{error}}", "path_outside_workspace": "Путь находится вне рабочего пространства", "settings_import_failed": "Не удалось импортировать настройки: {{error}}.", + "settings_save_failed": "Не удалось сохранить настройки. Некоторые могли остаться частично изменёнными: {{error}}", "mistake_limit_guidance": "Это может указывать на сбой в процессе мышления модели или неспособность правильно использовать инструмент, что можно смягчить с помощью руководства пользователя (например, \"Попробуйте разбить задачу на более мелкие шаги\").", "violated_organization_allowlist": "Не удалось выполнить задачу: текущий профиль несовместим с настройками вашей организации", "condense_failed": "Не удалось сжать контекст", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index b5c543c405..83d54b62a0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "{{path}} yoluna erişilemiyor: {{error}}", "path_outside_workspace": "Yol çalışma alanının dışında", "settings_import_failed": "Ayarlar içe aktarılamadı: {{error}}.", + "settings_save_failed": "Ayarlar kaydedilemedi. Bazıları kısmen değişmiş olarak kalmış olabilir: {{error}}", "mistake_limit_guidance": "Bu, modelin düşünce sürecindeki bir başarısızlığı veya bir aracı düzgün kullanamama durumunu gösterebilir, bu da kullanıcı rehberliği ile hafifletilebilir (örn. \"Görevi daha küçük adımlara bölmeyi deneyin\").", "violated_organization_allowlist": "Görev yürütülemedi: Geçerli profil kuruluşunuzun ayarlarıyla uyumlu değil", "condense_failed": "Bağlam sıkıştırılamadı", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index cb2ed8e9b8..f1bce7743f 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Không thể truy cập đường dẫn {{path}}: {{error}}", "path_outside_workspace": "Đường dẫn nằm ngoài không gian làm việc", "settings_import_failed": "Nhập cài đặt thất bại: {{error}}.", + "settings_save_failed": "Không thể lưu cài đặt. Một số cài đặt có thể vẫn bị thay đổi một phần: {{error}}", "mistake_limit_guidance": "Điều này có thể cho thấy sự thất bại trong quá trình suy nghĩ của mô hình hoặc không thể sử dụng công cụ đúng cách, có thể được giảm thiểu bằng hướng dẫn của người dùng (ví dụ: \"Hãy thử chia nhỏ nhiệm vụ thành các bước nhỏ hơn\").", "violated_organization_allowlist": "Không thể chạy tác vụ: hồ sơ hiện tại không tương thích với cài đặt của tổ chức của bạn", "condense_failed": "Không thể nén ngữ cảnh", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 08cfe736b5..1ddac8f209 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -63,6 +63,7 @@ "cannot_access_path": "无法访问路径 {{path}}:{{error}}", "path_outside_workspace": "路径位于工作区之外", "settings_import_failed": "设置导入失败:{{error}}。", + "settings_save_failed": "无法保存设置。部分设置可能仍处于部分更改的状态:{{error}}", "mistake_limit_guidance": "这可能表明模型思维过程失败或无法正确使用工具,可通过用户指导来缓解(例如\"尝试将任务分解为更小的步骤\")。", "violated_organization_allowlist": "执行任务失败:当前配置文件与您的组织设置不兼容", "condense_failed": "压缩上下文失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92fdbaeb96..d78e73aab1 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "無法存取路徑 {{path}}:{{error}}", "path_outside_workspace": "路徑位於工作區之外", "settings_import_failed": "設定匯入失敗:{{error}}。", + "settings_save_failed": "無法儲存設定。部分設定可能仍處於部分變更的狀態:{{error}}", "mistake_limit_guidance": "這可能表明模型思維過程失敗或無法正確使用工具,可透過使用者指導來緩解(例如「嘗試將工作分解為更小的步驟」)。", "violated_organization_allowlist": "執行工作失敗:目前設定檔與您的組織設定不相容", "condense_failed": "壓縮上下文失敗", diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..7198b17c32 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -415,6 +415,16 @@ export const stories: Record = { ) }, + "appearance-consumers": async () => { + const { AppearanceConsumersStory } = + await import("@/components/settings/__tests__/AppearanceConsumers.visual.fixture") + return + }, + "appearance-preview": async () => { + const { AppearancePreviewStory } = + await import("@/components/settings/__tests__/AppearancePreview.visual.fixture") + return + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3b6a3ca66b..8e664eebbc 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -177,7 +177,7 @@ const ChatRow = memo( const prevHeightRef = useRef(0) const [chatrow, { height }] = useSize( -
+
, ) diff --git a/webview-ui/src/components/chat/QueuedMessages.tsx b/webview-ui/src/components/chat/QueuedMessages.tsx index 5bfb6fdb48..ea8f1a5234 100644 --- a/webview-ui/src/components/chat/QueuedMessages.tsx +++ b/webview-ui/src/components/chat/QueuedMessages.tsx @@ -40,7 +40,7 @@ export const QueuedMessages = ({ queue, onRemove, onUpdate }: QueuedMessagesProp } return ( -
+
{t("queuedMessages.title")}
{queue.map((message, index) => { diff --git a/webview-ui/src/components/chat/TerminalOutput.tsx b/webview-ui/src/components/chat/TerminalOutput.tsx index 78684e3753..80a4870a5f 100644 --- a/webview-ui/src/components/chat/TerminalOutput.tsx +++ b/webview-ui/src/components/chat/TerminalOutput.tsx @@ -59,7 +59,7 @@ export const TerminalOutput: React.FC = ({ content, classNa style={{ fontFamily: "var(--vscode-editor-font-family, 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', monospace)", - fontSize: "var(--vscode-editor-font-size, 13px)", + fontSize: "var(--zoo-terminal-font-size, var(--vscode-editor-font-size, 13px))", lineHeight: "var(--vscode-editor-line-height, 1.4)", whiteSpace: "pre-wrap", wordBreak: "break-word", diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index c9c5d85aa0..c1d07e6bc2 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -150,7 +150,7 @@ export const StyledPre = styled.div<{ white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")}; word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")}; overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")}; - font-size: 0.95em; + font-size: var(--zoo-editor-font-size, 0.95em); font-family: var(--vscode-editor-font-family); } diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 02f696553f..dfc51a7737 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -102,7 +102,7 @@ const StyledMarkdown = styled.div` "Helvetica Neue", sans-serif; - font-size: var(--zoo-chat-font-size, var(--vscode-font-size, 13px)); + font-size: calc(var(--zoo-chat-font-size, var(--vscode-font-size, 13px)) * var(--zoo-ui-scale, 1)); p, li, diff --git a/webview-ui/src/components/settings/AppearancePreview.tsx b/webview-ui/src/components/settings/AppearancePreview.tsx new file mode 100644 index 0000000000..754c07edc8 --- /dev/null +++ b/webview-ui/src/components/settings/AppearancePreview.tsx @@ -0,0 +1,99 @@ +import type { CSSProperties } from "react" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { APPEARANCE_SETTINGS, clampAppearance, isChatFontSize, type AppearanceKey } from "@/utils/appearance" + +interface AppearancePreviewProps { + values: Partial> + chatFontSize?: number +} + +/** + * Live preview of the appearance settings using the *unsaved* values. + * + * It sets the same CSS custom properties the real UI reads, but only on this + * element, so the sample updates on every stepper click without touching the + * rest of the webview until the user presses Save. + */ +export const AppearancePreview = ({ values, chatFontSize }: AppearancePreviewProps) => { + const { t } = useAppTranslation() + + // Custom properties inherit, and the saved values live on . An unset value is therefore set + // to `initial` (the guaranteed-invalid value) rather than left out: leaving it out would inherit + // the saved value, so a reset would not show in the preview until after Save. `initial` makes + // var() fall through to the same fallback the live UI uses (e.g. the terminal follows VS Code's + // editor font size), so the preview never shows a size the chat would not. + const vars: Record = {} + for (const key of Object.keys(APPEARANCE_SETTINGS) as AppearanceKey[]) { + const setting = APPEARANCE_SETTINGS[key] + const v = clampAppearance(key, values[key]) + vars[setting.cssVar] = v === undefined ? "initial" : setting.toCss(v) + } + // Only a whole number of pixels in the range the host accepts is written into the style; anything else (a + // corrupted stored value, a string) is left out, like an unset value. + vars["--zoo-chat-font-size"] = isChatFontSize(chatFontSize) ? `${chatFontSize}px` : "initial" + + const uiText: CSSProperties = { + fontSize: "calc(var(--zoo-chat-font-size, var(--vscode-font-size)) * var(--zoo-ui-scale, 1))", + } + const row: CSSProperties = { + padding: "calc(10px * var(--zoo-spacing-scale, 1)) calc(15px * var(--zoo-spacing-scale, 1))", + } + const mono: CSSProperties = { + fontFamily: "var(--vscode-editor-font-family, monospace)", + margin: 0, + padding: "6px 10px", + borderRadius: 4, + overflowX: "auto", + whiteSpace: "pre", + } + + return ( +
+
+ {t("settings:ui.appearance.preview")} +
+ +
+
+ {t("settings:ui.appearance.sample.you")} +
+
{t("settings:ui.appearance.sample.user")}
+
+ +
+
+ {t("settings:ui.appearance.sample.zoo")} +
+
{t("settings:ui.appearance.sample.assistant")}
+ +
+ {t("settings:ui.appearance.editorFontSize.label")} +
+
+					{'const isLoading = status === "saving"'}
+				
+ +
+ {t("settings:ui.appearance.terminalFontSize.label")} +
+
+					{"$ pnpm test\n✓ 42 passed"}
+				
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/NumberStepper.tsx b/webview-ui/src/components/settings/NumberStepper.tsx new file mode 100644 index 0000000000..af61cbbaea --- /dev/null +++ b/webview-ui/src/components/settings/NumberStepper.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react" +import { Minus, Plus, RotateCcw } from "lucide-react" + +import { cn } from "@/lib/utils" +import { type AppearanceSetting } from "@/utils/appearance" + +interface NumberStepperProps { + label: string + description: string + resetLabel: string + setting: AppearanceSetting + /** Undefined = unset (shows `setting.fallback`, reset disabled). */ + value?: number + onChange: (value: number | undefined) => void + testId: string +} + +/** + * `[ − ] [ value ] [ + ] unit ↺`. + * Typing is allowed; the value commits (clamped) on blur/Enter so partial + * input like "1" on the way to "120" is never saved or applied. + */ +export const NumberStepper = ({ + label, + description, + resetLabel, + setting, + value, + onChange, + testId, +}: NumberStepperProps) => { + const { min, max, step, fallback, unit } = setting + const current = value ?? fallback + const [draft, setDraft] = useState(String(current)) + // Whether the user typed in the box. Leaving or confirming an untouched box must not turn what it shows (the + // fallback, while nothing is set) into a stored value. + const [typed, setTyped] = useState(false) + + useEffect(() => { + setDraft(String(current)) + setTyped(false) + }, [current]) + + // Arrow keys step from what is in the box when the user has typed a number, so the typed value is not lost. + const stepBase = typed && draft.trim() !== "" && Number.isFinite(Number(draft)) ? Number(draft) : current + + const commitTyped = () => { + if (!typed) return + setTyped(false) + commit(Number(draft)) + } + + const commit = (next: number) => { + if (!Number.isFinite(next)) { + setDraft(String(current)) + return + } + // An emptied box means "no entry", not 0: revert instead of clamping to the minimum. + if (draft.trim() === "" && next === 0) { + setDraft(String(current)) + return + } + const clamped = Math.min(max, Math.max(min, Math.round(next))) + setDraft(String(clamped)) + // Always the number the user chose. `fallback` is only what the box shows while nothing is set; for + // the font sizes the real default is VS Code's own size, so a typed 12 must stay 12. Only Reset + // clears the override. + onChange(clamped) + } + + const buttonClass = + "flex items-center justify-center w-8 self-stretch bg-vscode-input-background text-vscode-foreground hover:bg-vscode-list-hoverBackground disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" + + return ( +
+ +
+
+ + { + setDraft(e.target.value.replace(/[^\d]/g, "")) + setTyped(true) + }} + onBlur={commitTyped} + onKeyDown={(e) => { + if (e.key === "Enter") commitTyped() + if (e.key === "ArrowUp") { + e.preventDefault() + commit(stepBase + step) + } + if (e.key === "ArrowDown") { + e.preventDefault() + commit(stepBase - step) + } + }} + data-testid={`${testId}-input`} + /> + +
+ + {unit} + + +
+
+ {description} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5f1769a230..e10e6ed68d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -43,6 +43,7 @@ import { } from "@roo-code/types" import { vscode } from "@src/utils/vscode" +import { APPEARANCE_SETTINGS, type AppearanceKey } from "@src/utils/appearance" import { cn } from "@src/lib/utils" import { useAppTranslation } from "@src/i18n/TranslationContext" import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext" @@ -82,6 +83,7 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings" import { SkillsSettings } from "./SkillsSettings" import { RulesSettings } from "./RulesSettings" import { UISettings } from "./UISettings" +import { nextSaveRequestId } from "./saveRequestId" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" import { WorktreesView } from "../worktrees/WorktreesView" @@ -134,6 +136,16 @@ const SettingsView = forwardRef(({ onDone, t const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) + // The save currently waiting for the host's answer. The "unsaved changes" flag stays on until it succeeds. + const [pendingSaveId, setPendingSaveId] = useState(undefined) + // The state the pending save was made from, to tell whether the user edited again while it was in flight. + const pendingSaveEditedState = useRef(undefined) + // The appearance values this form has edited. A value that only differs from the live state because the live state + // changed (another webview, a reset from the host) is not an edit and is never sent. A ref, not state: it is read + // when saving and needs no render of its own. + const editedAppearance = useRef(new Set()) + // What the pending save sent for each appearance value, to tell which edits it has saved when it succeeds. + const pendingAppearanceSent = useRef>({}) const [errorMessage, setErrorMessage] = useState(undefined) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) @@ -152,6 +164,10 @@ const SettingsView = forwardRef(({ onDone, t const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(() => extensionState) + const latestCachedState = useRef(cachedState) + useEffect(() => { + latestCachedState.current = cachedState + }, [cachedState]) const { alwaysAllowReadOnly, @@ -214,6 +230,10 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, enterBehavior, includeCurrentTime, includeCurrentCost, @@ -235,6 +255,7 @@ const SettingsView = forwardRef(({ onDone, t setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName prevMode.current = mode + editedAppearance.current.clear() setChangeDetected(false) }, [currentApiConfigName, mode, extensionState]) @@ -246,6 +267,7 @@ const SettingsView = forwardRef(({ onDone, t handledSettingsImportedAt.current = settingsImportedAt setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) + editedAppearance.current.clear() setChangeDetected(false) }, [settingsImportedAt, extensionState]) @@ -255,6 +277,7 @@ const SettingsView = forwardRef(({ onDone, t return prevState } + if (field in APPEARANCE_SETTINGS) editedAppearance.current.add(field as AppearanceKey) setChangeDetected(true) return { ...prevState, [field]: value } }) @@ -382,8 +405,26 @@ const SettingsView = forwardRef(({ onDone, t const handleSubmit = () => { if (isSettingValid) { + // Only a save that changes an appearance value waits for the host's answer: those are saved + // all-or-nothing, and a failed save has to keep the edits. Every other save behaves as it always did. + // Only the appearance values this form changed are sent, and null (not undefined) for a reset so it + // survives JSON and clears the stored value. A save that changes none sends none of them, so it neither + // waits for the host nor puts an appearance value into the host's all-or-nothing save. + const changedAppearance: Record = Object.fromEntries( + [...editedAppearance.current] + .filter((key) => (cachedState[key] ?? null) !== (extensionState[key] ?? null)) + .map((key) => [key, cachedState[key] ?? null]), + ) + const appearanceEdited = Object.keys(changedAppearance).length > 0 + const saveId = appearanceEdited ? nextSaveRequestId() : undefined + if (saveId) { + pendingSaveEditedState.current = cachedState + pendingAppearanceSent.current = changedAppearance + setPendingSaveId(saveId) + } vscode.postMessage({ type: "updateSettings", + requestId: saveId, updatedSettings: { language, alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined, @@ -442,6 +483,7 @@ const SettingsView = forwardRef(({ onDone, t includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize: chatFontSize ?? null, + ...changedAppearance, enterBehavior: enterBehavior ?? "send", includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, @@ -465,10 +507,47 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "debugSetting", bool: cachedState.debug }) - setChangeDetected(false) + // With an appearance edit, the "unsaved changes" flag is cleared only once the host reports that + // `updateSettings` was stored: see the `settingsSaveResult` listener below. A failed save leaves the + // user's edits in place to retry. Any other save clears it at once. + if (!saveId) { + // Nothing was sent, so no appearance edit is left to save (an edit that was undone equals the live value). + editedAppearance.current.clear() + setChangeDetected(false) + } } } + useEffect(() => { + if (!pendingSaveId) return + + const onResult = (event: MessageEvent) => { + const message = event.data + if (message?.type !== "settingsSaveResult" || message.requestId !== pendingSaveId) return + + const editedState = pendingSaveEditedState.current + const sent = pendingAppearanceSent.current + pendingSaveEditedState.current = undefined + pendingAppearanceSent.current = {} + setPendingSaveId(undefined) + + // What was stored is no longer an unsaved edit, unless the user changed that value again meanwhile. + if (message.success) { + for (const [key, value] of Object.entries(sent)) { + if ((latestCachedState.current[key as AppearanceKey] ?? null) === value) { + editedAppearance.current.delete(key as AppearanceKey) + } + } + } + + // An edit made while the save was in flight is not in what was sent: it stays unsaved. + if (message.success && latestCachedState.current === editedState) setChangeDetected(false) + } + + window.addEventListener("message", onResult) + return () => window.removeEventListener("message", onResult) + }, [pendingSaveId]) + const checkUnsaveChanges = useCallback( (then: () => void) => { if (isChangeDetected) { @@ -488,6 +567,7 @@ const SettingsView = forwardRef(({ onDone, t if (confirm) { // Discard changes: Reset state and flag setCachedState(extensionState) // Revert to original state + editedAppearance.current.clear() setChangeDetected(false) // Reset change flag confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) } @@ -696,7 +776,7 @@ const SettingsView = forwardRef(({ onDone, t variant={isSettingValid ? "primary" : "secondary"} className={!isSettingValid ? "!border-vscode-errorForeground" : ""} onClick={handleSubmit} - disabled={!isChangeDetected || !isSettingValid} + disabled={!isChangeDetected || !isSettingValid || !!pendingSaveId} data-testid="save-button"> {t("settings:common.save")} @@ -944,6 +1024,10 @@ const SettingsView = forwardRef(({ onDone, t reasoningBlockCollapsed={reasoningBlockCollapsed ?? true} enterBehavior={enterBehavior ?? "send"} chatFontSize={chatFontSize ?? undefined} + interfaceFontScale={interfaceFontScale ?? undefined} + terminalFontSize={terminalFontSize ?? undefined} + editorFontSize={editorFontSize ?? undefined} + spacingScale={spacingScale ?? undefined} autoCloseZooOpenedFiles={autoCloseZooOpenedFiles} autoCloseZooOpenedFilesAfterUserEdited={autoCloseZooOpenedFilesAfterUserEdited} autoCloseZooOpenedNewFiles={autoCloseZooOpenedNewFiles} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index 46353c26ad..ea730bb390 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -14,15 +14,24 @@ import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" import { Slider, Button } from "../ui" import { ExtensionStateContextType } from "@/context/ExtensionStateContext" +import { APPEARANCE_SETTINGS, type AppearanceKey } from "@/utils/appearance" +import { NumberStepper } from "./NumberStepper" +import { AppearancePreview } from "./AppearancePreview" -export const CHAT_FONT_SIZE_MIN = 8 -export const CHAT_FONT_SIZE_MAX = 32 -export const CHAT_FONT_SIZE_DEFAULT = 13 +const APPEARANCE_KEYS = Object.keys(APPEARANCE_SETTINGS) as AppearanceKey[] + +const CHAT_FONT_SIZE_MIN = 8 +const CHAT_FONT_SIZE_MAX = 32 +const CHAT_FONT_SIZE_DEFAULT = 13 interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" chatFontSize?: number + interfaceFontScale?: number + terminalFontSize?: number + editorFontSize?: number + spacingScale?: number autoCloseZooOpenedFiles?: boolean autoCloseZooOpenedFilesAfterUserEdited?: boolean autoCloseZooOpenedNewFiles?: boolean @@ -33,6 +42,10 @@ export const UISettings = ({ reasoningBlockCollapsed, enterBehavior, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, @@ -41,6 +54,13 @@ export const UISettings = ({ }: UISettingsProps) => { const { t } = useAppTranslation() + const appearance: Record = { + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, + } + // Detect platform for dynamic modifier key display const primaryMod = useMemo(() => { const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 @@ -160,6 +180,34 @@ export const UISettings = ({
+ {/* Appearance sizes: interface %, terminal px, editor px, spacing % */} + {APPEARANCE_KEYS.map((key) => ( + + { + setCachedStateField(key, value) + telemetryClient.capture("ui_settings_appearance_changed", { + key, + value: value ?? null, + }) + }} + testId={`appearance-${key}`} + /> + + ))} + + {/* Live preview of the (unsaved) size settings above */} + + {/* Auto-close Zoo opened files */} + * (through the same function the app uses) or remove them again. + */ +export function AppearanceConsumersStory() { + const [applied, setApplied] = useState(false) + const apply = (values: AppearanceValues) => applyAppearanceVariables(document.documentElement, values) + + return ( + +
+
+ + + {applied ? "set" : "unset"} +
+ +
+ +
+
+ {}} + onHeightChange={() => {}} + isFollowUpAnswered={false} + /> +
+
+ {}} + onUpdate={() => {}} + /> +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/AppearanceConsumers.visual.tsx b/webview-ui/src/components/settings/__tests__/AppearanceConsumers.visual.tsx new file mode 100644 index 0000000000..914d2effbc --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearanceConsumers.visual.tsx @@ -0,0 +1,88 @@ +import type { Locator } from "@playwright/test" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +const px = (locator: Locator, property: string) => + locator.evaluate((element, name) => Number.parseFloat(getComputedStyle(element).getPropertyValue(name)), property) + +// Real layout, so the computed values are the ones a user would get: the saved values are applied to through +// the app's own function, then removed again, and every consumer must follow both ways. +for (const theme of visualThemes) { + test(`the appearance values reach every consumer, and removing them restores the defaults (${theme.name})`, async ({ + mount, + page, + }) => { + const component = mountedStory(await mount("appearance-consumers")) + await applyVisualTheme(page, theme) + const story = component.getByTestId("appearance-consumers-story") + + const markdownText = story.getByTestId("consumer-markdown").locator("p").first() + const queued = story.getByTestId("queued-messages") + // The wrapper that ChatRow puts around every row: its padding is what the spacing setting changes. + const chatRowWrapper = story.getByTestId("consumer-chat-row").locator(":scope > div").first() + const terminal = story + .getByTestId("consumer-terminal") + .locator("pre, code, div") + .filter({ hasText: "Tests passed" }) + .last() + const diffCode = story.getByTestId("consumer-diff").locator(".diff-view").first() + const code = story.getByTestId("consumer-code").locator("code").first() + await expect(markdownText).toBeVisible() + await expect(queued).toBeVisible() + await expect(chatRowWrapper).toContainText("A chat row with an assistant reply.") + await expect(diffCode).toBeVisible() + // The code block highlights asynchronously: wait for it, so the picture is the same on every run. + await expect(code.locator("span[style]").first()).toBeVisible({ timeout: 20_000 }) + + // Unset: the historical look. + await expect(story.getByTestId("appearance-state")).toHaveText("unset") + const before = { + chat: await px(markdownText, "font-size"), + padX: await px(queued, "padding-left"), + padY: await px(queued, "padding-top"), + terminal: await px(terminal, "font-size"), + diff: await px(diffCode, "font-size"), + code: await px(code, "font-size"), + } + expect(before.padX).toBe(15) + expect(before.padY).toBe(10) + expect(await px(chatRowWrapper, "padding-left")).toBe(15) + expect(await px(chatRowWrapper, "padding-top")).toBe(10) + expect(before.terminal).not.toBe(20) + expect(before.diff).not.toBe(22) + expect(before.code).not.toBe(22) + await expect(page.locator("html")).not.toHaveClass(/zoo-editor-font-set/) + await expect(story).toHaveScreenshot(`appearance-consumers-${theme.name}-unset.png`) + + // Set: interface 150%, terminal 20px, editor 22px, spacing 150%. + await story.getByTestId("apply-appearance").click() + await expect(story.getByTestId("appearance-state")).toHaveText("set") + await expect(page.locator("html")).toHaveClass(/zoo-editor-font-set/) + await expect.poll(() => px(queued, "padding-left")).toBe(22.5) // 15px at 150% + expect(await px(queued, "padding-top")).toBe(15) // 10px at 150% + expect(await px(chatRowWrapper, "padding-left")).toBe(22.5) + expect(await px(chatRowWrapper, "padding-top")).toBe(15) + expect(await px(markdownText, "font-size")).toBeCloseTo(before.chat * 1.5, 1) + expect(await px(terminal, "font-size")).toBe(20) + expect(await px(diffCode, "font-size")).toBe(22) + expect(await px(code, "font-size")).toBe(22) + await expect(story).toHaveScreenshot(`appearance-consumers-${theme.name}-set.png`) + + // Removed again: every consumer is back on its default. + await story.getByTestId("remove-appearance").click() + await expect(story.getByTestId("appearance-state")).toHaveText("unset") + await expect(page.locator("html")).not.toHaveClass(/zoo-editor-font-set/) + await expect.poll(() => px(queued, "padding-left")).toBe(15) + expect(await px(queued, "padding-top")).toBe(10) + expect(await px(chatRowWrapper, "padding-left")).toBe(15) + expect(await px(chatRowWrapper, "padding-top")).toBe(10) + expect(await px(markdownText, "font-size")).toBeCloseTo(before.chat, 1) + expect(await px(terminal, "font-size")).toBeCloseTo(before.terminal, 1) + expect(await px(diffCode, "font-size")).toBeCloseTo(before.diff, 1) + expect(await px(code, "font-size")).toBeCloseTo(before.code, 1) + // ...and it looks the way it did before: the same picture as the unset state. + await expect(story).toHaveScreenshot(`appearance-consumers-${theme.name}-unset.png`) + }) +} diff --git a/webview-ui/src/components/settings/__tests__/AppearancePreview.spec.tsx b/webview-ui/src/components/settings/__tests__/AppearancePreview.spec.tsx new file mode 100644 index 0000000000..0412ce11f0 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearancePreview.spec.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { AppearancePreview } from "../AppearancePreview" + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +const root = () => screen.getByTestId("appearance-preview") + +describe("AppearancePreview", () => { + it("resets every unset value to `initial`, so the live CSS fallback chain applies and nothing is inherited from ", () => { + render() + const style = root().style + for (const cssVar of [ + "--zoo-terminal-font-size", + "--zoo-editor-font-size", + "--zoo-ui-scale", + "--zoo-spacing-scale", + "--zoo-chat-font-size", + ]) { + expect(style.getPropertyValue(cssVar)).toBe("initial") + } + }) + + it("is not affected by a saved value on the root: a reset shows before Save", () => { + document.documentElement.style.setProperty("--zoo-terminal-font-size", "30px") + try { + render() + // The preview sets its own value for the property, so the root's 30px cannot reach it. + expect(root().style.getPropertyValue("--zoo-terminal-font-size")).toBe("initial") + } finally { + document.documentElement.style.removeProperty("--zoo-terminal-font-size") + } + }) + + it("sets the values the user has set, converted the way the live UI converts them, and resets the rest", () => { + render( + , + ) + const style = root().style + expect(style.getPropertyValue("--zoo-terminal-font-size")).toBe("18px") + expect(style.getPropertyValue("--zoo-ui-scale")).toBe("1.2") + expect(style.getPropertyValue("--zoo-spacing-scale")).toBe("initial") + expect(style.getPropertyValue("--zoo-editor-font-size")).toBe("initial") + }) + + it("clamps out-of-range values like the live UI", () => { + render() + expect(root().style.getPropertyValue("--zoo-spacing-scale")).toBe("2") + }) + + it("applies the chat font size only when one is set", () => { + const { rerender } = render() + expect(root().style.getPropertyValue("--zoo-chat-font-size")).toBe("initial") + rerender() + expect(root().style.getPropertyValue("--zoo-chat-font-size")).toBe("16px") + }) + + it.each([8, 16, 32])("writes a chat font size of %i into the style", (size) => { + render() + expect(root().style.getPropertyValue("--zoo-chat-font-size")).toBe(`${size}px`) + }) + + it.each([ + ["below the range", 7], + ["above the range", 33], + ["zero", 0], + ["negative", -12], + ["fractional", 12.5], + ["not a number", Number.NaN], + ["infinite", Number.POSITIVE_INFINITY], + ["a string that would break out of the declaration", "12px; background: url(x)"], + ["an object", { size: 12 }], + ])("leaves a chat font size that is %s out of the style", (_name, size) => { + render() + expect(root().style.getPropertyValue("--zoo-chat-font-size")).toBe("initial") + expect(root().getAttribute("style")).not.toContain("background") + }) + + it("sizes the editor sample like the live code block when no editor size is set (0.95em of the chat text)", () => { + const { container } = render() + const sample = container.querySelector("pre")! + expect(sample.style.fontSize).toBe("var(--zoo-editor-font-size, 0.95em)") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.fixture.tsx new file mode 100644 index 0000000000..f96bbab205 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.fixture.tsx @@ -0,0 +1,33 @@ +import { AppearancePreview } from "../AppearancePreview" +import { AppProviders } from "../../../../playwright/AppProviders" + +/** + * Two previews, one above the other: + * - "set": explicit sizes, so the stepper values show up in the sample. + * - "reset": every value unset while carries saved values. The preview must ignore the saved + * values (a reset shows before Save) and fall back to VS Code's sizes, like the live UI does. + */ +export function AppearancePreviewStory() { + return ( + +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.tsx b/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.tsx new file mode 100644 index 0000000000..867eb7bfab --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearancePreview.visual.tsx @@ -0,0 +1,23 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +for (const theme of visualThemes) { + test(`renders the appearance preview in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("appearance-preview")) + await applyVisualTheme(page, theme) + // Saved values on , as the app writes them. The "reset" preview must not pick these up. + await page.evaluate(() => { + const root = document.documentElement + root.style.setProperty("--zoo-terminal-font-size", "30px") + root.style.setProperty("--zoo-editor-font-size", "30px") + root.style.setProperty("--zoo-ui-scale", "2") + root.style.setProperty("--zoo-spacing-scale", "2") + root.style.setProperty("--zoo-chat-font-size", "30px") + }) + const story = component.getByTestId("appearance-preview-story") + await expect(story.getByTestId("appearance-preview-set")).toBeVisible() + await expect(story.getByTestId("appearance-preview-reset")).toBeVisible() + await expect(story).toHaveScreenshot(`appearance-preview-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/settings/__tests__/NumberStepper.spec.tsx b/webview-ui/src/components/settings/__tests__/NumberStepper.spec.tsx new file mode 100644 index 0000000000..bbe26b7225 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/NumberStepper.spec.tsx @@ -0,0 +1,240 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { APPEARANCE_SETTINGS } from "@/utils/appearance" + +import { NumberStepper } from "../NumberStepper" + +const setting = APPEARANCE_SETTINGS.terminalFontSize // 8–32 px, step 1, fallback 12 + +const setup = (props: Partial> = {}) => { + const onChange = vi.fn() + render( + , + ) + return { onChange, input: screen.getByTestId("t-input") as HTMLInputElement } +} + +describe("NumberStepper", () => { + it("shows the fallback when unset and hides reset", () => { + const { input } = setup() + expect(input.value).toBe("12") + expect(screen.getByTestId("t-reset").className).toContain("invisible") + }) + + it("shows the stored value and an active reset", () => { + const { input } = setup({ value: 16 }) + expect(input.value).toBe("16") + expect(screen.getByTestId("t-reset").className).not.toContain("invisible") + }) + + it("increments and decrements by the step", () => { + const { onChange } = setup({ value: 16 }) + fireEvent.click(screen.getByTestId("t-increment")) + expect(onChange).toHaveBeenLastCalledWith(17) + fireEvent.click(screen.getByTestId("t-decrement")) + expect(onChange).toHaveBeenLastCalledWith(15) + }) + + it("keeps an explicit value that equals the displayed default: only Reset clears an override", () => { + const { onChange } = setup({ value: 13 }) + fireEvent.click(screen.getByTestId("t-decrement")) // 12 == the shown fallback, but the user chose it + expect(onChange).toHaveBeenLastCalledWith(12) + expect(onChange).not.toHaveBeenCalledWith(undefined) + }) + + it("stores a typed value that equals the displayed default as that number", () => { + const { onChange, input } = setup() + // As typed over the shown 12: the box goes through "1" on the way to "12". + fireEvent.change(input, { target: { value: "1" } }) + fireEvent.change(input, { target: { value: "12" } }) + fireEvent.blur(input) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith(12) + }) + + it("disables the buttons at the limits", () => { + setup({ value: setting.max }) + expect((screen.getByTestId("t-increment") as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByTestId("t-decrement") as HTMLButtonElement).disabled).toBe(false) + }) + + it("disables decrement at the minimum", () => { + setup({ value: setting.min }) + expect((screen.getByTestId("t-decrement") as HTMLButtonElement).disabled).toBe(true) + }) + + it("does not commit while typing, then commits clamped on blur", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "999" } }) + expect(onChange).not.toHaveBeenCalled() + fireEvent.blur(input) + expect(onChange).toHaveBeenCalledWith(setting.max) + expect(input.value).toBe(String(setting.max)) + }) + + it("commits on Enter and strips non-digits", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "2a0" } }) + expect(input.value).toBe("20") + fireEvent.keyDown(input, { key: "Enter" }) + expect(onChange).toHaveBeenCalledWith(20) + }) + + it("supports arrow keys", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.keyDown(input, { key: "ArrowUp" }) + expect(onChange).toHaveBeenLastCalledWith(17) + fireEvent.keyDown(input, { key: "ArrowDown" }) + expect(onChange).toHaveBeenLastCalledWith(15) + }) + + describe("arrow keys", () => { + it("step from the stored value when nothing was typed", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.keyDown(input, { key: "ArrowUp" }) + expect(onChange).toHaveBeenLastCalledWith(17) + fireEvent.keyDown(input, { key: "ArrowDown" }) + expect(onChange).toHaveBeenLastCalledWith(15) + }) + + it("step from the number typed in the box, not the stored value", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "20" } }) + fireEvent.keyDown(input, { key: "ArrowUp" }) + expect(onChange).toHaveBeenLastCalledWith(21) + }) + + it("step down from the typed number", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "20" } }) + fireEvent.keyDown(input, { key: "ArrowDown" }) + expect(onChange).toHaveBeenLastCalledWith(19) + }) + + it("step from the stored value when the box was emptied", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "" } }) + fireEvent.keyDown(input, { key: "ArrowUp" }) + expect(onChange).toHaveBeenLastCalledWith(17) + }) + }) + + describe("accessibility", () => { + it("announces the unit and the description with the value", () => { + const { input } = setup({ description: "Size of the terminal text" }) + + const described = (input.getAttribute("aria-describedby") ?? "").split(" ") + const texts = described.map((id) => document.getElementById(id)?.textContent) + + expect(texts).toEqual([setting.unit, "Size of the terminal text"]) + }) + }) + + describe("leaving the box without typing", () => { + it("stores nothing when the setting is unset and the box is only focused and left", () => { + const { onChange, input } = setup() // unset: shows the fallback, 12 + fireEvent.focus(input) + fireEvent.blur(input) + expect(onChange).not.toHaveBeenCalled() + expect(input.value).toBe("12") + }) + + it("stores nothing when Enter is pressed in an untouched box", () => { + const { onChange, input } = setup() + fireEvent.keyDown(input, { key: "Enter" }) + expect(onChange).not.toHaveBeenCalled() + }) + + it("stores nothing when a set value is only focused and left", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.focus(input) + fireEvent.blur(input) + expect(onChange).not.toHaveBeenCalled() + }) + + it("still stores what the user typed, even when it equals the shown fallback", () => { + const { onChange, input } = setup() + fireEvent.change(input, { target: { value: "1" } }) + fireEvent.change(input, { target: { value: "12" } }) + fireEvent.blur(input) + expect(onChange).toHaveBeenCalledWith(12) + }) + + it("treats a second blur after a commit as untouched", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "20" } }) + fireEvent.blur(input) + fireEvent.blur(input) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) + + it("reverts an empty entry to the current value", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "" } }) + fireEvent.blur(input) + expect(input.value).toBe("16") + expect(onChange).not.toHaveBeenCalled() + }) + + it("clamps a typed zero up to the minimum (a real 0, unlike an emptied box)", () => { + const { onChange, input } = setup({ value: 16 }) + fireEvent.change(input, { target: { value: "0" } }) + fireEvent.blur(input) + expect(onChange).toHaveBeenCalledWith(setting.min) + }) + + it("reset clears the override", () => { + const { onChange } = setup({ value: 20 }) + fireEvent.click(screen.getByTestId("t-reset")) + expect(onChange).toHaveBeenCalledWith(undefined) + }) + + it("reverts when the typed number is too large to be finite", () => { + const { onChange, input } = setup({ value: 16 }) + // 400 digits parse to Infinity. + fireEvent.change(input, { target: { value: "9".repeat(400) } }) + fireEvent.blur(input) + expect(onChange).not.toHaveBeenCalled() + expect(input.value).toBe("16") + }) + + it("re-syncs the draft when the value changes from outside", () => { + const { rerender } = (() => { + const r = render( + , + ) + return r + })() + expect((screen.getByTestId("x-input") as HTMLInputElement).value).toBe("14") + rerender( + , + ) + expect((screen.getByTestId("x-input") as HTMLInputElement).value).toBe("20") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.saveResult.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.saveResult.spec.tsx new file mode 100644 index 0000000000..60a33a8ce7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.saveResult.spec.tsx @@ -0,0 +1,325 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react" +import { QueryClient } from "@tanstack/react-query" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { renderWithExtensionState } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" + +import SettingsView from "../SettingsView" +import { answerSave, lastSaveRequestId } from "./saveAcknowledgement" + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("@/utils/TelemetryClient", () => ({ telemetryClient: { capture: vi.fn() } })) + +const postMessage = vi.mocked(vscode.postMessage) + +const posted = (type: string) => + postMessage.mock.calls.map((call) => call[0] as { type: string }).filter((message) => message.type === type) + +const renderUiTab = () => + renderWithExtensionState(, { + queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }), + }) + +/** Edit an appearance value (the terminal font size, 12 by default) so Save becomes available. */ +const makeAnEdit = async () => { + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-increment")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) +} + +/** Edit a setting that is not an appearance value, so Save becomes available. */ +const makeAnOtherEdit = async () => { + fireEvent.click(await screen.findByTestId("collapse-thinking-checkbox")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) +} + +/** The extension host reports a new state, as when another webview or the host changes a saved value. */ +const liveStateChange = (state: Record) => + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "state", state } })) + }) + +const lastUpdatedSettings = () => + (posted("updateSettings").at(-1) as { updatedSettings?: Record } | undefined)?.updatedSettings ?? + {} + +const appearanceKeys = ["interfaceFontScale", "terminalFontSize", "editorFontSize", "spacingScale"] as const + +describe("SettingsView – save acknowledgement", () => { + beforeEach(() => postMessage.mockClear()) + + it("sends the settings with a request id, plus the API configuration, telemetry and debug as before", async () => { + renderUiTab() + await makeAnEdit() + + fireEvent.click(screen.getByTestId("save-button")) + + const [save] = posted("updateSettings") as Array<{ type: string; requestId?: string }> + expect(save.requestId).toMatch(/^settings-save-[a-z0-9]+-\d+$/) + // These do not wait for the host's answer, exactly as before the acknowledgement existed. + expect(posted("upsertApiConfiguration")).toHaveLength(1) + expect(posted("telemetrySetting")).toHaveLength(1) + expect(posted("debugSetting")).toHaveLength(1) + // Save stays unavailable, and the edit stays unsaved, until the host answers. + expect(screen.getByTestId("save-button")).toBeDisabled() + }) + + it("clears the unsaved flag once the host confirms, and sends nothing more", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + + answerSave(lastSaveRequestId(postMessage), { success: true }) + + // Saved: nothing left to save. + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + expect(posted("upsertApiConfiguration")).toHaveLength(1) + expect(posted("updateSettings")).toHaveLength(1) + }) + + it("keeps the edits and the unsaved flag when the host reports a failure, so the user can retry", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + + answerSave(lastSaveRequestId(postMessage), { success: false, error: "disk full" }) + + // The edit is still there, still unsaved, and Save is available again. + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + // The stepper started at 12; the user raised it, and that edit survives the failed save. + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("13") + + // Retrying sends a fresh request with a new id. + const firstId = lastSaveRequestId(postMessage) + fireEvent.click(screen.getByTestId("save-button")) + expect(lastSaveRequestId(postMessage)).not.toBe(firstId) + expect(posted("updateSettings")).toHaveLength(2) + }) + + describe("a save that changes no appearance value", () => { + it("is sent without a request id and behaves as it always did: nothing to wait for", async () => { + renderUiTab() + await makeAnOtherEdit() + + fireEvent.click(screen.getByTestId("save-button")) + + const [save] = posted("updateSettings") as Array<{ type: string; requestId?: string }> + expect(save.requestId).toBeUndefined() + expect(posted("upsertApiConfiguration")).toHaveLength(1) + // The unsaved flag is cleared at once, so Save is idle straight away. + expect(screen.getByTestId("save-button")).toBeDisabled() + // ...and a further edit enables it again, with no answer from the host needed in between. + fireEvent.click(screen.getByTestId("collapse-thinking-checkbox")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) + + it("ignores any save result, since it did not ask for one", async () => { + renderUiTab() + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + fireEvent.click(screen.getByTestId("collapse-thinking-checkbox")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + + answerSave("settings-save-never-issued", { success: false, error: "nope" }) + + expect(screen.getByTestId("save-button")).toBeEnabled() + }) + + it("also waits for the host when an appearance value is edited together with other settings", async () => { + renderUiTab() + await makeAnOtherEdit() + fireEvent.click(await screen.findByTestId("appearance-spacingScale-increment")) + + fireEvent.click(screen.getByTestId("save-button")) + + const [save] = posted("updateSettings") as Array<{ type: string; requestId?: string }> + expect(save.requestId).toMatch(/^settings-save-[a-z0-9]+-\d+$/) + expect(screen.getByTestId("save-button")).toBeDisabled() + answerSave(save.requestId, { success: false }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) + + it("treats a reset of an appearance value as an appearance edit", async () => { + renderWithExtensionState(, { + state: { terminalFontSize: 20 }, + queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }), + }) + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-reset")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + + fireEvent.click(screen.getByTestId("save-button")) + + const [save] = posted("updateSettings") as Array<{ type: string; requestId?: string }> + expect(save.requestId).toMatch(/^settings-save-[a-z0-9]+-\d+$/) + }) + }) + + it("does not send a second save while one is waiting for the host", async () => { + renderUiTab() + await makeAnEdit() + + fireEvent.click(screen.getByTestId("save-button")) + fireEvent.click(screen.getByTestId("save-button")) + fireEvent.click(screen.getByTestId("save-button")) + + expect(posted("updateSettings")).toHaveLength(1) + expect(posted("upsertApiConfiguration")).toHaveLength(1) + }) + + it("ignores an answer for a different save, or one that arrives with no id", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + + answerSave("settings-save-never-issued", { success: true }) + answerSave(undefined, { success: true }) + expect(screen.getByTestId("save-button")).toBeDisabled() + + // The real answer still works afterwards: the edit is saved and Save is idle. + answerSave(lastSaveRequestId(postMessage), { success: false }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) + + it("ignores a save result when nothing is being saved", async () => { + renderUiTab() + await makeAnEdit() + answerSave("settings-save-never-issued", { success: true }) + // The unsaved edit is untouched. + expect(screen.getByTestId("save-button")).toBeEnabled() + }) + + it("keeps an edit made while the save was pending unsaved, so Save stays available for it", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + + // A second edit lands before the host answers; it is not in what was sent. + fireEvent.click(screen.getByTestId("enter-behavior-checkbox")) + answerSave(lastSaveRequestId(postMessage), { success: true }) + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) + + it("does nothing, and does not fail, when the answer arrives after the view is gone", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const view = renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const id = lastSaveRequestId(postMessage) + view.unmount() + + answerSave(id, { success: true }) + expect(posted("upsertApiConfiguration")).toHaveLength(1) + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + it("never reuses a request id across mounts, so an earlier save's answer cannot complete a later one", async () => { + const first = renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const earlierId = lastSaveRequestId(postMessage) + first.unmount() + + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const laterId = lastSaveRequestId(postMessage) + expect(laterId).not.toBe(earlierId) + + // The earlier save's answer arrives while the new save is pending: it must not complete it. + answerSave(earlierId, { success: true }) + expect(screen.getByTestId("save-button")).toBeDisabled() + + answerSave(laterId, { success: false }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) +}) + +describe("SettingsView – which appearance values a save sends", () => { + beforeEach(() => postMessage.mockClear()) + + it("does not send a value that only differs because the live state changed, and asks for no acknowledgement", async () => { + renderUiTab() + await screen.findByTestId("appearance-terminalFontSize-input") + + liveStateChange({ terminalFontSize: 20 }) // another webview changed it; the user did not + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + + const sent = lastUpdatedSettings() + for (const key of appearanceKeys) expect(sent).not.toHaveProperty(key) + expect((posted("updateSettings").at(-1) as { requestId?: string }).requestId).toBeUndefined() + }) + + it("sends only the value the user edited, not another that the live state changed meanwhile", async () => { + renderUiTab() + await screen.findByTestId("appearance-terminalFontSize-input") + + liveStateChange({ editorFontSize: 18 }) // not edited here + await makeAnEdit() // the terminal font size + fireEvent.click(screen.getByTestId("save-button")) + + const sent = lastUpdatedSettings() + expect(sent.terminalFontSize).toBe(13) + expect(sent).not.toHaveProperty("editorFontSize") + expect((posted("updateSettings").at(-1) as { requestId?: string }).requestId).toBeDefined() + }) + + it("sends nothing for a value that was edited and then put back to what is saved", async () => { + renderWithExtensionState(, { + state: { terminalFontSize: 14 }, + }) + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-increment")) // 15 + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-decrement")) // back to the saved 14 + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + + for (const key of appearanceKeys) expect(lastUpdatedSettings()).not.toHaveProperty(key) + }) + + it("stops treating a value as an edit once its save was stored, so a later save does not send it again", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) + liveStateChange({ terminalFontSize: 25 }) // later, someone else changes it + postMessage.mockClear() + + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).not.toHaveProperty("terminalFontSize") + }) + + it("keeps a value edited again while the save was pending as an edit, and sends it with the next save", async () => { + renderUiTab() + await makeAnEdit() // 13 + fireEvent.click(screen.getByTestId("save-button")) + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // 14, after what was sent + answerSave(lastSaveRequestId(postMessage), { success: true }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + postMessage.mockClear() + + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings().terminalFontSize).toBe(14) + }) + + it("keeps the edit after a failed save, so a retry sends it again", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: false, error: "nope" }) + postMessage.mockClear() + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings().terminalFontSize).toBe(13) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 0835f255f9..0269124efa 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -8,6 +8,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, type ProviderSettings } from "@roo- import type { ApiOptionsProps } from "../ApiOptions" import SettingsView from "../SettingsView" +import { saveAndConfirm } from "./saveAcknowledgement" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) @@ -500,6 +501,85 @@ describe("SettingsView - Sound Settings", () => { ) }) + const appearanceKeys = ["interfaceFontScale", "terminalFontSize", "editorFontSize", "spacingScale"] as const + const savedUpdates = () => + vi + .mocked(vscode.postMessage) + .mock.calls.map( + ([message]) => + message as { type?: string; requestId?: string; updatedSettings?: Record }, + ) + .filter((message) => message.type === "updateSettings") + + it("saves the four appearance values that were changed", () => { + const { activateTab, getSettingsContent } = renderSettingsView() + activateTab("ui") + + const values = { + interfaceFontScale: 120, + terminalFontSize: 14, + editorFontSize: 15, + spacingScale: 80, + } + for (const [key, value] of Object.entries(values)) { + const input = within(getSettingsContent()).getByTestId(`appearance-${key}-input`) + fireEvent.change(input, { target: { value: String(value) } }) + fireEvent.blur(input) + } + saveAndConfirm(vi.mocked(vscode.postMessage)) + + expect(savedUpdates().at(-1)?.updatedSettings).toMatchObject(values) + }) + + it("persists null for each appearance value that was reset", () => { + const values = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 } + renderWithExtensionState(, { state: values }) + const getSettingsContent = () => screen.getByTestId("settings-content") + + for (const key of appearanceKeys) { + fireEvent.click(within(getSettingsContent()).getByTestId(`appearance-${key}-reset`)) + } + saveAndConfirm(vi.mocked(vscode.postMessage)) + + expect(savedUpdates().at(-1)?.updatedSettings).toMatchObject({ + interfaceFontScale: null, + terminalFontSize: null, + editorFontSize: null, + spacingScale: null, + }) + }) + + it("sends only the appearance values this form changed", () => { + const values = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 } + renderWithExtensionState(, { state: values }) + const getSettingsContent = () => screen.getByTestId("settings-content") + + const input = within(getSettingsContent()).getByTestId("appearance-terminalFontSize-input") + expect((input as HTMLInputElement).value).toBe("14") // the saved value is what the form starts from + fireEvent.change(input, { target: { value: "16" } }) + fireEvent.blur(input) + saveAndConfirm(vi.mocked(vscode.postMessage)) + + const sent = savedUpdates().at(-1)?.updatedSettings ?? {} + expect(sent.terminalFontSize).toBe(16) + for (const key of appearanceKeys.filter((k) => k !== "terminalFontSize")) { + expect(sent).not.toHaveProperty(key) + } + }) + + it("sends none of the appearance values, and asks for no acknowledgement, when the save changes none", () => { + renderWithExtensionState(, { + state: { interfaceFontScale: 120 }, + }) + fireEvent.click(within(screen.getByTestId("settings-content")).getByTestId("sound-enabled-checkbox")) + fireEvent.click(screen.getByTestId("save-button")) // no acknowledgement is asked for + + const save = savedUpdates().at(-1) + expect(save).toBeDefined() + for (const key of appearanceKeys) expect(save?.updatedSettings).not.toHaveProperty(key) + expect(save?.requestId).toBeUndefined() + }) + it("saves fallback defaults for checkpoint and terminal settings", async () => { const { activateTab, getSettingsContent } = renderSettingsView({ enableCheckpoints: undefined, diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 4e61f0fcff..727ab84698 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -176,4 +176,86 @@ describe("UISettings", () => { }) }) }) + + describe("appearance steppers", () => { + const keys = ["interfaceFontScale", "terminalFontSize", "editorFontSize", "spacingScale"] as const + + it("renders all four size controls, in the design order, with defaults", () => { + const { getByTestId } = render() + const inputs = keys.map((k) => (getByTestId(`appearance-${k}-input`) as HTMLInputElement).value) + expect(inputs).toEqual(["100", "12", "13", "100"]) + }) + + it("shows the saved values", () => { + const { getByTestId } = render( + , + ) + expect((getByTestId("appearance-interfaceFontScale-input") as HTMLInputElement).value).toBe("120") + expect((getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("14") + expect((getByTestId("appearance-editorFontSize-input") as HTMLInputElement).value).toBe("15") + expect((getByTestId("appearance-spacingScale-input") as HTMLInputElement).value).toBe("80") + }) + + it.each([ + ["interfaceFontScale", 105], + ["terminalFontSize", 13], + ["editorFontSize", 14], + ["spacingScale", 105], + ] as const)("stores %s as %s when incremented from the default", (key, expected) => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + fireEvent.click(getByTestId(`appearance-${key}-increment`)) + expect(setCachedStateField).toHaveBeenCalledWith(key, expected) + }) + + it("reset clears the stored value (undefined → null on save)", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + fireEvent.click(getByTestId("appearance-terminalFontSize-reset")) + expect(setCachedStateField).toHaveBeenCalledWith("terminalFontSize", undefined) + }) + + it.each([ + ["interfaceFontScale", 120], + ["terminalFontSize", 20], + ["editorFontSize", 18], + ["spacingScale", 80], + ] as const)("records a reset of %s as a null value, not a number", (key, current) => { + vi.mocked(telemetryClient.capture).mockClear() + const props = { ...defaultProps, [key]: current } + const { getByTestId } = render() + + fireEvent.click(getByTestId(`appearance-${key}-reset`)) + + expect(telemetryClient.capture).toHaveBeenCalledTimes(1) + expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_appearance_changed", { + key, + value: null, + }) + }) + + it("records telemetry for changes", () => { + const { getByTestId } = render() + fireEvent.click(getByTestId("appearance-editorFontSize-increment")) + expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_appearance_changed", { + key: "editorFontSize", + value: 14, + }) + }) + + it("previews the unsaved values live", () => { + const { getByTestId } = render() + const preview = getByTestId("appearance-preview") + expect(preview.style.getPropertyValue("--zoo-ui-scale")).toBe("1.5") + expect(preview.style.getPropertyValue("--zoo-spacing-scale")).toBe("0.5") + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-set.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-set.png new file mode 100644 index 0000000000..ae21d756a2 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-set.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-unset.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-unset.png new file mode 100644 index 0000000000..b488da073f Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-dark-unset.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-set.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-set.png new file mode 100644 index 0000000000..431de9d782 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-set.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-unset.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-unset.png new file mode 100644 index 0000000000..56cce7bd8e Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-light-unset.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-set.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-set.png new file mode 100644 index 0000000000..04dc5e3aef Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-set.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-unset.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-unset.png new file mode 100644 index 0000000000..ddff203f44 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-high-contrast-unset.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-set.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-set.png new file mode 100644 index 0000000000..c9d28e7172 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-set.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-unset.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-unset.png new file mode 100644 index 0000000000..337ab6f498 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-consumers-light-unset.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-dark.png new file mode 100644 index 0000000000..f57aca01b6 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast-light.png new file mode 100644 index 0000000000..751c957b97 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast.png new file mode 100644 index 0000000000..2041a649fd Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-light.png new file mode 100644 index 0000000000..78f4089f8d Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/appearance-preview-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png index b2f92d7b04..fcd5bd8299 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png index 3aa9092f5d..d5047e4be0 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png index f9c3e057ba..e370f0d287 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png index ca6aab1ec3..71fbf8be63 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/saveAcknowledgement.ts b/webview-ui/src/components/settings/__tests__/saveAcknowledgement.ts new file mode 100644 index 0000000000..2f24c431e4 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/saveAcknowledgement.ts @@ -0,0 +1,28 @@ +import { act, fireEvent, screen } from "@testing-library/react" + +// Only the recorded calls are read, so any `vi.fn()` or `vi.mocked(fn)` works. +interface PostMessage { + mock: { calls: ReadonlyArray> } +} + +/** The request id of the most recent `updateSettings` the component posted. */ +export const lastSaveRequestId = (postMessage: PostMessage): string | undefined => { + const saves = postMessage.mock.calls + .map((call) => call[0] as { type?: string; requestId?: string }) + .filter((message) => message?.type === "updateSettings") + return saves.at(-1)?.requestId +} + +/** Plays the extension host's answer to a save. */ +export const answerSave = (requestId: string | undefined, result: { success: boolean; error?: string }) => + act(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: "settingsSaveResult", requestId, ...result } }), + ) + }) + +/** Clicks Save, then reports the host's success, so the follow-up messages (API config, telemetry) go out. */ +export const saveAndConfirm = (postMessage: PostMessage) => { + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) +} diff --git a/webview-ui/src/components/settings/__tests__/saveRequestId.spec.ts b/webview-ui/src/components/settings/__tests__/saveRequestId.spec.ts new file mode 100644 index 0000000000..531f3f423a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/saveRequestId.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest" + +describe("nextSaveRequestId", () => { + it("never repeats within a load", async () => { + const { nextSaveRequestId } = await import("../saveRequestId") + + const ids = Array.from({ length: 1000 }, () => nextSaveRequestId()) + + expect(new Set(ids).size).toBe(1000) + for (const id of ids) expect(id).toMatch(/^settings-save-[a-z0-9]+-\d+$/) + }) + + it("does not repeat after the webview reloads, although the counter starts again", async () => { + vi.resetModules() + const beforeReload = (await import("../saveRequestId")).nextSaveRequestId() + + vi.resetModules() + const afterReload = (await import("../saveRequestId")).nextSaveRequestId() + + // Both are the first save of their load, so only the part that is different on every load tells them apart. + expect(beforeReload.endsWith("-1")).toBe(true) + expect(afterReload.endsWith("-1")).toBe(true) + expect(afterReload).not.toBe(beforeReload) + }) + + it("would repeat without the part that differs per load", async () => { + // The reason the part exists: with the counter alone, the first save after a reload has the first save's id. + const counterOnly = () => { + let counter = 0 + return () => `settings-save-${++counter}` + } + expect(counterOnly()()).toBe(counterOnly()()) + }) +}) diff --git a/webview-ui/src/components/settings/saveRequestId.ts b/webview-ui/src/components/settings/saveRequestId.ts new file mode 100644 index 0000000000..95265e693a --- /dev/null +++ b/webview-ui/src/components/settings/saveRequestId.ts @@ -0,0 +1,10 @@ +// Different on every load of the webview. A reload starts the counter again, and an answer to a save that was sent +// before the reload can still arrive, so a counter alone could give a new save the id of an old, unanswered one. +const loadId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` + +// Module scope, not per mount: an answer to a save from an earlier mount can still arrive, and it must not match a +// later mount's request id. +let counter = 0 + +/** An id for a settings save that no other save has used: not in this mount, an earlier one, or before a reload. */ +export const nextSaveRequestId = () => `settings-save-${loadId}-${++counter}` diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 41f94b5ff5..b198dac406 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -35,6 +35,7 @@ import { CustomSupportPrompts } from "@roo/support-prompt" import { experimentDefault } from "@roo/experiments" import { vscode } from "@src/utils/vscode" +import { applyAppearanceVariables } from "@src/utils/appearance" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" import { StringCache } from "@src/utils/stringCache" @@ -133,6 +134,10 @@ export interface ExtensionStateContextType extends ExtensionState { setHistoryPreviewCollapsed: (value: boolean) => void setReasoningBlockCollapsed: (value: boolean) => void chatFontSize?: number + interfaceFontScale?: number + terminalFontSize?: number + editorFontSize?: number + spacingScale?: number setChatFontSize: (value: number | undefined) => void enterBehavior?: "send" | "newline" setEnterBehavior: (value: "send" | "newline") => void @@ -619,11 +624,26 @@ export const ExtensionStateContextProvider: React.FC<{ } }, [state.chatFontSize]) + // Appearance scales/sizes. Unset values remove the inline override so the + // stylesheet defaults in index.css (100% / VS Code's sizes) apply. + useEffect(() => { + applyAppearanceVariables(document.documentElement, { + interfaceFontScale: state.interfaceFontScale, + terminalFontSize: state.terminalFontSize, + editorFontSize: state.editorFontSize, + spacingScale: state.spacingScale, + }) + }, [state.interfaceFontScale, state.terminalFontSize, state.editorFontSize, state.spacingScale]) + const contextValue: ExtensionStateContextType = { ...state, // `chatFontSize` is persisted as nullish (null on reset); normalize null to // undefined so it matches the context type and means "use VS Code default". chatFontSize: state.chatFontSize ?? undefined, + interfaceFontScale: state.interfaceFontScale ?? undefined, + terminalFontSize: state.terminalFontSize ?? undefined, + editorFontSize: state.editorFontSize ?? undefined, + spacingScale: state.spacingScale ?? undefined, reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true, didHydrateState, showWelcome, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 424255245a..0ac2bc91a0 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -92,6 +92,33 @@ const ChatFontSizeTestComponent = () => { ) } +const AppearanceTestComponent = () => { + const { interfaceFontScale, terminalFontSize, editorFontSize, spacingScale } = useExtensionState() + + return ( +
+ {JSON.stringify({ + interfaceFontScale: interfaceFontScale ?? null, + terminalFontSize: terminalFontSize ?? null, + editorFontSize: editorFontSize ?? null, + spacingScale: spacingScale ?? null, + })} +
+ ) +} + +const sendState = (state: Partial) => + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "state", state } })) + }) + +const APPEARANCE_VARS = [ + "--zoo-ui-scale", + "--zoo-terminal-font-size", + "--zoo-editor-font-size", + "--zoo-spacing-scale", +] as const + const ApiConfigTestComponent = () => { const { apiConfiguration, setApiConfiguration } = useExtensionState() @@ -794,6 +821,79 @@ describe("ExtensionStateContext", () => { expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("") }) + describe("appearance settings", () => { + const clearVars = () => { + for (const name of APPEARANCE_VARS) document.documentElement.style.removeProperty(name) + document.documentElement.classList.remove("zoo-editor-font-set") + } + const appearance = () => JSON.parse(screen.getByTestId("appearance").textContent!) + const renderAppearance = () => + render( + + + , + ) + + beforeEach(clearVars) + afterEach(clearVars) + + it("leaves every value unset and the CSS variables absent before any state arrives", () => { + renderAppearance() + + expect(appearance()).toEqual({ + interfaceFontScale: null, + terminalFontSize: null, + editorFontSize: null, + spacingScale: null, + }) + for (const name of APPEARANCE_VARS) expect(document.documentElement.style.getPropertyValue(name)).toBe("") + }) + + it("hydrates all four values from the host state and applies their CSS variables", () => { + renderAppearance() + + sendState({ interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 }) + + expect(appearance()).toEqual({ + interfaceFontScale: 120, + terminalFontSize: 14, + editorFontSize: 15, + spacingScale: 80, + }) + const style = document.documentElement.style + expect(style.getPropertyValue("--zoo-ui-scale")).toBe("1.2") + expect(style.getPropertyValue("--zoo-terminal-font-size")).toBe("14px") + expect(style.getPropertyValue("--zoo-editor-font-size")).toBe("15px") + expect(style.getPropertyValue("--zoo-spacing-scale")).toBe("0.8") + }) + + it("treats a null from the host (a saved reset) as unset and removes the CSS variables", () => { + renderAppearance() + sendState({ interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 }) + + sendState({ interfaceFontScale: null, terminalFontSize: null, editorFontSize: null, spacingScale: null }) + + expect(appearance()).toEqual({ + interfaceFontScale: null, + terminalFontSize: null, + editorFontSize: null, + spacingScale: null, + }) + for (const name of APPEARANCE_VARS) expect(document.documentElement.style.getPropertyValue(name)).toBe("") + }) + + it("updates one value without disturbing the others", () => { + renderAppearance() + sendState({ interfaceFontScale: 120, terminalFontSize: 14 }) + + sendState({ terminalFontSize: 18 }) + + expect(appearance()).toMatchObject({ interfaceFontScale: 120, terminalFontSize: 18 }) + expect(document.documentElement.style.getPropertyValue("--zoo-ui-scale")).toBe("1.2") + expect(document.documentElement.style.getPropertyValue("--zoo-terminal-font-size")).toBe("18px") + }) + }) + it("updates allowedCommands through setAllowedCommands", () => { render( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d39d8edd6a..5a631162eb 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Tanca automaticament els fitxers nous creats", "description": "Quan esta activat, les pestanyes dels fitxers que Zoo ha creat (en lloc de modificar) tambe es tanquen automaticament despres d'acceptar el canvi." + }, + "appearance": { + "reset": "Restableix el valor per defecte", + "interfaceFontScale": { + "label": "Mida de la lletra de la interfície", + "description": "Escala tot el text de Zoo Code. El 100% coincideix amb VS Code." + }, + "terminalFontSize": { + "label": "Mida de la lletra del terminal", + "description": "Mida de la lletra de la sortida d'ordres mostrada al xat." + }, + "editorFontSize": { + "label": "Mida de la lletra de l'editor", + "description": "Mida de la lletra dels blocs de codi i dels diffs mostrats al xat." + }, + "spacingScale": { + "label": "Densitat de l'espaiat", + "description": "Farciment entre els missatges del xat. Un valor més baix és més compacte." + }, + "preview": "Previsualització", + "sample": { + "you": "Tu", + "zoo": "Zoo", + "user": "Afegeix un indicador de càrrega al botó Desa", + "assistant": "Fet. He afegit un indicador que es mostra mentre la sol·licitud està en curs." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 983db1df5d..ce9e4b4d06 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Neu erstellte Dateien automatisch schließen", "description": "Wenn aktiviert, werden Tabs für Dateien, die Zoo neu erstellt hat (anstatt sie zu bearbeiten), nach dem Akzeptieren der Änderung ebenfalls automatisch geschlossen." + }, + "appearance": { + "reset": "Auf Standard zurücksetzen", + "interfaceFontScale": { + "label": "Schriftgröße der Oberfläche", + "description": "Skaliert den gesamten Zoo-Code-Text. 100 % entspricht VS Code." + }, + "terminalFontSize": { + "label": "Schriftgröße des Terminals", + "description": "Schriftgröße für die im Chat angezeigte Befehlsausgabe." + }, + "editorFontSize": { + "label": "Schriftgröße des Editors", + "description": "Schriftgröße für Codeblöcke und Diffs im Chat." + }, + "spacingScale": { + "label": "Abstandsdichte", + "description": "Innenabstand zwischen Chat-Nachrichten. Niedriger ist kompakter." + }, + "preview": "Vorschau", + "sample": { + "you": "Du", + "zoo": "Zoo", + "user": "Füge der Schaltfläche „Speichern“ einen Ladeindikator hinzu", + "assistant": "Erledigt. Ich habe einen Spinner hinzugefügt, der angezeigt wird, solange die Anfrage läuft." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index ea924ce79e..b1c9862193 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -242,6 +242,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Auto-close newly created files", "description": "When enabled, tabs for files that Zoo created (rather than modified) will also be closed automatically after the edit is accepted." + }, + "appearance": { + "reset": "Reset to default", + "interfaceFontScale": { + "label": "Interface font size", + "description": "Scales all Zoo Code text. 100% matches VS Code." + }, + "terminalFontSize": { + "label": "Terminal font size", + "description": "Font size for command output shown in chat." + }, + "editorFontSize": { + "label": "Editor font size", + "description": "Font size for code blocks and diffs shown in chat." + }, + "spacingScale": { + "label": "Spacing density", + "description": "Padding between chat messages. Lower is more compact." + }, + "preview": "Preview", + "sample": { + "you": "You", + "zoo": "Zoo", + "user": "Add a loading spinner to the Save button", + "assistant": "Done. I added a spinner that shows while the request is in flight." + } } }, "prompts": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 57612bf3e5..3a07469c23 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Cerrar automaticamente los archivos nuevos creados", "description": "Cuando esta opcion esta activada, las pestanas de archivos que Zoo ha creado (en lugar de modificar) tambien se cierran automaticamente despues de aceptar la modificacion." + }, + "appearance": { + "reset": "Restablecer al valor predeterminado", + "interfaceFontScale": { + "label": "Tamaño de fuente de la interfaz", + "description": "Escala todo el texto de Zoo Code. El 100 % coincide con VS Code." + }, + "terminalFontSize": { + "label": "Tamaño de fuente del terminal", + "description": "Tamaño de fuente de la salida de comandos que se muestra en el chat." + }, + "editorFontSize": { + "label": "Tamaño de fuente del editor", + "description": "Tamaño de fuente de los bloques de código y los diffs que se muestran en el chat." + }, + "spacingScale": { + "label": "Densidad de espaciado", + "description": "Relleno entre los mensajes del chat. Un valor menor es más compacto." + }, + "preview": "Vista previa", + "sample": { + "you": "Tú", + "zoo": "Zoo", + "user": "Añade un indicador de carga al botón Guardar", + "assistant": "Listo. He añadido un indicador que se muestra mientras la solicitud está en curso." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a91a5137d3..4c8045032b 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Fermer automatiquement les nouveaux fichiers créés", "description": "Quand cette option est activée, les onglets des fichiers que Zoo a créés (plutôt que modifiés) sont également fermés automatiquement après l'acceptation de la modification." + }, + "appearance": { + "reset": "Rétablir la valeur par défaut", + "interfaceFontScale": { + "label": "Taille de police de l'interface", + "description": "Met à l'échelle tout le texte de Zoo Code. 100 % correspond à VS Code." + }, + "terminalFontSize": { + "label": "Taille de police du terminal", + "description": "Taille de police de la sortie des commandes affichée dans le chat." + }, + "editorFontSize": { + "label": "Taille de police de l'éditeur", + "description": "Taille de police des blocs de code et des diffs affichés dans le chat." + }, + "spacingScale": { + "label": "Densité d'espacement", + "description": "Marge intérieure entre les messages du chat. Plus bas, plus compact." + }, + "preview": "Aperçu", + "sample": { + "you": "Vous", + "zoo": "Zoo", + "user": "Ajoute un indicateur de chargement au bouton Enregistrer", + "assistant": "Terminé. J'ai ajouté un indicateur qui s'affiche pendant que la requête est en cours." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index bc6e973623..43e926a4d8 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Nai banayi gayi files ko svachalit band karo", "description": "Saksham hone par, Zoo dvara banaayi gayi (sanshodhit nahin) files ki tabs ko bhi parivartan svikaar hone ke baad svachalit roop se band kar diya jaata hai." + }, + "appearance": { + "reset": "डिफ़ॉल्ट पर रीसेट करें", + "interfaceFontScale": { + "label": "इंटरफ़ेस फ़ॉन्ट आकार", + "description": "Zoo Code के सारे टेक्स्ट को स्केल करता है। 100% VS Code के बराबर है।" + }, + "terminalFontSize": { + "label": "टर्मिनल फ़ॉन्ट आकार", + "description": "चैट में दिखाए गए कमांड आउटपुट का फ़ॉन्ट आकार।" + }, + "editorFontSize": { + "label": "एडिटर फ़ॉन्ट आकार", + "description": "चैट में दिखाए गए कोड ब्लॉक और डिफ़ का फ़ॉन्ट आकार।" + }, + "spacingScale": { + "label": "स्पेसिंग घनत्व", + "description": "चैट संदेशों के बीच की पैडिंग। कम मान अधिक सघन होता है।" + }, + "preview": "पूर्वावलोकन", + "sample": { + "you": "आप", + "zoo": "Zoo", + "user": "Save बटन में लोडिंग स्पिनर जोड़ें", + "assistant": "हो गया। मैंने एक स्पिनर जोड़ा है जो अनुरोध चलने के दौरान दिखता है।" + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3ed57f373e..db60af9c3b 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Tutup otomatis file baru yang dibuat", "description": "Saat diaktifkan, tab untuk file yang dibuat Zoo (bukan dimodifikasi) juga ditutup secara otomatis setelah perubahan diterima." + }, + "appearance": { + "reset": "Setel ulang ke default", + "interfaceFontScale": { + "label": "Ukuran font antarmuka", + "description": "Menskalakan semua teks Zoo Code. 100% sama dengan VS Code." + }, + "terminalFontSize": { + "label": "Ukuran font terminal", + "description": "Ukuran font untuk output perintah yang ditampilkan di chat." + }, + "editorFontSize": { + "label": "Ukuran font editor", + "description": "Ukuran font untuk blok kode dan diff yang ditampilkan di chat." + }, + "spacingScale": { + "label": "Kerapatan spasi", + "description": "Padding antar pesan chat. Nilai lebih rendah lebih ringkas." + }, + "preview": "Pratinjau", + "sample": { + "you": "Anda", + "zoo": "Zoo", + "user": "Tambahkan spinner pemuatan ke tombol Simpan", + "assistant": "Selesai. Saya menambahkan spinner yang tampil selama permintaan berlangsung." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b708304e63..e9b06eea5d 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Chiudi automaticamente i nuovi file creati", "description": "Quando abilitata, le schede dei file che Zoo ha creato (invece di modificare) vengono chiuse automaticamente dopo l'accettazione della modifica." + }, + "appearance": { + "reset": "Ripristina predefinito", + "interfaceFontScale": { + "label": "Dimensione carattere dell'interfaccia", + "description": "Ridimensiona tutto il testo di Zoo Code. 100% corrisponde a VS Code." + }, + "terminalFontSize": { + "label": "Dimensione carattere del terminale", + "description": "Dimensione carattere dell'output dei comandi mostrato nella chat." + }, + "editorFontSize": { + "label": "Dimensione carattere dell'editor", + "description": "Dimensione carattere dei blocchi di codice e dei diff mostrati nella chat." + }, + "spacingScale": { + "label": "Densità della spaziatura", + "description": "Spaziatura interna tra i messaggi della chat. Un valore più basso è più compatto." + }, + "preview": "Anteprima", + "sample": { + "you": "Tu", + "zoo": "Zoo", + "user": "Aggiungi un indicatore di caricamento al pulsante Salva", + "assistant": "Fatto. Ho aggiunto un indicatore che compare mentre la richiesta è in corso." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index f6959b50dc..d1f65f50c3 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "新しく作成されたファイルを自動的に閉じる", "description": "有効にすると、Zooが作成した(変更ではなく)ファイルのタブも、変更を承認した後に自動的に閉じられます。" + }, + "appearance": { + "reset": "デフォルトに戻す", + "interfaceFontScale": { + "label": "インターフェースのフォントサイズ", + "description": "Zoo Code のすべてのテキストを拡大縮小します。100% は VS Code と同じです。" + }, + "terminalFontSize": { + "label": "ターミナルのフォントサイズ", + "description": "チャットに表示されるコマンド出力のフォントサイズ。" + }, + "editorFontSize": { + "label": "エディターのフォントサイズ", + "description": "チャットに表示されるコードブロックと diff のフォントサイズ。" + }, + "spacingScale": { + "label": "余白の密度", + "description": "チャットメッセージ間の余白。値が小さいほどコンパクトになります。" + }, + "preview": "プレビュー", + "sample": { + "you": "あなた", + "zoo": "Zoo", + "user": "保存ボタンに読み込み中スピナーを追加して", + "assistant": "完了しました。リクエスト処理中に表示されるスピナーを追加しました。" + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 6cadbd8a3b..a46c144e81 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "새로 만든 파일 자동 닫기", "description": "활성화 시, Zoo가 수정한 것이 아니라 새로 만든 파일의 탭도 변경 사항을 승인한 후 자동으로 닫힙니다." + }, + "appearance": { + "reset": "기본값으로 재설정", + "interfaceFontScale": { + "label": "인터페이스 글꼴 크기", + "description": "모든 Zoo Code 텍스트의 크기를 조절합니다. 100%는 VS Code와 같습니다." + }, + "terminalFontSize": { + "label": "터미널 글꼴 크기", + "description": "채팅에 표시되는 명령 출력의 글꼴 크기입니다." + }, + "editorFontSize": { + "label": "편집기 글꼴 크기", + "description": "채팅에 표시되는 코드 블록과 diff의 글꼴 크기입니다." + }, + "spacingScale": { + "label": "간격 밀도", + "description": "채팅 메시지 사이의 여백입니다. 낮을수록 더 촘촘합니다." + }, + "preview": "미리보기", + "sample": { + "you": "나", + "zoo": "Zoo", + "user": "저장 버튼에 로딩 스피너를 추가해 줘", + "assistant": "완료했습니다. 요청이 진행되는 동안 표시되는 스피너를 추가했습니다." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 992e9c53e0..39d70cf889 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Automatisch sluiten van nieuw gemaakte bestanden", "description": "Wanneer ingeschakeld, worden tabbladen van bestanden die Zoo heeft aangemaakt (in plaats van gewijzigd) ook automatisch gesloten nadat de wijziging is geaccepteerd." + }, + "appearance": { + "reset": "Standaardwaarde herstellen", + "interfaceFontScale": { + "label": "Lettergrootte van de interface", + "description": "Schaalt alle Zoo Code-tekst. 100% komt overeen met VS Code." + }, + "terminalFontSize": { + "label": "Lettergrootte van de terminal", + "description": "Lettergrootte voor de opdrachtuitvoer die in de chat wordt getoond." + }, + "editorFontSize": { + "label": "Lettergrootte van de editor", + "description": "Lettergrootte voor codeblokken en diffs in de chat." + }, + "spacingScale": { + "label": "Dichtheid van de witruimte", + "description": "Opvulling tussen chatberichten. Lager is compacter." + }, + "preview": "Voorbeeld", + "sample": { + "you": "Jij", + "zoo": "Zoo", + "user": "Voeg een laadspinner toe aan de knop Opslaan", + "assistant": "Klaar. Ik heb een spinner toegevoegd die wordt getoond zolang het verzoek loopt." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 459675b0e1..ded72ee954 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Automatycznie zamykaj nowo utworzone pliki", "description": "Gdy wlaczone, karty plikow, ktore Zoo utworzylo (zamiast zmodyfikowalo), sa rowniez automatycznie zamykane po zaakceptowaniu zmiany." + }, + "appearance": { + "reset": "Przywróć domyślne", + "interfaceFontScale": { + "label": "Rozmiar czcionki interfejsu", + "description": "Skaluje cały tekst Zoo Code. 100% odpowiada VS Code." + }, + "terminalFontSize": { + "label": "Rozmiar czcionki terminala", + "description": "Rozmiar czcionki danych wyjściowych poleceń wyświetlanych w czacie." + }, + "editorFontSize": { + "label": "Rozmiar czcionki edytora", + "description": "Rozmiar czcionki bloków kodu i różnic wyświetlanych w czacie." + }, + "spacingScale": { + "label": "Gęstość odstępów", + "description": "Odstęp między wiadomościami czatu. Niższa wartość oznacza zwartszy układ." + }, + "preview": "Podgląd", + "sample": { + "you": "Ty", + "zoo": "Zoo", + "user": "Dodaj wskaźnik ładowania do przycisku Zapisz", + "assistant": "Gotowe. Dodałem wskaźnik, który jest widoczny, gdy żądanie jest w toku." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 500babaaa8..de7858730b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Fechar automaticamente novos arquivos criados", "description": "Quando ativada, as abas de arquivos que o Zoo criou (em vez de modificar) tambem sao fechadas automaticamente apos aceitar a modificacao." + }, + "appearance": { + "reset": "Restaurar padrão", + "interfaceFontScale": { + "label": "Tamanho da fonte da interface", + "description": "Dimensiona todo o texto do Zoo Code. 100% corresponde ao VS Code." + }, + "terminalFontSize": { + "label": "Tamanho da fonte do terminal", + "description": "Tamanho da fonte da saída de comandos exibida no chat." + }, + "editorFontSize": { + "label": "Tamanho da fonte do editor", + "description": "Tamanho da fonte dos blocos de código e diffs exibidos no chat." + }, + "spacingScale": { + "label": "Densidade de espaçamento", + "description": "Preenchimento entre as mensagens do chat. Menor é mais compacto." + }, + "preview": "Pré-visualização", + "sample": { + "you": "Você", + "zoo": "Zoo", + "user": "Adicione um indicador de carregamento ao botão Salvar", + "assistant": "Pronto. Adicionei um indicador que aparece enquanto a solicitação está em andamento." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 053ff0684d..214e8ea67a 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Автоматически закрывать новые созданные файлы", "description": "Если включено, вкладки файлов, которые Zoo создал (а не изменил), также автоматически закрываются после принятия изменения." + }, + "appearance": { + "reset": "Сбросить по умолчанию", + "interfaceFontScale": { + "label": "Размер шрифта интерфейса", + "description": "Масштабирует весь текст Zoo Code. 100% соответствует VS Code." + }, + "terminalFontSize": { + "label": "Размер шрифта терминала", + "description": "Размер шрифта вывода команд, показываемого в чате." + }, + "editorFontSize": { + "label": "Размер шрифта редактора", + "description": "Размер шрифта блоков кода и диффов, показываемых в чате." + }, + "spacingScale": { + "label": "Плотность отступов", + "description": "Отступы между сообщениями чата. Чем меньше значение, тем компактнее." + }, + "preview": "Предпросмотр", + "sample": { + "you": "Вы", + "zoo": "Zoo", + "user": "Добавь индикатор загрузки на кнопку «Сохранить»", + "assistant": "Готово. Я добавил индикатор, который отображается, пока выполняется запрос." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index b37f28d44d..091c1e6eb6 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Yeni olusturulan dosyalari otomatik kapat", "description": "Etkinlestirildiginde, Zoo'nun olusturdugu (degistirmedigi) dosyalarin sekmeleri de degisiklik kabul edildikten sonra otomatik olarak kapatilir." + }, + "appearance": { + "reset": "Varsayılana sıfırla", + "interfaceFontScale": { + "label": "Arayüz yazı tipi boyutu", + "description": "Tüm Zoo Code metnini ölçekler. %100, VS Code ile aynıdır." + }, + "terminalFontSize": { + "label": "Terminal yazı tipi boyutu", + "description": "Sohbette gösterilen komut çıktısının yazı tipi boyutu." + }, + "editorFontSize": { + "label": "Düzenleyici yazı tipi boyutu", + "description": "Sohbette gösterilen kod blokları ve diff'lerin yazı tipi boyutu." + }, + "spacingScale": { + "label": "Boşluk yoğunluğu", + "description": "Sohbet mesajları arasındaki dolgu. Düşük değer daha sıkışık görünür." + }, + "preview": "Önizleme", + "sample": { + "you": "Sen", + "zoo": "Zoo", + "user": "Kaydet düğmesine bir yükleniyor göstergesi ekle", + "assistant": "Tamam. İstek sürerken görünen bir gösterge ekledim." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 94843217ae..b4a1ee3f90 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "Tự động đóng các tệp mới tạo", "description": "Khi bật, các tab của các tệp mà Zoo đã tạo (thay vì chỉnh sửa) cũng sẽ tự động đóng sau khi chấp nhận thay đổi." + }, + "appearance": { + "reset": "Đặt lại về mặc định", + "interfaceFontScale": { + "label": "Cỡ chữ giao diện", + "description": "Thu phóng toàn bộ văn bản của Zoo Code. 100% khớp với VS Code." + }, + "terminalFontSize": { + "label": "Cỡ chữ terminal", + "description": "Cỡ chữ cho kết quả lệnh hiển thị trong chat." + }, + "editorFontSize": { + "label": "Cỡ chữ trình soạn thảo", + "description": "Cỡ chữ cho khối mã và diff hiển thị trong chat." + }, + "spacingScale": { + "label": "Mật độ khoảng cách", + "description": "Khoảng đệm giữa các tin nhắn chat. Giá trị thấp hơn sẽ gọn hơn." + }, + "preview": "Xem trước", + "sample": { + "you": "Bạn", + "zoo": "Zoo", + "user": "Thêm biểu tượng đang tải vào nút Lưu", + "assistant": "Xong. Tôi đã thêm một biểu tượng hiển thị khi yêu cầu đang được xử lý." + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8be0fe1532..6770e7992c 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1179,6 +1179,32 @@ "autoCloseZooOpenedNewFiles": { "label": "自动关闭新建的文件", "description": "启用后,Zoo 新建(而非修改)的文件标签页在接受更改后也会自动关闭。" + }, + "appearance": { + "reset": "恢复默认", + "interfaceFontScale": { + "label": "界面字体大小", + "description": "缩放所有 Zoo Code 文本。100% 与 VS Code 一致。" + }, + "terminalFontSize": { + "label": "终端字体大小", + "description": "聊天中显示的命令输出的字体大小。" + }, + "editorFontSize": { + "label": "编辑器字体大小", + "description": "聊天中显示的代码块和 diff 的字体大小。" + }, + "spacingScale": { + "label": "间距密度", + "description": "聊天消息之间的内边距。数值越小越紧凑。" + }, + "preview": "预览", + "sample": { + "you": "你", + "zoo": "Zoo", + "user": "给保存按钮添加一个加载动画", + "assistant": "完成。我添加了一个在请求进行时显示的加载动画。" + } } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 65eb5c67a5..86ce43a94d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -189,6 +189,32 @@ "autoCloseZooOpenedNewFiles": { "label": "自動關閉新建立的檔案", "description": "啟用後,Zoo 新建(而非修改)的檔案分頁在接受變更後也會自動關閉。" + }, + "appearance": { + "reset": "還原為預設值", + "interfaceFontScale": { + "label": "介面字型大小", + "description": "縮放所有 Zoo Code 文字。100% 與 VS Code 相同。" + }, + "terminalFontSize": { + "label": "終端機字型大小", + "description": "聊天中顯示的指令輸出的字型大小。" + }, + "editorFontSize": { + "label": "編輯器字型大小", + "description": "聊天中顯示的程式碼區塊與 diff 的字型大小。" + }, + "spacingScale": { + "label": "間距密度", + "description": "聊天訊息之間的內距。數值越小越緊湊。" + }, + "preview": "預覽", + "sample": { + "you": "你", + "zoo": "Zoo", + "user": "為儲存按鈕加上載入動畫", + "assistant": "完成。我加入了一個在請求進行中顯示的載入動畫。" + } } }, "prompts": { diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index bccbdf7031..808063b3b9 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -27,10 +27,10 @@ @theme { --font-display: var(--vscode-font-family); - --text-xs: calc(var(--vscode-font-size) * 0.85); - --text-sm: calc(var(--vscode-font-size) * 0.9); - --text-base: var(--vscode-font-size); - --text-lg: calc(var(--vscode-font-size) * 1.1); + --text-xs: calc(var(--vscode-font-size) * var(--zoo-ui-scale, 1) * 0.85); + --text-sm: calc(var(--vscode-font-size) * var(--zoo-ui-scale, 1) * 0.9); + --text-base: calc(var(--vscode-font-size) * var(--zoo-ui-scale, 1)); + --text-lg: calc(var(--vscode-font-size) * var(--zoo-ui-scale, 1) * 1.1); --color-background: var(--background); --color-foreground: var(--foreground); @@ -266,6 +266,25 @@ html { * runtime. Scoped to chat rendering so it does not resize settings/other UI. */ --zoo-chat-font-size: var(--vscode-font-size); + + /* + * Appearance settings (UI tab). Each is overridden inline on by + * ExtensionStateContext only when the user sets it, so the defaults below + * reproduce the historical look exactly (scale 1, VS Code sizes, 10/15px). + */ + --zoo-ui-scale: 1; + --zoo-spacing-scale: 1; + --zoo-row-py: calc(10px * var(--zoo-spacing-scale)); + --zoo-row-px: calc(15px * var(--zoo-spacing-scale)); +} + +/* + * Interface font size scales the whole webview's base text. Tailwind's text-* + * tokens and most components derive from body font-size, so one rule covers + * chat, settings and history without touching each component. + */ +body { + font-size: calc(var(--vscode-font-size) * var(--zoo-ui-scale)); } body { @@ -550,6 +569,17 @@ input[cmdk-input]:focus { "liga" 1; } +/* + * Editor font size from the UI tab. Scoped to a class that is only present while + * the user has set a value, so the diff view keeps its original sizing otherwise. + */ +html.zoo-editor-font-set .diff-view, +html.zoo-editor-font-set .diff-view pre, +html.zoo-editor-font-set .diff-view code, +html.zoo-editor-font-set .diff-view .hljs { + font-size: var(--zoo-editor-font-size); +} + /* DiffView background tints via CSS classes instead of inline styles */ .diff-content-inserted { background-color: color-mix(in srgb, var(--vscode-diffEditor-insertedTextBackground) 70%, transparent); diff --git a/webview-ui/src/utils/__tests__/appearance.spec.ts b/webview-ui/src/utils/__tests__/appearance.spec.ts new file mode 100644 index 0000000000..400306b042 --- /dev/null +++ b/webview-ui/src/utils/__tests__/appearance.spec.ts @@ -0,0 +1,116 @@ +import { globalSettingsSchema } from "@roo-code/types" + +import { applyAppearanceVariables, clampAppearance, isChatFontSize } from "../appearance" + +describe("clampAppearance", () => { + it("passes through in-range integers", () => { + expect(clampAppearance("interfaceFontScale", 110)).toBe(110) + expect(clampAppearance("terminalFontSize", 14)).toBe(14) + }) + + // The bounds are written out here, not read from APPEARANCE_SETTINGS (the thing under test), and the host checks + // the same bounds with globalSettingsSchema: a drift in either place fails one of these. + const bounds = [ + ["interfaceFontScale", 50, 200], + ["terminalFontSize", 8, 32], + ["editorFontSize", 8, 32], + ["spacingScale", 50, 200], + ] as const + + it.each(bounds)("%s is clamped to %i..%i", (key, min, max) => { + expect(clampAppearance(key, min - 1)).toBe(min) + expect(clampAppearance(key, 0)).toBe(min) + expect(clampAppearance(key, min)).toBe(min) + expect(clampAppearance(key, max)).toBe(max) + expect(clampAppearance(key, max + 1)).toBe(max) + expect(clampAppearance(key, 99999)).toBe(max) + }) + + it.each(bounds)("%s: the host's schema accepts the bounds and refuses a value outside them", (key, min, max) => { + const schema = globalSettingsSchema.shape[key] + expect(schema.safeParse(min).success).toBe(true) + expect(schema.safeParse(max).success).toBe(true) + expect(schema.safeParse(min - 1).success).toBe(false) + expect(schema.safeParse(max + 1).success).toBe(false) + // And what the stepper can produce is always accepted. + for (const value of [min - 10, min, max, max + 10]) { + expect(schema.safeParse(clampAppearance(key, value)).success).toBe(true) + } + }) + + it("rounds fractional input", () => { + expect(clampAppearance("spacingScale", 87.6)).toBe(88) + }) + + it("treats null/undefined/NaN as unset", () => { + expect(clampAppearance("spacingScale", undefined)).toBeUndefined() + expect(clampAppearance("spacingScale", null)).toBeUndefined() + expect(clampAppearance("spacingScale", NaN)).toBeUndefined() + }) +}) + +describe("applyAppearanceVariables", () => { + let root: HTMLElement + + beforeEach(() => { + root = document.createElement("div") + }) + + it("writes percent settings as unitless multipliers and px settings as px", () => { + applyAppearanceVariables(root, { + interfaceFontScale: 120, + terminalFontSize: 14, + editorFontSize: 13, + spacingScale: 80, + }) + expect(root.style.getPropertyValue("--zoo-ui-scale")).toBe("1.2") + expect(root.style.getPropertyValue("--zoo-terminal-font-size")).toBe("14px") + expect(root.style.getPropertyValue("--zoo-editor-font-size")).toBe("13px") + expect(root.style.getPropertyValue("--zoo-spacing-scale")).toBe("0.8") + }) + + it("removes overrides for unset values so stylesheet defaults apply", () => { + applyAppearanceVariables(root, { interfaceFontScale: 120, terminalFontSize: 14 }) + applyAppearanceVariables(root, { interfaceFontScale: undefined, terminalFontSize: null }) + expect(root.style.getPropertyValue("--zoo-ui-scale")).toBe("") + expect(root.style.getPropertyValue("--zoo-terminal-font-size")).toBe("") + }) + + it("marks the root only while an editor font size is set", () => { + applyAppearanceVariables(root, { editorFontSize: 15 }) + expect(root.classList.contains("zoo-editor-font-set")).toBe(true) + applyAppearanceVariables(root, { editorFontSize: undefined }) + expect(root.classList.contains("zoo-editor-font-set")).toBe(false) + }) + + it("clamps out-of-range values before writing", () => { + applyAppearanceVariables(root, { spacingScale: 1000 }) + expect(root.style.getPropertyValue("--zoo-spacing-scale")).toBe("2") + }) +}) + +describe("isChatFontSize", () => { + it("accepts exactly what the host's schema accepts", () => { + const schema = globalSettingsSchema.shape.chatFontSize + for (const value of [ + 7, + 8, + 9, + 16, + 31, + 32, + 33, + 12.5, + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + "12", + null, + undefined, + {}, + ]) { + expect(isChatFontSize(value)).toBe(typeof value === "number" && schema.safeParse(value).success) + } + }) +}) diff --git a/webview-ui/src/utils/appearance.ts b/webview-ui/src/utils/appearance.ts new file mode 100644 index 0000000000..c327fc9976 --- /dev/null +++ b/webview-ui/src/utils/appearance.ts @@ -0,0 +1,90 @@ +/** + * User-configurable appearance settings: interface text scale, terminal and + * editor (code) font sizes, and spacing scale. + * + * Each value maps to one CSS custom property on ; index.css owns the + * defaults and every consumer, so components never read these settings. + * Unset (undefined/null) removes the override and restores the default. + */ + +export interface AppearanceSetting { + min: number + max: number + step: number + /** Value shown in the stepper when the setting is unset. */ + fallback: number + unit: "%" | "px" + cssVar: string + toCss: (value: number) => string +} + +export const APPEARANCE_SETTINGS = { + interfaceFontScale: { + min: 50, + max: 200, + step: 5, + fallback: 100, + unit: "%", + cssVar: "--zoo-ui-scale", + toCss: (v) => String(v / 100), + }, + terminalFontSize: { + min: 8, + max: 32, + step: 1, + fallback: 12, + unit: "px", + cssVar: "--zoo-terminal-font-size", + toCss: (v) => `${v}px`, + }, + editorFontSize: { + min: 8, + max: 32, + step: 1, + fallback: 13, + unit: "px", + cssVar: "--zoo-editor-font-size", + toCss: (v) => `${v}px`, + }, + spacingScale: { + min: 50, + max: 200, + step: 5, + fallback: 100, + unit: "%", + cssVar: "--zoo-spacing-scale", + toCss: (v) => String(v / 100), + }, +} satisfies Record + +export type AppearanceKey = keyof typeof APPEARANCE_SETTINGS +export type AppearanceValues = Partial> + +/** The chat font size the host accepts (`globalSettingsSchema`): an integer from 8 to 32 px. */ +export const isChatFontSize = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 8 && value <= 32 + +/** Clamps to the setting's range and snaps to an integer; non-finite → undefined. */ +export function clampAppearance(key: AppearanceKey, value: number | null | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + const { min, max } = APPEARANCE_SETTINGS[key] + return Math.min(max, Math.max(min, Math.round(value))) +} + +/** Present on while the user has set an editor font size. */ +export const EDITOR_FONT_SET_CLASS = "zoo-editor-font-set" + +/** Writes (or clears) every appearance CSS variable on `root`. */ +export function applyAppearanceVariables(root: HTMLElement, values: AppearanceValues): void { + for (const key of Object.keys(APPEARANCE_SETTINGS) as AppearanceKey[]) { + const setting = APPEARANCE_SETTINGS[key] + const value = clampAppearance(key, values[key]) + if (value === undefined) { + root.style.removeProperty(setting.cssVar) + } else { + root.style.setProperty(setting.cssVar, setting.toCss(value)) + } + // Lets index.css apply the editor size to the diff view only while it is set. + if (key === "editorFontSize") root.classList.toggle(EDITOR_FONT_SET_CLASS, value !== undefined) + } +}