diff --git a/knip.json b/knip.json index 6f57a2fe54..d38d936a35 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,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 40f83fbaa2..61da751a22 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" @@ -341,6 +342,10 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "chatFontSize" + | "interfaceFontScale" + | "terminalFontSize" + | "editorFontSize" + | "spacingScale" | "enterBehavior" | "includeCurrentTime" | "includeCurrentCost" @@ -450,6 +455,14 @@ export type ExtensionState = Pick< * (captured during async getStateToPostToWebview) from overwriting newer messages. */ clineMessagesSeq?: number + + /** + * Monotonically increasing sequence number for every state push, taken together with the host's read + * of its settings. The frontend applies the appearance values (font sizes, spacing) of a push only if its + * seq is greater than the last applied one, so an older push that arrives late cannot restore + * values a newer push has already replaced. + */ + appearanceSeq?: number } export interface Command { diff --git a/src/core/config/__tests__/appearanceSettingWrites.spec.ts b/src/core/config/__tests__/appearanceSettingWrites.spec.ts new file mode 100644 index 0000000000..424b1283f5 --- /dev/null +++ b/src/core/config/__tests__/appearanceSettingWrites.spec.ts @@ -0,0 +1,639 @@ +import type { RooCodeSettings } from "@roo-code/types" + +import { + APPEARANCE_SETTING_KEYS, + beginSettingWrite, + finishSettingWrites, + isAppearanceSetting, + disposeSettingWrites, + performSettingWrite, + setValuesThroughWriteChain, + settingWriteBookkeepingSize, + 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) + finishSettingWrites([write]) // leave nothing linked for the next test + }) + + 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() + }) + + describe("a write that never settles", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + /** A store that settles only when it is released, and records what it stored. */ + const deferredProxy = () => { + const releases: Array<() => void> = [] + const proxy = { + getValue: (key: string) => stored.get(key), + setValue: vi.fn(async (key: string, value: unknown) => { + if (value === 120) await new Promise((resolve) => releases.push(resolve)) + stored.set(key, value) + }), + } as unknown as ContextProxy + return { proxy, landLate: () => releases.splice(0).forEach((release) => release()) } + } + + it("tells the caller after the timeout, refuses a new write of that key while the store is outstanding, and goes ahead once it has settled", async () => { + const { proxy, landLate } = deferredProxy() + const first = beginSettingWrite("interfaceFontScale", 100, 120) + const firstFailed = expect(performSettingWrite(proxy, first)).rejects.toThrow( + "did not finish in 15 seconds", + ) + + await vi.advanceTimersByTimeAsync(14_999) + await vi.advanceTimersByTimeAsync(1) + await firstFailed + + // The store is still outstanding: a new write is refused at once, not queued behind it and not started. + const second = beginSettingWrite("interfaceFontScale", 100, 150) + await expect(performSettingWrite(proxy, second)).rejects.toThrow("has not finished yet") + expect(proxy.setValue).toHaveBeenCalledTimes(1) + expect(stored.get("interfaceFontScale")).toBe(100) + + // It lands late. Nothing newer was written meanwhile, so it cannot have been overwritten or have overwritten. + landLate() + await vi.advanceTimersByTimeAsync(0) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(settingWriteBookkeepingSize().queuedKeys).toBe(0) + + // The key is free again. + const third = beginSettingWrite("interfaceFontScale", 120, 150) + await performSettingWrite(proxy, third) + expect(stored.get("interfaceFontScale")).toBe(150) + + finishSettingWrites([third]) + await undoSettingWrite(proxy, second) + await undoSettingWrite(proxy, first) + expect(vi.getTimerCount()).toBe(0) + }) + + it("restores the previous value when the store of a rolled-back write lands after the timeout", async () => { + const { proxy, landLate } = deferredProxy() + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + + // The save reported failure, so it is rolled back; its store has not settled yet. + await undoSettingWrite(proxy, save) // returns at once: nobody waits for the store + expect(stored.get("interfaceFontScale")).toBe(100) + const newer = beginSettingWrite("interfaceFontScale", 100, 150) + await expect(performSettingWrite(proxy, newer)).rejects.toThrow("has not finished yet") + + landLate() + await vi.advanceTimersByTimeAsync(0) + + // The store landed, and the rollback that was waiting behind it put the value back. + expect(stored.get("interfaceFontScale")).toBe(100) + expect(settingWriteBookkeepingSize().queuedKeys).toBe(0) + await undoSettingWrite(proxy, newer) + }) + + it("runs a deferred rollback before a write made after the store has settled, and does not undo that write", async () => { + const { proxy, landLate } = deferredProxy() + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + await undoSettingWrite(proxy, save) + + landLate() + // A write made as soon as the store has settled queues behind the rollback. + await vi.advanceTimersByTimeAsync(0) + const newer = beginSettingWrite("interfaceFontScale", 100, 150) + await performSettingWrite(proxy, newer) + finishSettingWrites([newer]) + + expect(stored.get("interfaceFontScale")).toBe(150) + }) + + it("leaves the value alone when the store that timed out fails in the end", async () => { + const failLate: Array<() => void> = [] + const proxy = { + getValue: (key: string) => stored.get(key), + setValue: vi.fn( + () => + new Promise((_resolve, reject) => { + failLate.push(() => reject(new Error("storage unavailable"))) + }), + ), + } as unknown as ContextProxy + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + await undoSettingWrite(proxy, save) + + failLate.forEach((fail) => fail()) + await vi.advanceTimersByTimeAsync(0) + + expect(stored.get("interfaceFontScale")).toBe(100) + expect(proxy.setValue).toHaveBeenCalledTimes(1) // nothing was stored, so nothing was put back + expect(settingWriteBookkeepingSize().queuedKeys).toBe(0) + }) + + it("drops a write whose caller gave up before it started, so it never lands later", async () => { + const { proxy, landLate } = deferredProxy() + const first = beginSettingWrite("interfaceFontScale", 100, 120) // the store that hangs + const second = beginSettingWrite("interfaceFontScale", 100, 150) // queued behind it + const firstDone = expect(performSettingWrite(proxy, first)).rejects.toThrow("did not finish") + const secondDone = expect(performSettingWrite(proxy, second)).rejects.toThrow("did not finish") + + await vi.advanceTimersByTimeAsync(15_000) + await firstDone + await secondDone + landLate() + await vi.advanceTimersByTimeAsync(0) + + // Only the first store was ever issued; the second was dropped, not run after the first landed. + expect(proxy.setValue).toHaveBeenCalledTimes(1) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(settingWriteBookkeepingSize().queuedKeys).toBe(0) + await undoSettingWrite(proxy, second) + await undoSettingWrite(proxy, first) + }) + + it("puts the first value back when two saves both fail, the second never having started, and the first lands late", async () => { + const { proxy, landLate } = deferredProxy() + const first = beginSettingWrite("interfaceFontScale", 100, 120) // the store that hangs + const second = beginSettingWrite("interfaceFontScale", 100, 150) // queued behind it, dropped at its timeout + const firstDone = expect(performSettingWrite(proxy, first)).rejects.toThrow("did not finish") + const secondDone = expect(performSettingWrite(proxy, second)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await firstDone + await secondDone + + // The saves report their failures in the order they gave up: the first one first. + await undoSettingWrite(proxy, first) + await undoSettingWrite(proxy, second) + landLate() + await vi.advanceTimersByTimeAsync(0) + + // The first save's value landed after both had failed, and the rollback of the second (which took over the + // first's) put back what was stored before either. + expect(stored.get("interfaceFontScale")).toBe(100) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("tells the caller when the rollback of a timed-out write is over, after the store has settled", async () => { + const { proxy, landLate } = deferredProxy() + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + const settled = vi.fn() + + await undoSettingWrite(proxy, save, settled) + expect(settled).not.toHaveBeenCalled() // the store is still outstanding + + landLate() + await vi.advanceTimersByTimeAsync(0) + + expect(settled).toHaveBeenCalledTimes(1) + expect(stored.get("interfaceFontScale")).toBe(100) + }) + + it("tells the caller too when the late store failed and nothing had to be put back", async () => { + const failLate: Array<() => void> = [] + const proxy = { + getValue: (key: string) => stored.get(key), + setValue: vi.fn( + () => new Promise((_resolve, reject) => failLate.push(() => reject(new Error("no")))), + ), + } as unknown as ContextProxy + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + const settled = vi.fn() + await undoSettingWrite(proxy, save, settled) + + failLate.forEach((fail) => fail()) + await vi.advanceTimersByTimeAsync(0) + + expect(settled).toHaveBeenCalledTimes(1) + }) + + it("does not call back for a rollback that did not have to wait for a store", async () => { + const proxy = { + getValue: (key: string) => stored.get(key), + setValue: async (key: string, value: unknown) => void stored.set(key, value), + } as unknown as ContextProxy + const write = beginSettingWrite("interfaceFontScale", 100, 120) + await performSettingWrite(proxy, write) + const settled = vi.fn() + + await undoSettingWrite(proxy, write, settled) + + expect(settled).not.toHaveBeenCalled() + expect(stored.get("interfaceFontScale")).toBe(100) + }) + + describe("when the provider is disposed", () => { + it("drops a write that is waiting behind a store, so it is never issued, and tells its caller", async () => { + const { proxy, landLate } = deferredProxy() + const first = beginSettingWrite("interfaceFontScale", 100, 120) // hangs + const second = beginSettingWrite("interfaceFontScale", 100, 150) // waiting behind it + const firstDone = expect(performSettingWrite(proxy, first)).rejects.toThrow("did not finish") + const secondDone = expect(performSettingWrite(proxy, second)).rejects.toThrow("provider was disposed") + await vi.advanceTimersByTimeAsync(1_000) + + disposeSettingWrites(proxy) + await secondDone + await vi.advanceTimersByTimeAsync(14_000) + await firstDone + landLate() + await vi.advanceTimersByTimeAsync(0) + + // Only the first store was ever issued, and it was not cancelled: it landed. + expect(proxy.setValue).toHaveBeenCalledTimes(1) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(settingWriteBookkeepingSize().queuedKeys).toBe(0) + expect(vi.getTimerCount()).toBe(0) + await undoSettingWrite(proxy, second) + await undoSettingWrite(proxy, first) + }) + + it("drops a rollback that is waiting behind a store", async () => { + const { proxy, landLate } = deferredProxy() + const save = beginSettingWrite("interfaceFontScale", 100, 120) + const saveFailed = expect(performSettingWrite(proxy, save)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await saveFailed + const settled = vi.fn() + await undoSettingWrite(proxy, save, settled) + + disposeSettingWrites(proxy) + landLate() + await vi.advanceTimersByTimeAsync(0) + + // The rollback was dropped with the provider: the store that landed stays, and the caller is told. + expect(stored.get("interfaceFontScale")).toBe(120) + expect(settled).toHaveBeenCalledTimes(1) + }) + + it("leaves the writes of another provider alone", async () => { + const { proxy, landLate } = deferredProxy() + const other = { + getValue: (key: string) => stored.get(key), + setValue: vi.fn(), + } as unknown as ContextProxy + const hung = beginSettingWrite("interfaceFontScale", 100, 120) + const hungDone = performSettingWrite(proxy, hung) // its store is outstanding + const waiting = beginSettingWrite("interfaceFontScale", 100, 150) + const waitingDone = performSettingWrite(proxy, waiting) // owned by `proxy`, waits behind `hung` + await vi.advanceTimersByTimeAsync(0) + + disposeSettingWrites(other) // a different provider goes away + landLate() + await hungDone + await waitingDone + + expect(stored.get("interfaceFontScale")).toBe(150) // the waiting write was not dropped + finishSettingWrites([hung, waiting]) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("does not drop a second time a write whose caller already gave up", async () => { + const { proxy, landLate } = deferredProxy() + const first = beginSettingWrite("interfaceFontScale", 100, 120) // hangs + const second = beginSettingWrite("interfaceFontScale", 100, 150) // waits, then times out + const firstDone = expect(performSettingWrite(proxy, first)).rejects.toThrow("did not finish") + const secondDone = expect(performSettingWrite(proxy, second)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await firstDone + await secondDone + + expect(() => disposeSettingWrites(proxy)).not.toThrow() // the second one still sits in the queue + + landLate() + await vi.advanceTimersByTimeAsync(0) + expect(proxy.setValue).toHaveBeenCalledTimes(1) + await undoSettingWrite(proxy, second) + await undoSettingWrite(proxy, first) + }) + + it("does nothing for a provider with no writes", () => { + expect(() => disposeSettingWrites({})).not.toThrow() + }) + }) + + it("keeps the other keys going while one key's store is outstanding", async () => { + const { proxy, landLate } = deferredProxy() + const hung = beginSettingWrite("interfaceFontScale", 100, 120) + const hungDone = expect(performSettingWrite(proxy, hung)).rejects.toThrow("did not finish") + await vi.advanceTimersByTimeAsync(15_000) + await hungDone + + const other = beginSettingWrite("terminalFontSize", 12, 14) + await performSettingWrite(proxy, other) + + expect(stored.get("terminalFontSize")).toBe(14) + finishSettingWrites([other]) + landLate() + await vi.advanceTimersByTimeAsync(0) + await undoSettingWrite(proxy, hung) + }) + + it("does not time out a write that finishes in time, and leaves no timer behind", async () => { + const proxy = { + getValue: (key: string) => stored.get(key), + setValue: async (key: string, value: unknown) => { + await new Promise((resolve) => setTimeout(resolve, 14_000)) + stored.set(key, value) + }, + } as unknown as ContextProxy + const write = beginSettingWrite("interfaceFontScale", 100, 120) + const done = performSettingWrite(proxy, write) + + await vi.advanceTimersByTimeAsync(14_000) + await done + finishSettingWrites([write]) + + expect(stored.get("interfaceFontScale")).toBe(120) + expect(vi.getTimerCount()).toBe(0) + }) + }) + + describe("setValuesThroughWriteChain", () => { + let setValues: ReturnType + let proxy: ContextProxy + + beforeEach(() => { + setValues = vi.fn().mockResolvedValue(undefined) + stored = new Map([ + ["interfaceFontScale", 100], + ["terminalFontSize", 12], + ]) + proxy = { + getValue: (key: string) => stored.get(key), + setValue: async (key: string, value: unknown) => { + stored.set(key, value) + }, + setValues, + } as unknown as ContextProxy + }) + + it("hands settings with no appearance value to setValues as they are", async () => { + await setValuesThroughWriteChain(proxy, { mode: "code" }) + + expect(setValues).toHaveBeenCalledExactlyOnceWith({ mode: "code" }) + }) + + it("stores several appearance values through the chain and the rest through setValues", async () => { + await setValuesThroughWriteChain(proxy, { interfaceFontScale: 120, terminalFontSize: 14, mode: "code" }) + + expect(stored.get("interfaceFontScale")).toBe(120) + expect(stored.get("terminalFontSize")).toBe(14) + expect(setValues).toHaveBeenCalledExactlyOnceWith({ mode: "code" }) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("puts back the value whose write failed after storing it, keeps the other, and throws the failure", async () => { + proxy.setValue = (async (key: string, value: unknown) => { + stored.set(key, value) + if (key === "interfaceFontScale") throw new Error("failed after storing") + }) as unknown as ContextProxy["setValue"] + + await expect( + setValuesThroughWriteChain(proxy, { interfaceFontScale: 120, terminalFontSize: 14 }), + ).rejects.toThrow("failed after storing") + + expect(stored.get("interfaceFontScale")).toBe(100) + expect(stored.get("terminalFontSize")).toBe(14) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("keeps the stored appearance values when the other settings fail, and throws that failure", async () => { + setValues.mockRejectedValue(new Error("other settings failed")) + + await expect(setValuesThroughWriteChain(proxy, { interfaceFontScale: 120, mode: "code" })).rejects.toThrow( + "other settings failed", + ) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + + it.each([ + ["interfaceFontScale", 49], + ["interfaceFontScale", 201], + ["terminalFontSize", 999], + ["terminalFontSize", 12.5], + ["editorFontSize", "12"], + ["spacingScale", Number.NaN], + ["spacingScale", {}], + ])("rejects %s = %j before anything is written", async (key, value) => { + const setValue = vi.spyOn(proxy, "setValue") + + await expect( + setValuesThroughWriteChain(proxy, { interfaceFontScale: 100, [key]: value, mode: "code" } as never), + ).rejects.toThrow(`Invalid value for ${key}`) + + expect(setValue).not.toHaveBeenCalled() + expect(setValues).not.toHaveBeenCalled() + expect(stored.get("interfaceFontScale")).toBe(100) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it.each([ + ["interfaceFontScale", 50], + ["interfaceFontScale", 200], + ["terminalFontSize", 8], + ["terminalFontSize", 32], + ["editorFontSize", null], + ["spacingScale", undefined], + ])("accepts %s = %j", async (key, value) => { + await setValuesThroughWriteChain(proxy, { [key]: value } as never) + + expect(stored.get(key)).toBe(value) + }) + + it("does not take an inherited value for something the caller wrote", async () => { + const inherited = Object.create({ interfaceFontScale: 150 }) as RooCodeSettings + inherited.mode = "code" + + await setValuesThroughWriteChain(proxy, inherited) + + expect(stored.get("interfaceFontScale")).toBe(100) + expect(setValues).toHaveBeenCalledExactlyOnceWith({ mode: "code" }) + }) + + it("does not let a failing save that wrote the same value undo it", async () => { + const save = beginSettingWrite("interfaceFontScale", 100, 120) + await performSettingWrite(proxy, save) + + await setValuesThroughWriteChain(proxy, { interfaceFontScale: 120 }) + await undoSettingWrite(proxy, save) + + expect(stored.get("interfaceFontScale")).toBe(120) + }) + }) +}) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6c66655325..487bf821ee 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,179 @@ 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("stores every appearance value of an import that has several", async () => { + const result = await importWith({ interfaceFontScale: 120, terminalFontSize: 14, spacingScale: 90 }) + + expect(result).toMatchObject({ success: true }) + expect(stored.get("interfaceFontScale")).toBe(120) + expect(stored.get("terminalFontSize")).toBe(14) + expect(stored.get("spacingScale")).toBe(90) + expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 }) + }) + + it("puts back only the value whose write failed after storing it, when another appearance write succeeds", async () => { + mockContextProxy.setValue.mockImplementation((async (key: string, value: unknown) => { + stored.set(key, value) + if (key === "interfaceFontScale") throw new Error("failed after storing") + }) as unknown as typeof mockContextProxy.setValue) + + const result = await importWith({ interfaceFontScale: 120, terminalFontSize: 14 }) + + expect(result).toEqual({ success: false, error: "failed after storing" }) + expect(stored.get("interfaceFontScale")).toBe(100) + expect(stored.get("terminalFontSize")).toBe(14) + 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 a failed write:', + 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..bc3950ecad --- /dev/null +++ b/src/core/config/appearanceSettingWrites.ts @@ -0,0 +1,316 @@ +import { globalSettingsSchema, 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 + /** + * Values of writes before this one that were rolled back while this one was pending, and may have stored them (a + * store that landed late). This write's rollback puts `previous` back when the stored value is any of them. + */ + alsoWrote?: 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. The entry of a key is released when the last write's store has settled, not when its caller + * stopped waiting. + */ +const settingWriteQueues = new Map>() + +/** + * How long a caller waits for a write, counted from when it was queued. It is shorter than the time the webview waits + * for the answer to a save, so the webview gets a failure, not silence. A write that has not started by then never + * will. A write that has started cannot be cancelled (`globalState.update` has no cancellation), so its key stays + * taken until the store settles: a later write of that key is refused at once (see `staleKeys`) rather than queued, so + * a store that never settles holds one entry and one closure, not one per attempt, and a late store can never land + * after, and overwrite, a newer write. + */ +const SETTING_WRITE_TIMEOUT_MS = 15_000 + +/** Keys with a write whose caller gave up but whose store has not settled. */ +const staleKeys = new Set() + +/** + * `behindStale`: the write is a rollback that was asked for while the key's store is still outstanding. It is not + * refused: it waits its turn behind that store, without a timeout, and runs when the store has settled, before any + * write that comes after it. + */ +/** + * Writes that are waiting (not started) for each owner, a provider's ContextProxy, so that disposing the provider can + * drop them. A store that has started cannot be cancelled and runs on. + */ +const waitingByOwner = new WeakMap void>>() + +/** Drops the writes of a disposed provider that have not started: they are never issued, and their callers are told. */ +export const disposeSettingWrites = (owner: object) => { + for (const cancel of [...(waitingByOwner.get(owner) ?? [])]) cancel() + waitingByOwner.delete(owner) +} + +const queuedSettingWrite = ( + owner: object, + key: AppearanceSettingKey, + write: () => Promise, + behindStale = false, +): Promise => { + if (staleKeys.has(key) && !behindStale) { + return Promise.reject(new Error(`An earlier write of ${key} has not finished yet`)) + } + + const prior = settingWriteQueues.get(key) ?? Promise.resolve() + let gaveUp = false + let started = false + + let resolveTail: () => void = () => {} + const tail = new Promise((resolve) => (resolveTail = resolve)) + settingWriteQueues.set(key, tail) + // The key is free for the next write once this one's store has settled (or it was dropped before it started). + const release = () => { + staleKeys.delete(key) + if (settingWriteQueues.get(key) === tail) settingWriteQueues.delete(key) + resolveTail() + } + + const waiting = waitingByOwner.get(owner) ?? new Set<() => void>() + waitingByOwner.set(owner, waiting) + + return new Promise((resolve, reject) => { + const cancel = () => { + if (started || gaveUp) return + gaveUp = true + clearTimeout(timer) + reject(new Error(`The write of ${key} was dropped: its provider was disposed`)) + } + waiting.add(cancel) + const timer = behindStale + ? undefined + : setTimeout(() => { + gaveUp = true + if (started) staleKeys.add(key) + reject(new Error(`Writing ${key} did not finish in ${SETTING_WRITE_TIMEOUT_MS / 1000} seconds`)) + }, SETTING_WRITE_TIMEOUT_MS) + + void prior.then(() => { + // Its caller has given up before it started: it is dropped, and the next write may go ahead. + waiting.delete(cancel) + if (gaveUp) return release() + started = true + void Promise.resolve() + .then(write) + .then( + () => undefined, + (error: unknown) => ({ error }), + ) + .then((failure) => { + clearTimeout(timer) + release() + // After the timeout the caller has been told already: a second answer is ignored. + if (failure) reject(failure.error) + else resolve() + }) + }) + }) +} + +/** @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 + staleKeys.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(contextProxy, 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, + onDeferredSettled?: () => void, +) => { + 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, including when this + // write's store lands late (it never completed for its caller, but it may have stored its value). + const { next } = write + if (next && !next.done) { + next.previous = write.previous + next.inherited = true + next.alsoWrote = [...(write.alsoWrote ?? []), write.wrote, ...(next.alsoWrote ?? [])] + } + 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) + + const restore = async () => { + const stored = contextProxy.getValue(key as keyof RooCodeSettings) + if (stored === write.wrote || write.alsoWrote?.includes(stored)) { + await contextProxy.setValue(key as keyof RooCodeSettings, write.previous as never) + } + } + + if (staleKeys.has(key)) { + // The store of this write (or of an earlier one of the key) is still outstanding and may yet land, after the + // caller was told it failed. The rollback is attached to that store: it is queued now, behind it, so it runs + // when the store has settled and before any write that comes after, which are refused until then. Nobody + // waits for it (the store may never settle), and a newer write cannot have been stored in between. + // `onDeferredSettled` runs when it is over, so the caller can show what is really stored. + void queuedSettingWrite(contextProxy, key, restore, true) + .catch((error: unknown) => + console.error(`Failed to restore setting "${key}" after a write that timed out:`, error), + ) + .finally(() => onDeferredSettled?.()) + return + } + + await queuedSettingWrite(contextProxy, key, restore) +} + +/** + * Writes settings from a generic writer (an import, the public API, the configuration of a new task). 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 + * written here meanwhile, even when it is the same value. The write is not rolled back as a whole: 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. An invalid appearance value rejects the write + * before any value is stored. + */ +export async function setValuesThroughWriteChain( + contextProxy: ContextProxy, + settings: RooCodeSettings, + onDeferredSettled?: () => void, +) { + // Own properties only: an inherited value is not something the caller wrote. + const keys = APPEARANCE_SETTING_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(settings, key)) + if (keys.length === 0) return contextProxy.setValues(settings) + + // Every appearance value is checked before anything is written (these values come from the API and from a + // webview's new-task configuration, which are not bound by the settings controls): an invalid one rejects the + // whole write, so no appearance value is started and no other setting is stored. + for (const key of keys) { + if (!globalSettingsSchema.shape[key].safeParse(settings[key]).success) { + throw new Error(`Invalid value for ${key}`) + } + } + + const writes = keys.map((key) => beginSettingWrite(key, contextProxy.getValue(key), settings[key])) + const others = Object.fromEntries( + Object.entries(settings).filter(([key]) => !isAppearanceSetting(key)), + ) as RooCodeSettings + + // 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, onDeferredSettled) + } catch (restoreError) { + console.error(`Failed to restore setting "${write.key}" after a failed write:`, restoreError) + } + } + + if (failure) throw failure.reason +} diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 9882039ad3..d29c600f1f 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -19,6 +19,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" +import { setValuesThroughWriteChain } from "./appearanceSettingWrites" import { downgradeLegacyRooConfig, ROUTER_REMOVAL_IMPORT_WARNING } from "./routerRemoval" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { t } from "../../i18n" @@ -228,7 +229,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 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dc4ed27621..7e668847f8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -132,6 +132,11 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" +import { + disposeSettingWrites, + isAppearanceSetting, + setValuesThroughWriteChain, +} from "../config/appearanceSettingWrites" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -414,6 +419,12 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Sequence number for every state push (including those without clineMessages), used by the + * frontend to ignore the appearance values of a push that arrives after a newer one. + */ + private appearanceSeq = 0 + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.94.0-models-oauth-chat" // v3.94.0 model support, MCP OAuth, and chat reliability @@ -951,6 +962,8 @@ export class ClineProvider this._disposed = true this._postStateToWebviewThrottled.cancel() + // Appearance writes that have not started are dropped; one whose store has started cannot be cancelled. + disposeSettingWrites(this.contextProxy) this.stopWebviewWatchdog() this.diagnosticsRecorder.record({ boundary: "provider", phase: "start", type: "dispose" }) this.log("Disposing ClineProvider...") @@ -2397,8 +2410,14 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // The appearance values are left out: they are not changed here, and writing them back from this snapshot + // would bypass their write chain and could replace a value that a save stored in the meantime. + const unchanged = Object.fromEntries( + Object.entries(globalSettings).filter(([key]) => !isAppearanceSetting(key)), + ) as RooCodeSettings + await this.contextProxy.setValues({ - ...globalSettings, + ...unchanged, currentApiConfigName: profileToActivate, listApiConfigMeta: entries, }) @@ -3105,6 +3124,10 @@ export class ClineProvider // Ensure the store is initialized before reading task history await this.taskHistoryStore.initialized + // Taken in the same synchronous step as getState() starts, which reads the settings before its first await. + // So a higher number always means a newer snapshot, however long the rest of the push takes. + const appearanceSeq = ++this.appearanceSeq + const { apiConfiguration, lastShownAnnouncementId, @@ -3169,6 +3192,10 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, enterBehavior, cloudUserInfo, cloudIsAuthenticated, @@ -3350,6 +3377,11 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, + appearanceSeq, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, @@ -3583,6 +3615,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, @@ -3709,7 +3745,11 @@ export class ClineProvider } public async setValues(values: RooCodeSettings) { - await this.contextProxy.setValues(values) + await setValuesThroughWriteChain( + this.contextProxy, + values, + () => void this.postStateToWebview().catch(() => undefined), + ) } // dev diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 07bacb91f6..72cb5a42ed 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3400,6 +3400,100 @@ describe("ClineProvider", () => { expect(statePostSettled).toBe(true) }) + test("numbers every state push, including those without clineMessages, in the order they start", async () => { + const first = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + const second = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + expect(first.appearanceSeq).toBeDefined() + expect(second.appearanceSeq).toBe(first.appearanceSeq! + 1) + + const [older, newer] = await Promise.all([ + provider.getStateToPostToWebview({ includeTaskHistory: false }), + provider.getStateToPostToWebview({ includeTaskHistory: false }), + ]) + expect(newer.appearanceSeq).toBe(older.appearanceSeq! + 1) + }) + + test("deleting a provider profile does not write the appearance values back from its snapshot", async () => { + vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({ + currentApiConfigName: "a", + listApiConfigMeta: [ + { id: "1", name: "a" }, + { id: "2", name: "b" }, + ], + terminalFontSize: 14, + interfaceFontScale: 120, + soundEnabled: true, + }) + const setValues = vi.spyOn(provider.contextProxy, "setValues").mockResolvedValue(undefined) + + await provider.deleteProviderProfile({ id: "1", name: "a" }) + + expect(setValues).toHaveBeenCalledExactlyOnceWith({ + currentApiConfigName: "b", + listApiConfigMeta: [{ id: "2", name: "b" }], + soundEnabled: true, + }) + }) + + test("setValues shows what is stored when a timed-out write has been rolled back", async () => { + const chain = await import("../../config/appearanceSettingWrites") + const spy = vi.spyOn(chain, "setValuesThroughWriteChain").mockResolvedValue(undefined) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.setValues({ terminalFontSize: 14 }) + const onDeferredSettled = spy.mock.calls[0][2] + expect(post).not.toHaveBeenCalled() + onDeferredSettled?.() + + expect(post).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) + + test("disposing the provider drops its appearance writes that have not started", async () => { + const chain = await import("../../config/appearanceSettingWrites") + const spy = vi.spyOn(chain, "disposeSettingWrites") + + await provider.dispose() + + expect(spy).toHaveBeenCalledExactlyOnceWith(provider.contextProxy) + spy.mockRestore() + }) + + test("setValues, which a new task's configuration goes through, writes an appearance value through the write chain", async () => { + const setValue = vi.spyOn(provider.contextProxy, "setValue").mockResolvedValue(undefined) + const setValues = vi.spyOn(provider.contextProxy, "setValues").mockResolvedValue(undefined) + + await provider.setValues({ terminalFontSize: 14, mode: "code" }) + + expect(setValue).toHaveBeenCalledWith("terminalFontSize", 14) + expect(setValues).toHaveBeenCalledExactlyOnceWith({ mode: "code" }) + }) + + test("numbers a push by when its settings were read, not by when it finishes", async () => { + let stored = 1 + vi.spyOn(provider.contextProxy, "getValues").mockImplementation(() => ({ spacingScale: stored })) + let releaseFirst: () => void = () => {} + let firstHasRead: () => void = () => {} + const hasRead = new Promise((resolve) => (firstHasRead = resolve)) + vi.spyOn(provider.customModesManager, "getCustomModes") + .mockImplementationOnce(() => { + firstHasRead() // the settings are read by now, and this push waits + return new Promise((resolve) => (releaseFirst = () => resolve([]))) + }) + .mockResolvedValue([]) + + const older = provider.getStateToPostToWebview({ includeTaskHistory: false }) // reads 1, then waits + await hasRead + stored = 2 + const newer = await provider.getStateToPostToWebview({ includeTaskHistory: false }) // reads 2, finishes first + releaseFirst() + const olderState = await older + + expect(newer.spacingScale).toBe(2) + expect(olderState.spacingScale).toBe(1) + expect(newer.appearanceSeq).toBeGreaterThan(olderState.appearanceSeq!) + }) + test.each([ ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], [ @@ -4151,6 +4245,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..8a0300d7b6 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,815 @@ 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("shows what is stored once a write that timed out has landed and its rollback has run", async () => { + vi.useFakeTimers() + try { + let landLate: () => void = () => {} + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation( + async (key: string, value: unknown) => { + if (key === "terminalFontSize" && value === 14) { + await new Promise((resolve) => (landLate = resolve)) // the store hangs, then lands + } + return real(key as never, value as never) + }, + ) + + const saving = save({ terminalFontSize: 14 }) + await vi.advanceTimersByTimeAsync(15_000) + await saving + + // The save reported the failure, and the webview was refreshed once: the store has not landed yet. + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "settingsSaveResult", success: false }), + ) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(stored.terminalFontSize).toBe(12) + + landLate() + await vi.advanceTimersByTimeAsync(0) + + // The late store was rolled back, and the webview is refreshed again with what is stored. + expect(stored.terminalFontSize).toBe(12) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("logs when the refresh after a rollback that had to wait for a store fails", async () => { + vi.useFakeTimers() + try { + let landLate: () => void = () => {} + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation( + async (key: string, value: unknown) => { + if (key === "terminalFontSize" && value === 14) { + await new Promise((resolve) => (landLate = resolve)) + } + return real(key as never, value as never) + }, + ) + vi.mocked(mockClineProvider.postStateToWebview) + .mockResolvedValueOnce(undefined as never) + .mockRejectedValueOnce(new Error("webview gone")) + + const saving = save({ terminalFontSize: 14 }) + await vi.advanceTimersByTimeAsync(15_000) + await saving + landLate() + await vi.advanceTimersByTimeAsync(0) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + "[updateSettings] Could not refresh the webview state: webview gone", + ) + } finally { + vi.useRealTimers() + } + }) + + 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 2a04aa413f..b754fe479a 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" @@ -131,6 +140,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, @@ -742,8 +765,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") @@ -763,6 +810,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") { @@ -872,13 +921,75 @@ 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, () => { + // A store that timed out has settled and its rollback has run: show what is really stored. + provider + .postStateToWebview() + .catch((error: unknown) => + provider.log( + `[updateSettings] Could not refresh the webview state: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + }) + } 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/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index f4ed8af469..73a27a32bb 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import type { RooCodeSettings } from "@roo-code/types" + import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" import { makeClineProviderFactory } from "../../test-utils/provider" @@ -63,4 +65,97 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("writes an appearance value through the per-key write chain, and the rest through setValues", async () => { + const stored = new Map([["terminalFontSize", 12]]) + const setValues = vi.fn().mockResolvedValue(undefined) + const setValue = vi.fn(async (key: string, value: unknown) => { + stored.set(key, value) + }) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { getValue: (key: string) => stored.get(key), setValue, setValues }, + providerSettingsManager: { saveConfig: vi.fn().mockResolvedValue("id"), setModeConfig: vi.fn() }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await api.setConfiguration({ terminalFontSize: 14, mode: "code" }) + + expect(setValue).toHaveBeenCalledExactlyOnceWith("terminalFontSize", 14) + expect(setValues).toHaveBeenCalledExactlyOnceWith({ mode: "code" }) + }) + + it.each([[999], [7], [12.5], ["12"]])( + "rejects an out-of-range appearance value (%j) and stores nothing", + async (value) => { + const setValue = vi.fn() + const setValues = vi.fn() + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { getValue: vi.fn(), setValue, setValues }, + providerSettingsManager: { saveConfig: vi.fn(), setModeConfig: vi.fn() }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ terminalFontSize: value, mode: "code" } as unknown as RooCodeSettings), + ).rejects.toThrow("Invalid value for terminalFontSize") + + expect(setValue).not.toHaveBeenCalled() + expect(setValues).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + }, + ) + + it("shows what is stored when a timed-out write has been rolled back", async () => { + const chain = await import("../../core/config/appearanceSettingWrites") + const spy = vi.spyOn(chain, "setValuesThroughWriteChain").mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: {}, + providerSettingsManager: { saveConfig: vi.fn().mockResolvedValue("id"), setModeConfig: vi.fn() }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await api.setConfiguration({ terminalFontSize: 14 }) + expect(postStateToWebview).toHaveBeenCalledTimes(1) // the one after the write + spy.mock.calls[0][2]?.() // the rollback of a timed-out write is over + + expect(postStateToWebview).toHaveBeenCalledTimes(2) + spy.mockRestore() + }) + + it("puts an appearance value back when its write fails, and rejects", async () => { + const stored = new Map([["terminalFontSize", 12]]) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { + getValue: (key: string) => stored.get(key), + setValue: async (key: string, value: unknown) => { + stored.set(key, value) + if (value === 14) throw new Error("failed after storing") + }, + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { saveConfig: vi.fn(), setModeConfig: vi.fn() }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect(api.setConfiguration({ terminalFontSize: 14 })).rejects.toThrow("failed after storing") + + expect(stored.get("terminalFontSize")).toBe(12) + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index da59c48b58..d7033713fe 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -32,6 +32,7 @@ import { Package } from "../shared/package" import type { Mode } from "../shared/modes" import type { ClineProvider } from "../core/webview/ClineProvider" import type { ClineProviderFactory } from "../core/webview/ClineProviderFactory" +import { setValuesThroughWriteChain } from "../core/config/appearanceSettingWrites" import { readTaskMessages } from "../core/task-persistence/taskMessages" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" @@ -629,7 +630,11 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) + await setValuesThroughWriteChain( + this.sidebarProvider.contextProxy, + values, + () => void this.sidebarProvider.postStateToWebview().catch(() => undefined), + ) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) if (values.modeApiConfigs) { await Promise.all( diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index d414d697f2..adf335c580 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -51,6 +51,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 8361221019..6a0cde0445 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -47,6 +47,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 21cb77dd5f..be4cc36ac4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -47,6 +47,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 5b959f6e2f..9df4210fcc 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -47,6 +47,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 03e911de49..1fc22b12ca 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -47,6 +47,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 36479f1060..6a076c3fc6 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -47,6 +47,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 d047cf3cbf..0f92d559b4 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -47,6 +47,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 ecdf443a3d..95b72be042 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -47,6 +47,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 2220a608d6..a594e9ba01 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -47,6 +47,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 b462dc8056..706815c684 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -47,6 +47,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 01523541d9..2216becc05 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -47,6 +47,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 d2b6412257..f7ffc569e1 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -47,6 +47,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 02e65d5ab9..065d157b5b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -51,6 +51,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 64e60ab2ff..3502a9e9cd 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -47,6 +47,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 992e03a419..b7b5a6d33f 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -47,6 +47,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 94c61bdaae..08d6575037 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -47,6 +47,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 2c39a92571..3296607f17 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -52,6 +52,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 a96493284b..b7ac658878 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -47,6 +47,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 b87ee61669..7fde0f4ebe 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 + }, "anthropic-settings": async () => { const [{ AppProviders }, { Anthropic }, { providerIdentifiers }] = await Promise.all([ import("../AppProviders"), 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..3ba3fc825e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -6,6 +6,7 @@ import React, { useLayoutEffect, useMemo, useRef, + useReducer, useState, } from "react" import { @@ -43,6 +44,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 +84,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" @@ -99,6 +102,14 @@ export interface SettingsViewRef { checkUnsaveChanges: (then: () => void) => void } +/** + * How long the form waits for the host to answer a save that changes an appearance value. The host always answers + * (success or failure), but a write that never settles would otherwise leave the form locked until the webview reloads. + * After this the save is treated as failed: the edits stay, Save and Done work again, and an answer that arrives + * later is ignored (the host refreshes the state after it saves, so the form is brought up to date by that). + */ +export const SAVE_ANSWER_TIMEOUT_MS = 20_000 + export const sectionNames = [ "providers", "autoApprove", @@ -133,7 +144,37 @@ const SettingsView = forwardRef(({ onDone, t const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt, mode } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) - const [isChangeDetected, setChangeDetected] = useState(false) + // Whether the form has unsaved changes, and how many changes the user has made in all. The count only goes up: a + // save remembers the count it was made at, to tell whether the user edited again while it was in flight, and a + // reset of the flag (an import, a profile switch) must not make a later edit look like no edit. (Not the identity + // of the state object: an automatic update of the state that is no edit, such as a settings page initialising + // itself, replaces that object too.) + const [{ count: changeCount, changed: isChangeDetected }, setChangeDetected] = useReducer( + (state: { count: number; changed: boolean }, changed: boolean) => ({ + count: changed ? state.count + 1 : state.count, + changed, + }), + { count: 0, changed: false }, + ) + // The save currently waiting for the host's answer. The "unsaved changes" flag stays on until it succeeds. + const [pendingSaveId, setPendingSaveId] = useState(undefined) + const pendingSaveChangeCount = useRef(0) + // Appearance values that a successful save stored, until the live state shows them. The host reports a success even + // if refreshing the state failed afterwards, and then the live state is stale: comparing with it alone would take + // a later reset to the old value for "nothing changed" and not send it. + const appearanceSaved = useRef>({}) + // Saves that were not answered in time. Their outcome is unknown: the host may still store them. + const timedOutSaves = useRef(new Map; changeCount: number }>()) + const [saveOutcomeUnknown, setSaveOutcomeUnknown] = useState(false) + // 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>({}) + // A request to leave the form (a tab switch, a command from the host) that came in while a save was pending. It is + // asked again, through the same unsaved-changes check, once the save has settled. + const heldNavigation = useRef<(() => void) | undefined>(undefined) const [errorMessage, setErrorMessage] = useState(undefined) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) @@ -152,6 +193,12 @@ const SettingsView = forwardRef(({ onDone, t const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(() => extensionState) + const latestCachedState = useRef(cachedState) + const latestChangeCount = useRef(changeCount) + useEffect(() => { + latestCachedState.current = cachedState + latestChangeCount.current = changeCount + }, [cachedState, changeCount]) const { alwaysAllowReadOnly, @@ -214,6 +261,10 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, enterBehavior, includeCurrentTime, includeCurrentCost, @@ -235,6 +286,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 +298,7 @@ const SettingsView = forwardRef(({ onDone, t handledSettingsImportedAt.current = settingsImportedAt setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) + editedAppearance.current.clear() setChangeDetected(false) }, [settingsImportedAt, extensionState]) @@ -255,6 +308,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 } }) @@ -380,10 +434,40 @@ const SettingsView = forwardRef(({ onDone, t const isSettingValid = !errorMessage + // What the form compares an appearance value with: what a save stored, if the live state has not caught up yet. + const savedAppearanceValue = (key: AppearanceKey) => + key in appearanceSaved.current ? appearanceSaved.current[key] : extensionState[key] + 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. + // A value whose earlier save was not answered is sent whatever it equals: that save may still store its value. + const unresolved = new Set([...timedOutSaves.current.values()].flatMap(({ sent }) => Object.keys(sent))) + const changedAppearance: Record = Object.fromEntries( + [...editedAppearance.current] + .filter( + (key) => + unresolved.has(key) || (cachedState[key] ?? null) !== (savedAppearanceValue(key) ?? null), + ) + .map((key) => [key, cachedState[key] ?? null]), + ) + const appearanceEdited = Object.keys(changedAppearance).length > 0 + const saveId = appearanceEdited ? nextSaveRequestId() : undefined + if (saveId) { + pendingSaveChangeCount.current = changeCount + pendingAppearanceSent.current = changedAppearance + // This save is the newer word on these values: an earlier save's late answer must not overwrite them. + for (const { sent } of timedOutSaves.current.values()) + for (const key in changedAppearance) delete sent[key] + setPendingSaveId(saveId) + } vscode.postMessage({ type: "updateSettings", + requestId: saveId, updatedSettings: { language, alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined, @@ -442,6 +526,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,12 +550,137 @@ 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 + + // The save is over: stored, failed, or not answered in time. + const settle = (success: boolean) => { + const editedAt = pendingSaveChangeCount.current + const sent = pendingAppearanceSent.current + pendingAppearanceSent.current = {} + setPendingSaveId(undefined) + + // What was stored is no longer an unsaved edit, unless the user changed that value again meanwhile. + if (success) { + for (const [key, value] of Object.entries(sent)) { + appearanceSaved.current[key] = value + 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. + const clean = success && latestChangeCount.current === editedAt + if (clean) setChangeDetected(false) + + // A request to leave that came in meanwhile goes through the unsaved-changes check now: it proceeds if + // everything was stored, and otherwise (a failed save, or an edit made meanwhile) the user is asked. + const held = heldNavigation.current + heldNavigation.current = undefined + if (held) { + if (clean) { + held() + } else { + confirmDialogHandler.current = held + setDiscardDialogShow(true) + } + } + } + + const onResult = (event: MessageEvent) => { + const message = event.data + if (message?.type !== "settingsSaveResult" || message.requestId !== pendingSaveId) return + + settle(message.success === true) + } + + window.addEventListener("message", onResult) + const timer = setTimeout(() => { + // No answer is not a failure: the host may still store it. Its answer is waited for further on, and the + // user is told if they leave meanwhile. + timedOutSaves.current.set(pendingSaveId, { + sent: { ...pendingAppearanceSent.current }, + changeCount: pendingSaveChangeCount.current, + }) + setSaveOutcomeUnknown(true) + settle(false) + }, SAVE_ANSWER_TIMEOUT_MS) + return () => { + clearTimeout(timer) + window.removeEventListener("message", onResult) + } + }, [pendingSaveId]) + + // The answer to a save that was given up on. If it stored the values, the form is brought up to date with them: the + // values the user is not editing now take the stored value, and every value is compared with it from now on. + useEffect(() => { + const onLateResult = (event: MessageEvent) => { + const message = event.data + if (message?.type !== "settingsSaveResult") return + const late = timedOutSaves.current.get(message.requestId) + if (!late) return + + timedOutSaves.current.delete(message.requestId) + setSaveOutcomeUnknown(timedOutSaves.current.size > 0) + if (message.success !== true) return + + const { sent, changeCount: sentAt } = late + for (const [key, value] of Object.entries(sent)) { + appearanceSaved.current[key] = value + // An edit that was kept and now equals what was stored is no longer an unsaved edit. + if ((latestCachedState.current[key as AppearanceKey] ?? null) === value) { + editedAppearance.current.delete(key as AppearanceKey) + } + } + // Nothing else was changed since that save was sent: there is nothing left unsaved. + if (latestChangeCount.current === sentAt) setChangeDetected(false) + setCachedState((prev) => { + const next = { ...prev } + for (const [key, value] of Object.entries(sent)) { + if (!editedAppearance.current.has(key as AppearanceKey)) { + Object.assign(next, { [key]: value ?? undefined }) + } + } + return next + }) + } + + window.addEventListener("message", onLateResult) + return () => window.removeEventListener("message", onLateResult) + }, []) + + // Once the live state shows an appearance value it is the one to compare with again (it may also be newer, if + // another webview changed it). + const previousLive = useRef>({}) + useEffect(() => { + for (const key of Object.keys(APPEARANCE_SETTINGS) as AppearanceKey[]) { + if (previousLive.current[key] !== extensionState[key]) delete appearanceSaved.current[key] + previousLive.current[key] = extensionState[key] + } + }, [extensionState]) + const checkUnsaveChanges = useCallback( (then: () => void) => { + // A save waiting for the host's answer keeps going whatever is chosen here, so leaving now (and offering + // to discard changes that are being stored) would say something untrue. The request is kept, the latest + // one if there are several, and goes through this check again when the save has settled. + if (pendingSaveId) { + heldNavigation.current = then + return + } + if (isChangeDetected) { confirmDialogHandler.current = then setDiscardDialogShow(true) @@ -478,7 +688,7 @@ const SettingsView = forwardRef(({ onDone, t then() } }, - [isChangeDetected], + [isChangeDetected, pendingSaveId], ) useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges]) @@ -488,6 +698,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) } @@ -673,7 +884,11 @@ const SettingsView = forwardRef(({ onDone, t
- @@ -696,7 +911,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 +1159,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} @@ -996,6 +1215,11 @@ const SettingsView = forwardRef(({ onDone, t {t("settings:unsavedChangesDialog.description")} + {saveOutcomeUnknown && ( + + {t("settings:unsavedChangesDialog.saveMayComplete")} + + )} 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..ed24989604 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearanceConsumers.visual.tsx @@ -0,0 +1,124 @@ +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`) + }) +} + +// The configured chat font size is a fourth input of the chat text size, next to the interface scale: the text is +// the chat size times the scale. It is set on the way the app's own state provider sets it. +test("a configured chat font size and the interface scale combine in the chat text, and each can be removed", async ({ + mount, + page, +}) => { + const component = mountedStory(await mount("appearance-consumers")) + await applyVisualTheme(page, visualThemes[0]) + const story = component.getByTestId("appearance-consumers-story") + const markdownText = story.getByTestId("consumer-markdown").locator("p").first() + await expect(markdownText).toBeVisible() + + const base = await px(markdownText, "font-size") + const setChatSize = (value: string | null) => + page.evaluate((size) => { + if (size === null) document.documentElement.style.removeProperty("--zoo-chat-font-size") + else document.documentElement.style.setProperty("--zoo-chat-font-size", size) + }, value) + + // The chat size alone replaces the editor's text size. + await setChatSize("16px") + await expect.poll(() => px(markdownText, "font-size")).toBe(16) + + // With the interface scale (150%) on top: 16px * 1.5. + await story.getByTestId("apply-appearance").click() + await expect.poll(() => px(markdownText, "font-size")).toBe(24) + + // Clearing the chat size leaves the scale on the default size... + await setChatSize(null) + await expect.poll(() => px(markdownText, "font-size")).toBeCloseTo(base * 1.5, 1) + + // ...and removing the scale as well brings back the unset look. + await story.getByTestId("remove-appearance").click() + await expect.poll(() => px(markdownText, "font-size")).toBeCloseTo(base, 1) +}) 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..17d97a7bdf --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AppearancePreview.spec.tsx @@ -0,0 +1,136 @@ +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") + }) + + describe("isolation from the page root", () => { + const rootCssVars = [ + "--zoo-terminal-font-size", + "--zoo-editor-font-size", + "--zoo-ui-scale", + "--zoo-spacing-scale", + "--zoo-chat-font-size", + ] + const rootState = () => ({ + style: document.documentElement.getAttribute("style"), + className: document.documentElement.className, + vars: rootCssVars.map((cssVar) => document.documentElement.style.getPropertyValue(cssVar)), + }) + + beforeEach(() => { + rootCssVars.forEach((cssVar, index) => + document.documentElement.style.setProperty(cssVar, `${index + 10}px`), + ) + document.documentElement.classList.add("zoo-editor-font-set") + }) + + afterEach(() => { + rootCssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar)) + document.documentElement.classList.remove("zoo-editor-font-set") + }) + + it("leaves the root variables and the editor font class as they were, for set values, a reset and an unmount", () => { + const before = rootState() + expect(before.vars).toEqual(["10px", "11px", "12px", "13px", "14px"]) + + const { rerender, unmount } = render( + , + ) + expect(root().style.getPropertyValue("--zoo-terminal-font-size")).toBe("18px") + expect(rootState()).toEqual(before) + + rerender() + expect(root().style.getPropertyValue("--zoo-terminal-font-size")).toBe("initial") + expect(rootState()).toEqual(before) + + unmount() + expect(rootState()).toEqual(before) + expect(document.documentElement.classList.contains("zoo-editor-font-set")).toBe(true) + }) + }) + + 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.automaticUpdate.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.automaticUpdate.spec.tsx new file mode 100644 index 0000000000..9e1fc2537b --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.automaticUpdate.spec.tsx @@ -0,0 +1,77 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react" +import { useEffect } from "react" +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() } })) + +// The providers page fills in a default for its own part of the state when it first appears. That is not an edit, but +// it replaces the state object of the form while the page is open. +// Each time the page mounts it fills in a field that was still empty, so it is an initial sync: no edit, but the state +// object is replaced. (The form also mounts every page once, briefly, to index its settings, so a field filled in +// by the first mount would leave nothing to fill in later.) +const emptyFields = [ + "apiModelId", + "openRouterModelId", + "requestyModelId", + "unboundModelId", + "litellmModelId", + "ollamaModelId", + "lmStudioModelId", + "glamaModelId", +] +let fieldsFilled = 0 + +vi.mock("../ApiOptions", () => { + const ApiOptionsMock = ({ + setApiConfigurationField, + }: { + setApiConfigurationField: (field: string, value: unknown, isUserAction?: boolean) => void + }) => { + useEffect(() => { + setApiConfigurationField(emptyFields[fieldsFilled++ % emptyFields.length], "a-default-model", false) + }, [setApiConfigurationField]) + return
+ } + + return { default: ApiOptionsMock } +}) + +const postMessage = vi.mocked(vscode.postMessage) + +describe("SettingsView – an automatic update of the state while an appearance save waits", () => { + beforeEach(() => postMessage.mockClear()) + + it("does not keep the form unsaved after the save was stored, and leaving it asks nothing", async () => { + renderWithExtensionState() + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-increment")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + fireEvent.click(screen.getByTestId("save-button")) + + fireEvent.click(screen.getByTestId("tab-providers")) + await screen.findByTestId("api-options-mock") + answerSave(lastSaveRequestId(postMessage), { success: true }) + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + fireEvent.click(screen.getByRole("button", { name: "settings:common.done" })) + expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument() + }) + + it("still keeps the form unsaved when the user edited something while the save waited", async () => { + renderWithExtensionState() + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-increment")) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + fireEvent.click(screen.getByTestId("save-button")) + + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // a real edit, after the save + answerSave(lastSaveRequestId(postMessage), { success: true }) + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) +}) 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..371ee3d623 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.saveResult.spec.tsx @@ -0,0 +1,753 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react" +import { QueryClient } from "@tanstack/react-query" +import { createRef } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { renderWithExtensionState } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" + +import SettingsView, { SAVE_ANSWER_TIMEOUT_MS, type SettingsViewRef } 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("compares with what a save stored when the live state did not catch up, so a reset to the old value is still sent", async () => { + renderUiTab() + await makeAnEdit() // 13; the live state is unset and stays so: its refresh after the save never arrived + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + postMessage.mockClear() + + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-reset")) // back to unset, which is the stale live value + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).toHaveProperty("terminalFontSize", null) + }) + + it("keeps what a save stored for a value until that value's own live state changes, not another value's", async () => { + renderUiTab() + await makeAnEdit() // terminalFontSize 13 + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) // the live state never caught up + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + liveStateChange({ spacingScale: 90 }) // a different value changes in the live state + postMessage.mockClear() + + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-reset")) + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).toHaveProperty("terminalFontSize", null) + }) + + it("still sees an edit made after an import that happened while a save was waiting", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + liveStateChange({ settingsImportedAt: Date.now() }) // the import resets the form and the unsaved flag + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + + fireEvent.click(await screen.findByTestId("appearance-terminalFontSize-increment")) // one more edit + answerSave(lastSaveRequestId(postMessage), { success: true }) + + // That edit was not in the saved request: it stays unsaved. + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + }) + + it("goes back to comparing with the live state once it has changed", async () => { + renderUiTab() + await makeAnEdit() // 13 + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + liveStateChange({ terminalFontSize: 20 }) // another webview stored 20 + postMessage.mockClear() + + fireEvent.change(screen.getByTestId("appearance-terminalFontSize-input"), { target: { value: "2" } }) + fireEvent.change(screen.getByTestId("appearance-terminalFontSize-input"), { target: { value: "20" } }) + fireEvent.blur(screen.getByTestId("appearance-terminalFontSize-input")) + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).not.toHaveProperty("terminalFontSize") // 20 is what is stored: nothing to send + }) + + 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) + }) +}) + +describe("SettingsView – leaving while an appearance save is pending", () => { + beforeEach(() => postMessage.mockClear()) + + const doneButton = () => screen.getByRole("button", { name: "settings:common.done" }) + + it("disables Done while the save waits, and enables it again once the host has answered", async () => { + renderUiTab() + await makeAnEdit() + expect(doneButton()).toBeEnabled() + + fireEvent.click(screen.getByTestId("save-button")) + expect(doneButton()).toBeDisabled() + + answerSave(lastSaveRequestId(postMessage), { success: true }) + await waitFor(() => expect(doneButton()).toBeEnabled()) + }) + + it("offers the discard dialog again after a failed save, which left the edits in place", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: false, error: "nope" }) + await waitFor(() => expect(doneButton()).toBeEnabled()) + + fireEvent.click(doneButton()) + + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + }) + + it("does not disable Done for a save that changes no appearance value", async () => { + renderUiTab() + await makeAnOtherEdit() + + fireEvent.click(screen.getByTestId("save-button")) + + expect(doneButton()).toBeEnabled() + }) + + describe("a request to leave that comes in while the save waits", () => { + const renderWithRef = () => { + const ref = createRef() + renderWithExtensionState() + return ref + } + const dialog = () => screen.queryByText("settings:unsavedChangesDialog.title") + + it("is held, and goes ahead once the save was stored", async () => { + const ref = renderWithRef() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const then = vi.fn() + + act(() => ref.current?.checkUnsaveChanges(then)) + expect(then).not.toHaveBeenCalled() + expect(dialog()).not.toBeInTheDocument() + + answerSave(lastSaveRequestId(postMessage), { success: true }) + + expect(then).toHaveBeenCalledTimes(1) + expect(dialog()).not.toBeInTheDocument() + }) + + it("asks the user instead when the save failed, and goes ahead only if they discard", async () => { + const ref = renderWithRef() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const then = vi.fn() + act(() => ref.current?.checkUnsaveChanges(then)) + + answerSave(lastSaveRequestId(postMessage), { success: false, error: "nope" }) + + expect(then).not.toHaveBeenCalled() + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + fireEvent.click(screen.getByText("settings:unsavedChangesDialog.discardButton")) + expect(then).toHaveBeenCalledTimes(1) + }) + + it("asks the user when an edit was made meanwhile, because that edit was not saved", async () => { + const ref = renderWithRef() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const then = vi.fn() + act(() => ref.current?.checkUnsaveChanges(then)) + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // not in what was sent + + answerSave(lastSaveRequestId(postMessage), { success: true }) + + expect(then).not.toHaveBeenCalled() + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + }) + + it("keeps only the latest of several requests", async () => { + const ref = renderWithRef() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const first = vi.fn() + const latest = vi.fn() + act(() => { + ref.current?.checkUnsaveChanges(first) + ref.current?.checkUnsaveChanges(latest) + }) + + answerSave(lastSaveRequestId(postMessage), { success: true }) + + expect(first).not.toHaveBeenCalled() + expect(latest).toHaveBeenCalledTimes(1) + }) + + it("goes ahead at once when no save is pending and nothing is unsaved", () => { + const ref = renderWithRef() + const then = vi.fn() + + act(() => ref.current?.checkUnsaveChanges(then)) + + expect(then).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe("SettingsView – a save the host never answers", () => { + beforeEach(() => { + postMessage.mockClear() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + afterEach(() => vi.useRealTimers()) + + const doneButton = () => screen.getByRole("button", { name: "settings:common.done" }) + + it("unlocks the form after the wait, keeping the edits, so Save can be tried again", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + expect(screen.getByTestId("save-button")).toBeDisabled() + expect(doneButton()).toBeDisabled() + + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS)) + + expect(screen.getByTestId("save-button")).toBeEnabled() + expect(doneButton()).toBeEnabled() + postMessage.mockClear() + fireEvent.click(screen.getByTestId("save-button")) + expect(lastUpdatedSettings().terminalFontSize).toBe(13) // the edit was kept, and is sent again + }) + + it("does not unlock the form before the wait is over", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS - 1000)) + + expect(screen.getByTestId("save-button")).toBeDisabled() + }) + + it("ignores an answer that arrives after the wait, so it cannot complete a later save", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const lateId = lastSaveRequestId(postMessage) + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS)) + + fireEvent.click(screen.getByTestId("save-button")) // tries again: a new request + expect(lastSaveRequestId(postMessage)).not.toBe(lateId) + answerSave(lateId, { success: true }) // the first save answers, late + + expect(screen.getByTestId("save-button")).toBeDisabled() // the second save is still waiting + answerSave(lastSaveRequestId(postMessage), { success: true }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) // saved: nothing left to save + }) + + it("treats a held request to leave as after a failed save, when the wait is over", async () => { + const ref = createRef() + renderWithExtensionState() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + const then = vi.fn() + act(() => ref.current?.checkUnsaveChanges(then)) + + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS)) + + expect(then).not.toHaveBeenCalled() + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + }) + + describe("the outcome of a save that was not answered is unknown", () => { + const noteShown = () => screen.queryByTestId("save-outcome-unknown") + + const timeOutAfterAnEdit = async () => { + renderUiTab() + await makeAnEdit() // 13 + fireEvent.click(screen.getByTestId("save-button")) + const id = lastSaveRequestId(postMessage) + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS)) + return id + } + + it("tells the user in the discard dialog that the save may still complete", async () => { + await timeOutAfterAnEdit() + + fireEvent.click(doneButton()) + + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + expect(noteShown()).toHaveTextContent("settings:unsavedChangesDialog.saveMayComplete") + }) + + it("does not say that after a save that failed", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: false, error: "nope" }) + await waitFor(() => expect(doneButton()).toBeEnabled()) + + fireEvent.click(doneButton()) + + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + expect(noteShown()).not.toBeInTheDocument() + }) + + it("brings the form up to date when the late answer says the save was stored, even after the edits were discarded", async () => { + const id = await timeOutAfterAnEdit() + fireEvent.click(doneButton()) + fireEvent.click(await screen.findByText("settings:unsavedChangesDialog.discardButton")) + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("12") // discarded + + answerSave(id, { success: true }) // the host stored 13 after all + + await waitFor(() => + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("13"), + ) + expect(screen.getByTestId("save-button")).toBeDisabled() // nothing is unsaved + }) + + it("leaves a value the user is editing again as it is, but compares it with what was stored", async () => { + const id = await timeOutAfterAnEdit() + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // 14, edited after the save + + answerSave(id, { success: true }) + + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("14") + expect(screen.getByTestId("save-button")).toBeEnabled() + postMessage.mockClear() + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-decrement")) // back to 13, which is stored now + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + expect(lastUpdatedSettings()).not.toHaveProperty("terminalFontSize") + }) + + it("clears the unsaved flag when a late success stores the edit that was kept, and nothing else changed", async () => { + const id = await timeOutAfterAnEdit() // 13 kept in the form, unanswered + + answerSave(id, { success: true }) + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + fireEvent.click(doneButton()) + expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument() + }) + + it("keeps the unsaved flag when something else was changed after that save was sent", async () => { + const id = await timeOutAfterAnEdit() + fireEvent.click(await screen.findByTestId("collapse-thinking-checkbox")) // another change, after the save + + answerSave(id, { success: true }) + + expect(screen.getByTestId("save-button")).toBeEnabled() + }) + + it("stops counting the stored value as an edit after a late success, so a later save does not send it again", async () => { + const id = await timeOutAfterAnEdit() + answerSave(id, { success: true }) + liveStateChange({ terminalFontSize: 20 }) // another webview stores 20 later + postMessage.mockClear() + + await makeAnOtherEdit() + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).not.toHaveProperty("terminalFontSize") + }) + + it("sends a reset to the live value while an earlier save of that value is unanswered, because it may still store", async () => { + await timeOutAfterAnEdit() // 13 sent, never answered; the live value is still unset + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-reset")) // back to unset, equal to the live value + postMessage.mockClear() + + fireEvent.click(screen.getByTestId("save-button")) + + expect(lastUpdatedSettings()).toHaveProperty("terminalFontSize", null) + }) + + it("shows a value as unset when the late success is for a reset", 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 id = lastSaveRequestId(postMessage) + expect(lastUpdatedSettings()).toHaveProperty("terminalFontSize", null) + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS)) + + answerSave(id, { success: true }) + + // Stored as unset (null on the wire): the field shows the fallback again, and nothing is left unsaved. + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("12") + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + }) + + it("keeps a value the user edited again after the save was sent when its late success arrives", async () => { + const id = await timeOutAfterAnEdit() // 13 sent, unanswered + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // 14, an edit made after the send + + answerSave(id, { success: true }) + + // The edit is not what was stored: it stays in the form, and is still an unsaved change. + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("14") + expect(screen.getByTestId("save-button")).toBeEnabled() + }) + + it("does not let a late answer overwrite a value that a newer save has stored", async () => { + const first = await timeOutAfterAnEdit() // 13, unanswered + fireEvent.click(screen.getByTestId("appearance-terminalFontSize-increment")) // 14 + fireEvent.click(screen.getByTestId("save-button")) + const second = lastSaveRequestId(postMessage) + expect(second).not.toBe(first) + answerSave(second, { success: true }) + + answerSave(first, { success: true }) // the old save answers last + + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("14") + }) + + it("does nothing for a late answer that says the save failed, and stops saying the save may complete", async () => { + const id = await timeOutAfterAnEdit() + + answerSave(id, { success: false, error: "nope" }) + + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("13") + fireEvent.click(doneButton()) + expect(await screen.findByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + expect(noteShown()).not.toBeInTheDocument() + }) + + it("ignores an answer to a request it never gave up on", async () => { + await timeOutAfterAnEdit() + + answerSave("settings-save-never-issued", { success: true }) + + expect((screen.getByTestId("appearance-terminalFontSize-input") as HTMLInputElement).value).toBe("13") + fireEvent.click(doneButton()) + expect(noteShown()).toBeInTheDocument() // still unknown + }) + }) + + it("does nothing when the answer came in time and the wait would have ended later", async () => { + renderUiTab() + await makeAnEdit() + fireEvent.click(screen.getByTestId("save-button")) + answerSave(lastSaveRequestId(postMessage), { success: true }) + + act(() => vi.advanceTimersByTime(SAVE_ANSWER_TIMEOUT_MS * 2)) + + expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument() + expect(screen.getByTestId("save-button")).toBeDisabled() // nothing unsaved + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 14a3100057..b10a6a211f 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() } })) @@ -506,6 +507,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 e90955857c..010305c7c4 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" import { @@ -139,6 +140,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 @@ -171,6 +176,17 @@ const messagesAreStale = (incoming: Partial, currentSequence: nu currentSequence !== undefined && incoming.clineMessagesSeq <= currentSequence +const APPEARANCE_KEYS = [ + "chatFontSize", + "interfaceFontScale", + "terminalFontSize", + "editorFontSize", + "spacingScale", +] as const satisfies readonly (keyof ExtensionState)[] + +const appearanceIsStale = (incoming: Partial, currentSequence: number | undefined): boolean => + incoming.appearanceSeq !== undefined && currentSequence !== undefined && incoming.appearanceSeq <= currentSequence + function internMessages(messages: ClineMessage[], previousMessages: ClineMessage[], cache: StringCache): void { const previousByTimestamp = new Map(previousMessages.map((item) => [item.ts, item])) for (const message of messages) { @@ -226,6 +242,21 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial rest.currentTaskId = newState.currentTaskId } + // The appearance values are read from a settings snapshot taken before the push is posted, so a stale + // push can carry old font sizes or spacing. Apply them only from a push newer than the last applied one. + // A numbered push is a whole snapshot: a value it does not carry is unset (JSON drops an undefined one), so + // the previous value is cleared rather than kept. + if (appearanceIsStale(newState, prevState.appearanceSeq)) { + for (const key of APPEARANCE_KEYS) { + ;(rest as Record)[key] = prevState[key] + } + rest.appearanceSeq = prevState.appearanceSeq + } else if (newState.appearanceSeq !== undefined) { + for (const key of APPEARANCE_KEYS) { + ;(rest as Record)[key] = newState[key] + } + } + // Note that we completely replace the previous apiConfiguration and customSupportPrompts objects // with new ones since the state that is broadcast is the entire objects so merging is not necessary. return { @@ -643,11 +674,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..d8399018b2 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() @@ -393,6 +420,74 @@ describe("ExtensionStateContext", () => { expect(cache?.size).toBe(0) }) + it("keeps the appearance values of a newer push when an older push arrives late", () => { + const initial = { + ...createInitialExtensionState(), + chatFontSize: 16, + interfaceFontScale: 1.2, + terminalFontSize: 14, + editorFontSize: 15, + spacingScale: 1.1, + appearanceSeq: 5, + } + const older = { + chatFontSize: 12, + interfaceFontScale: 1, + terminalFontSize: 10, + editorFontSize: 11, + spacingScale: 0.9, + appearanceSeq: 4, + soundEnabled: true, + } + const merged = mergeExtensionState(initial, older) + expect(merged).toMatchObject({ + chatFontSize: 16, + interfaceFontScale: 1.2, + terminalFontSize: 14, + editorFontSize: 15, + spacingScale: 1.1, + appearanceSeq: 5, + }) + // Only the appearance values are held back; the rest of the push still applies. + expect(merged.soundEnabled).toBe(true) + + const newer = mergeExtensionState(initial, { ...older, appearanceSeq: 6 }) + expect(newer).toMatchObject({ chatFontSize: 12, interfaceFontScale: 1, spacingScale: 0.9, appearanceSeq: 6 }) + }) + + it("clears an appearance value that a newer numbered push does not carry (it was reset to unset)", () => { + const initial = { + ...createInitialExtensionState(), + chatFontSize: 16, + terminalFontSize: 14, + spacingScale: 1.1, + appearanceSeq: 5, + } + // JSON serialization drops an undefined value, as the extension transport does. + const pushed = JSON.parse(JSON.stringify({ chatFontSize: 16, terminalFontSize: undefined, appearanceSeq: 6 })) + + const merged = mergeExtensionState(initial, pushed) + + expect(merged.terminalFontSize).toBeUndefined() + expect(merged.spacingScale).toBeUndefined() + expect(merged).toMatchObject({ chatFontSize: 16, appearanceSeq: 6 }) + }) + + it("does not clear the appearance values for a push with no sequence number, or a stale one", () => { + const initial = { ...createInitialExtensionState(), terminalFontSize: 14, appearanceSeq: 5 } + + expect(mergeExtensionState(initial, { soundEnabled: true }).terminalFontSize).toBe(14) + expect(mergeExtensionState(initial, { soundEnabled: true, appearanceSeq: 4 }).terminalFontSize).toBe(14) + }) + + it("applies the appearance values of a push that has no sequence number", () => { + const initial = { ...createInitialExtensionState(), spacingScale: 1.1, appearanceSeq: 5 } + expect(mergeExtensionState(initial, { spacingScale: 0.9 }).spacingScale).toBe(0.9) + expect( + mergeExtensionState(createInitialExtensionState(), { spacingScale: 0.9, appearanceSeq: 1 }), + ).toMatchObject({ spacingScale: 0.9, appearanceSeq: 1 }) + }) + it("clears a serialized absent task id and preserves task identity on stale snapshots", () => { const initial = { ...createInitialExtensionState(), currentTaskId: "task-a", clineMessagesSeq: 5 } // JSON serialization intentionally drops undefined, reproducing the extension transport. @@ -794,6 +889,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 d519faf081..457abe89ee 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -22,7 +22,8 @@ "title": "Canvis no desats", "description": "Voleu descartar els canvis i continuar?", "cancelButton": "Cancel·lar", - "discardButton": "Descartar canvis" + "discardButton": "Descartar canvis", + "saveMayComplete": "Un desament que encara no ha rebut resposta pot completar-se després de sortir." }, "sections": { "providers": "Proveïdors", @@ -1071,6 +1072,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 dcf7c64130..2be2a98f21 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -22,7 +22,8 @@ "title": "Ungespeicherte Änderungen", "description": "Möchtest du die Änderungen verwerfen und fortfahren?", "cancelButton": "Abbrechen", - "discardButton": "Änderungen verwerfen" + "discardButton": "Änderungen verwerfen", + "saveMayComplete": "Ein Speichern, das noch nicht beantwortet wurde, kann nach dem Verlassen noch abgeschlossen werden." }, "sections": { "providers": "Anbieter", @@ -1071,6 +1072,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 a0ad1a555b..73b221e184 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -22,7 +22,8 @@ "title": "Unsaved Changes", "description": "Do you want to discard changes and continue?", "cancelButton": "Cancel", - "discardButton": "Discard changes" + "discardButton": "Discard changes", + "saveMayComplete": "A save that has not been answered yet may still complete after you leave." }, "sections": { "providers": "Providers", @@ -234,6 +235,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 71ae1d4589..965ee3577b 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -22,7 +22,8 @@ "title": "Cambios no guardados", "description": "¿Desea descartar los cambios y continuar?", "cancelButton": "Cancelar", - "discardButton": "Descartar cambios" + "discardButton": "Descartar cambios", + "saveMayComplete": "Un guardado que aún no ha recibido respuesta puede completarse después de salir." }, "sections": { "providers": "Proveedores", @@ -1071,6 +1072,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 f891ef2896..8271fbe115 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -22,7 +22,8 @@ "title": "Modifications non enregistrées", "description": "Voulez-vous ignorer les modifications et continuer ?", "cancelButton": "Annuler", - "discardButton": "Ignorer les modifications" + "discardButton": "Ignorer les modifications", + "saveMayComplete": "Un enregistrement qui n'a pas encore reçu de réponse peut encore aboutir après votre départ." }, "sections": { "providers": "Fournisseurs", @@ -1071,6 +1072,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 d8316e6a58..2bb0bbaac0 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -22,7 +22,8 @@ "title": "असहेजे परिवर्तन", "description": "क्या आप परिवर्तनों को छोड़कर जारी रखना चाहते हैं?", "cancelButton": "रद्द करें", - "discardButton": "परिवर्तन छोड़ें" + "discardButton": "परिवर्तन छोड़ें", + "saveMayComplete": "जिस सेव का अभी तक उत्तर नहीं मिला है, वह आपके बाहर निकलने के बाद भी पूरा हो सकता है।" }, "sections": { "providers": "प्रदाता", @@ -1071,6 +1072,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 13985140b0..3ba6262fa2 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -22,7 +22,8 @@ "title": "Perubahan Belum Disimpan", "description": "Apakah kamu ingin membuang perubahan dan melanjutkan?", "cancelButton": "Batal", - "discardButton": "Buang perubahan" + "discardButton": "Buang perubahan", + "saveMayComplete": "Penyimpanan yang belum dijawab mungkin masih selesai setelah Anda keluar." }, "sections": { "providers": "Provider", @@ -1071,6 +1072,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 79359b442b..5ecb677c0e 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -22,7 +22,8 @@ "title": "Modifiche non salvate", "description": "Vuoi scartare le modifiche e continuare?", "cancelButton": "Annulla", - "discardButton": "Scarta modifiche" + "discardButton": "Scarta modifiche", + "saveMayComplete": "Un salvataggio che non ha ancora ricevuto risposta potrebbe comunque completarsi dopo l'uscita." }, "sections": { "providers": "Fornitori", @@ -1071,6 +1072,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 6cfe95b3da..e45f120285 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -22,7 +22,8 @@ "title": "未保存の変更", "description": "変更を破棄して続行しますか?", "cancelButton": "キャンセル", - "discardButton": "変更を破棄" + "discardButton": "変更を破棄", + "saveMayComplete": "まだ応答のない保存は、画面を離れた後に完了する可能性があります。" }, "sections": { "providers": "プロバイダー", @@ -1071,6 +1072,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 90d9697d56..1fb2d5bdac 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -22,7 +22,8 @@ "title": "저장되지 않은 변경 사항", "description": "변경 사항을 버리고 계속하시겠습니까?", "cancelButton": "취소", - "discardButton": "변경 사항 버리기" + "discardButton": "변경 사항 버리기", + "saveMayComplete": "아직 응답이 없는 저장은 나간 후에도 완료될 수 있습니다." }, "sections": { "providers": "공급자", @@ -1071,6 +1072,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 4e75fe25a0..e00bf09764 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -22,7 +22,8 @@ "title": "Niet-opgeslagen wijzigingen", "description": "Wil je de wijzigingen negeren en doorgaan?", "cancelButton": "Annuleren", - "discardButton": "Wijzigingen negeren" + "discardButton": "Wijzigingen negeren", + "saveMayComplete": "Een opslag die nog niet is beantwoord, kan na het verlaten alsnog worden voltooid." }, "sections": { "providers": "Providers", @@ -1071,6 +1072,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 ccf941ae2e..104707aa1e 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -22,7 +22,8 @@ "title": "Niezapisane zmiany", "description": "Czy chcesz odrzucić zmiany i kontynuować?", "cancelButton": "Anuluj", - "discardButton": "Odrzuć zmiany" + "discardButton": "Odrzuć zmiany", + "saveMayComplete": "Zapis, na który nie ma jeszcze odpowiedzi, może zostać ukończony po wyjściu." }, "sections": { "providers": "Dostawcy", @@ -1071,6 +1072,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 2c846e6701..dedec71359 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -22,7 +22,8 @@ "title": "Alterações não salvas", "description": "Deseja descartar as alterações e continuar?", "cancelButton": "Cancelar", - "discardButton": "Descartar alterações" + "discardButton": "Descartar alterações", + "saveMayComplete": "Um salvamento que ainda não foi respondido pode ser concluído depois que você sair." }, "sections": { "providers": "Provedores", @@ -1071,6 +1072,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 09136add5b..1f8c1a22c9 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -22,7 +22,8 @@ "title": "Несохранённые изменения", "description": "Вы хотите отменить изменения и продолжить?", "cancelButton": "Отмена", - "discardButton": "Отменить изменения" + "discardButton": "Отменить изменения", + "saveMayComplete": "Сохранение, на которое ещё нет ответа, может завершиться после выхода." }, "sections": { "providers": "Провайдеры", @@ -1071,6 +1072,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 5239344678..4e4b95cd5d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -22,7 +22,8 @@ "title": "Kaydedilmemiş Değişiklikler", "description": "Değişiklikleri atmak ve devam etmek istiyor musunuz?", "cancelButton": "İptal", - "discardButton": "Değişiklikleri At" + "discardButton": "Değişiklikleri At", + "saveMayComplete": "Henüz yanıtlanmamış bir kayıt, siz ayrıldıktan sonra yine de tamamlanabilir." }, "sections": { "providers": "Sağlayıcılar", @@ -1071,6 +1072,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 61080a1689..4cb6a1efcc 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -22,7 +22,8 @@ "title": "Thay đổi chưa lưu", "description": "Bạn có muốn hủy thay đổi và tiếp tục không?", "cancelButton": "Hủy", - "discardButton": "Hủy thay đổi" + "discardButton": "Hủy thay đổi", + "saveMayComplete": "Một lần lưu chưa được phản hồi vẫn có thể hoàn tất sau khi bạn rời đi." }, "sections": { "providers": "Nhà cung cấp", @@ -1071,6 +1072,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 dc94b391b9..7c2a7455dc 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -22,7 +22,8 @@ "title": "未保存的更改", "description": "是否放弃更改并继续?", "cancelButton": "取消", - "discardButton": "放弃更改" + "discardButton": "放弃更改", + "saveMayComplete": "尚未得到响应的保存可能在你离开后仍会完成。" }, "sections": { "providers": "提供商", @@ -1071,6 +1072,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 b18d055305..caa4a678af 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -22,7 +22,8 @@ "title": "未儲存的變更", "description": "是否要取消變更並繼續?", "cancelButton": "取消", - "discardButton": "取消變更" + "discardButton": "取消變更", + "saveMayComplete": "尚未得到回應的儲存可能在你離開後仍會完成。" }, "sections": { "providers": "供應商", @@ -188,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 de889f4168..e4161762ec 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); @@ -260,6 +260,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 { @@ -490,6 +509,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) + } +}