feat(zoom): route Increase/Decrease Font Size across editors, terminals and WebViews (#2172)

Increase, Decrease and Reset Font Size only reached the active CodeMirror editor, so they did nothing with focus in a terminal or plugin WebView. They now apply to the focused surface through a shared workbench service: an editor tab, a terminal tab (which refits), or a plugin WebView. Wheel zoom stays pointer-based.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-09 02:22:51 +08:00
1 джерело ea8e04e448
коміт 62a39dadb7
9 files changed
+259 -9

No files matched your search

+29 -4
Переглянути файл
@@ -6,6 +6,7 @@ import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { resetSurfaceFontZoom, stepFocusedSurfaceFontZoom } from "@/workbench/contributions";
import { editorController, useGotoLine, useTabs } from "./controller";
import {
addCaretsToLineEnds,
@@ -109,6 +110,27 @@ function withView(run: (view: EditorView, slot: EditorSlot) => unknown) {
};
}
/** Routes keyboard font zoom to focus, then the active editor slot. */
function stepSurfaceFontSize(delta: 1 | -1): void {
const activeEl = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;
if (activeEl) {
if (stepFocusedSurfaceFontZoom(delta)) return;
const cmEl = activeEl.closest?.(".cm-editor") as HTMLElement | null;
if (cmEl) {
const cmView = EditorView.findFromDOM(cmEl);
if (cmView) {
stepFontZoom(cmView, delta);
return;
}
}
}
const slot = activeSlot();
if (!slot?.view) return;
stepFontZoom(slot.view, delta);
if (!slot.view.hasFocus && !getContext().popupOpen) slot.view.focus();
}
// --- extend / shrink selection ---------------------------------------------------
/** One stack per selection range (by index): ⌥↑ / ⌥↓ work on every caret. */
@@ -872,6 +894,9 @@ export function registerEditorActions(): () => void {
run: () => {
for (const ed of useEditorRegistry.getState().editors)
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
if (typeof document !== "undefined") {
resetSurfaceFontZoom();
}
},
},
{
@@ -879,16 +904,16 @@ export function registerEditorActions(): () => void {
title: "Increase Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, 1)),
when: "projectOpen",
run: () => stepSurfaceFontSize(1),
},
{
id: "editor.decreaseFontSize",
title: "Decrease Font Size",
category: "View",
keywords: ["zoom", "font size"],
when: EDITOR_WHEN,
run: withView((view) => stepFontZoom(view, -1)),
when: "projectOpen",
run: () => stepSurfaceFontSize(-1),
},
{
id: "file.toggleReadOnly",
+25
Переглянути файл
@@ -102,6 +102,31 @@ describe("web view helpers", () => {
});
describe("frame bridge", () => {
it("accepts bounded zoom only from its parent with the bridge tag", () => {
const script = /<script>([\s\S]*)<\/script>/.exec(frameDocument("", { dark: true, vars: {} }))![1]!;
const parent = {};
const handlers = new Map<string, (e: { source: object; data: object }) => void>();
const root = document.createElement("html");
const frameWindow = {
parent,
addEventListener: (type: string, fn: (e: { source: object; data: object }) => void) => handlers.set(type, fn),
};
new Function("window", "document", script)(frameWindow, { documentElement: root });
const send = (source: object, data: object) => handlers.get("message")!({ source, data });
send({}, { tag: FRAME_TAG, fontZoomDelta: 1 });
send(parent, { tag: "other", fontZoomDelta: 1 });
send(parent, { tag: FRAME_TAG, fontZoomDelta: NaN });
send(parent, { tag: FRAME_TAG, fontZoomDelta: 100 });
expect(root.style.zoom).toBe("");
send(parent, { tag: FRAME_TAG, fontZoomDelta: 1 });
expect(root.style.zoom).toBe("1.08");
send(parent, { tag: FRAME_TAG, fontZoomDelta: -1 });
expect(root.style.zoom).toBe("");
send(parent, { tag: FRAME_TAG, fontZoomDelta: 1 });
send(parent, { tag: FRAME_TAG, fontZoomDelta: 0 });
expect(root.style.zoom).toBe("");
});
it("asks the workbench for the document and edits it, and hears of changes made elsewhere", async () => {
const script = /<script>([\s\S]*)<\/script>/.exec(frameDocument("", { dark: true, vars: {} }))![1]!;
const sent: Array<{ call: number; op: string; args: unknown }> = [];
+6 -1
Переглянути файл
@@ -20,7 +20,7 @@ import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { EmptyState, Spinner } from "@/ui";
import { editorService } from "@/workbench/contributions";
import { editorService, registerFontZoomSurface } from "@/workbench/contributions";
import { ToolWindowCornerActions, useOwnToolWindowHeader } from "@/workbench/toolwindows/ToolWindows";
import { pluginsBackend, type FileFilter } from "../backend";
import { errorText, usePlugins } from "../store";
@@ -164,6 +164,10 @@ export function WebView({ pluginId, view, label, header = "default", document =
const post = useCallback((message: object, transfer: Transferable[] = []) => {
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, ...message }, "*", transfer);
}, []);
useEffect(() => {
const host = frame.current;
if (host) return registerFontZoomSurface(host, (delta) => post({ fontZoomDelta: delta }));
}, [page, post]);
const postTheme = useCallback(() => {
const t = frameTheme();
if (ownHeader) {
@@ -360,6 +364,7 @@ export function WebView({ pluginId, view, label, header = "default", document =
<iframe
ref={frame}
className={styles.frame}
data-plugin-webview={pluginId}
title={label}
sandbox="allow-scripts"
allow={clipboard ? "clipboard-read; clipboard-write" : undefined}
+9 -1
Переглянути файл
@@ -60,12 +60,18 @@
if (initialTheme) applyTheme(initialTheme);
const stepFrameFontZoom = (delta) => {
const cur = Math.round((parseFloat(root.style.zoom || "1") || 1) * 100);
const next = delta === 0 ? 100 : Math.max(60, Math.min(200, cur + delta * 8));
root.style.zoom = next === 100 ? "" : (next / 100).toFixed(2);
};
// IDE shortcuts (Save, Close, Find Action…) pressed while the page has focus:
// the ones the page left alone go to the workbench. Editing keys stay here.
const EDITING_KEYS = new Set(["a", "c", "v", "x", "z", "y"]);
window.addEventListener("keydown", (e) => {
const fn = /^F\d{1,2}$/.test(e.key);
const mod = (e.metaKey || e.ctrlKey) && !EDITING_KEYS.has(e.key.toLowerCase());
const mod = (e.metaKey || e.ctrlKey || e.altKey) && !EDITING_KEYS.has(e.key.toLowerCase());
if (e.isComposing || (!fn && !mod)) return;
// After every handler of the page has seen it.
setTimeout(() => {
@@ -78,6 +84,8 @@
const m = e.data;
if (e.source !== window.parent || !m || m.tag !== TAG) return;
if (m.theme) applyTheme(m.theme);
else if (m.fontZoomDelta === -1 || m.fontZoomDelta === 0 || m.fontZoomDelta === 1)
stepFrameFontZoom(m.fontZoomDelta);
else if (typeof m.reply === "number") {
const p = pending.get(m.reply);
pending.delete(m.reply);
+33
Переглянути файл
@@ -108,3 +108,36 @@ describe("terminal copy capture", () => {
}
});
});
it.each([
["Ctrl+C", "c", "KeyC", true],
["Ctrl+R", "r", "KeyR", false],
["Shift+Enter", "Enter", "Enter", false],
] as const)("keeps terminal handling for %s even when bound to font zoom", (stroke, key, code, selection) => {
mock.settings.terminal.overrideIdeShortcuts = true;
mock.session.hasSelection = selection;
useKeymap.setState({ preset: "intellij-windows", overrides: { "editor.increaseFontSize": [stroke] } });
const zoom = vi.fn();
const removeAction = registerAction({ id: "editor.increaseFontSize", title: "Increase Font Size", run: zoom });
const removeKeys = installTerminalKeys();
const removeDispatcher = installKeyboardDispatcher();
try {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key,
code,
ctrlKey: stroke.startsWith("Ctrl"),
shiftKey: stroke.startsWith("Shift"),
cancelable: true,
}),
);
expect(zoom).not.toHaveBeenCalled();
if (selection) expect(mock.session.copySelection).toHaveBeenCalledOnce();
else expect(mock.session.send).toHaveBeenCalledWith(key === "r" ? "\x12" : "\x1b\r");
} finally {
removeDispatcher();
removeKeys();
removeAction();
useKeymap.setState({ overrides: {} });
}
});
+48 -1
Переглянути файл
@@ -1,3 +1,6 @@
import { useProject } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { getSession, newTab, splitTab, syncTerminalProject, unbindTerminals, useTerminals } from "./store";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS } from "./fit";
import { findTerminalLinks, type TerminalLink } from "./links";
@@ -21,7 +24,8 @@ const workbench = vi.hoisted(() => ({
}));
vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true }));
vi.mock("@/workbench/contributions", () => ({
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<object>()),
editorService: () => ({ openFile: workbench.openFile }),
notifications: () => ({ notify: workbench.notify }),
OWNS_FILE_DROP: { "data-owner": true },
@@ -210,3 +214,46 @@ describe("terminal open", () => {
container.remove();
});
});
describe("terminal font zoom", () => {
afterEach(() => {
unbindTerminals();
localStorage.clear();
useProject.setState({ settings: null });
});
it("keeps the offset through settings, refit and moving splits; fresh splits and restores start at the base", () => {
const settings = (fontSize: number) => {
useProject.setState({ settings: { terminal: { useEditorFont: false, fontSize } } } as never);
for (const tab of useTerminals.getState().tabs)
getSession(tab.session)?.applySettings(workflowSettings().terminal);
};
settings(14);
syncTerminalProject("/font-zoom");
const a = newTab("/font-zoom");
const first = getSession(a.session)!;
const b = newTab("/font-zoom");
const second = getSession(b.session)!;
first.stepFontSize(2);
expect(first.term.options.fontSize).toBe(16);
expect(second.term.options.fontSize).toBe(14);
settings(18);
expect(first.term.options.fontSize).toBe(20);
first.refit();
expect(first.term.options.fontSize).toBe(20);
splitTab(a.id, "horizontal", true);
expect(getSession(a.session)).toBe(first);
expect(first.term.options.fontSize).toBe(20);
const split = splitTab(a.id, "vertical")!;
const splitTabInfo = useTerminals.getState().tabs.find((t) => t.id === split)!;
expect(getSession(splitTabInfo.session)!.term.options.fontSize).toBe(18);
first.stepFontSize(0);
expect(first.term.options.fontSize).toBe(18);
second.stepFontSize(3);
expect(second.term.options.fontSize).toBe(21);
expect(first.term.options.fontSize).toBe(18);
syncTerminalProject(null);
syncTerminalProject("/font-zoom");
for (const tab of useTerminals.getState().tabs) expect(getSession(tab.session)!.term.options.fontSize).toBe(18);
});
});
+22 -2
Переглянути файл
@@ -9,7 +9,7 @@ import { SearchAddon, type ISearchOptions } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { editorService, notifications, registerFontZoomSurface } from "@/workbench/contributions";
import { fitScheduler, settledResize } from "./fit";
import { installTerminalDrop } from "./drop";
import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
@@ -211,6 +211,8 @@ export class TerminalSession {
/** Keystrokes typed while the PTY is still starting; sent as soon as it exists. */
private pendingInput = "";
private lastSize = { cols: 0, rows: 0 };
/** Per-tab font size offset (px) relative to `workflowSettings().terminal.fontSize`. */
private fontZoom = 0;
/** Bumped on restart so late output from a previous PTY is ignored. */
private generation = 0;
/** WebKitGTK delivers one IME commit' worth of text more than once; see `ImeCommitGate`. */
@@ -308,6 +310,7 @@ export class TerminalSession {
{ dispose: installTerminalDrop(this.host, (text) => this.paste(text)) },
);
this.host.addEventListener("mousedown", this.onMiddleClick);
this.disposables.push({ dispose: registerFontZoomSurface(this.host, (delta) => this.stepFontSize(delta)) });
}
private onMiddleClick = (e: MouseEvent) => {
@@ -352,7 +355,8 @@ export class TerminalSession {
() => this.scheduleFit(),
);
}
if (o.fontSize !== font.size) o.fontSize = font.size;
const targetSize = Math.max(8, Math.min(36, font.size + this.fontZoom));
if (o.fontSize !== targetSize) o.fontSize = targetSize;
if (o.lineHeight !== font.lineHeight) o.lineHeight = font.lineHeight;
if (o.letterSpacing !== font.letterSpacing) o.letterSpacing = font.letterSpacing;
if (o.fontWeight !== font.weight) o.fontWeight = font.weight;
@@ -363,6 +367,22 @@ export class TerminalSession {
this.scheduleFit();
}
/** Steps this terminal tab's font size by `delta` px (or resets to 0 when `delta === 0`). */
stepFontSize(delta: number) {
const base = fontFor(workflowSettings().terminal).size;
if (delta === 0) {
this.fontZoom = 0;
} else {
const nextSize = Math.max(8, Math.min(36, base + this.fontZoom + delta));
this.fontZoom = nextSize - base;
}
const targetSize = Math.max(8, Math.min(36, base + this.fontZoom));
if (this.term.options.fontSize !== targetSize) {
this.term.options.fontSize = targetSize;
this.refit();
}
}
get state(): SessionSnapshot {
return this.snapshot;
}
+62
Переглянути файл
@@ -1,3 +1,4 @@
import { registerFontZoomSurface } from "@/workbench/contributions";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
@@ -114,6 +115,67 @@ describe("editor font size actions", () => {
expect(view.dom.style.getPropertyValue("--openidea-editor-font-size")).toContain("+ 2px");
});
it("routes font size actions by focus even when another surface is hovered", async () => {
const md = open(mdCtx);
activate(md);
// Works even when editorFocus is false (e.g. a tool window has focus).
useContextKeys.setState({ projectOpen: true, textEditorActive: false, editorFocus: false, popupOpen: false });
const termHost = document.createElement("div");
termHost.setAttribute("data-terminal-session", "s1");
const termInput = document.createElement("textarea");
termHost.appendChild(termInput);
document.body.appendChild(termHost);
const termZooms: number[] = [];
const releaseTerminal = registerFontZoomSurface(termHost, (delta) => termZooms.push(delta));
const iframe = document.createElement("iframe");
iframe.setAttribute("data-plugin-webview", "dengliu.kubernetes");
document.body.appendChild(iframe);
const posted: unknown[] = [];
Object.defineProperty(iframe, "contentWindow", {
value: { postMessage: (msg: unknown) => posted.push(msg) },
configurable: true,
});
const releaseFrame = registerFontZoomSurface(iframe, (delta) =>
iframe.contentWindow?.postMessage({ tag: "openidea-view", fontZoomDelta: delta }, "*"),
);
try {
termInput.focus();
await executeAction("editor.increaseFontSize");
await executeAction("editor.decreaseFontSize");
expect(termZooms).toEqual([1, -1]);
expect(zoom(md)).toBe(0);
// The pointer must not redirect a keyboard action away from terminal focus.
const qsa = vi.spyOn(document, "querySelectorAll").mockImplementation(((sel: string) => {
if (sel === ":hover") return [iframe] as unknown as NodeListOf<Element>;
return Document.prototype.querySelectorAll.call(document, sel);
}) as typeof document.querySelectorAll);
await executeAction("editor.increaseFontSize");
expect(posted).toEqual([]);
expect(termZooms).toEqual([1, -1, 1]);
iframe.focus();
await executeAction("editor.increaseFontSize");
expect(posted).toEqual([{ tag: "openidea-view", fontZoomDelta: 1 }]);
qsa.mockRestore();
await executeAction("editor.resetFontSize");
expect(termZooms).toEqual([1, -1, 1, 0]);
expect(posted).toEqual([
{ tag: "openidea-view", fontZoomDelta: 1 },
{ tag: "openidea-view", fontZoomDelta: 0 },
]);
} finally {
releaseTerminal();
releaseFrame();
termHost.remove();
iframe.remove();
}
});
it("leaves ⌘= / ⌘- to fold and unfold", () => {
for (const km of [intellijMacos, intellijWindows]) {
expect(km["editor.increaseFontSize"]).toEqual(["Alt+Shift+."]);
+25
Переглянути файл
@@ -1706,3 +1706,28 @@ export function registerDocumentIndexSync(sync: (path: string) => Promise<void>)
export async function synchronizeDocumentIndexes(path: string): Promise<void> {
await Promise.all([...documentIndexSync].map((sync) => sync(path)));
}
/** Font zoom for non-CodeMirror surfaces, owned and released by their features. */
const fontZoomSurfaces = new Map<Element, (delta: number) => void>();
export function registerFontZoomSurface(host: Element, step: (delta: number) => void): () => void {
fontZoomSurfaces.set(host, step);
return () => {
fontZoomSurfaces.delete(host);
};
}
export function stepFocusedSurfaceFontZoom(delta: number): boolean {
const focused = document.activeElement;
for (const [host, step] of fontZoomSurfaces) {
if (focused && host.contains(focused)) {
step(delta);
return true;
}
}
return false;
}
export function resetSurfaceFontZoom(): void {
for (const step of fontZoomSurfaces.values()) step(0);
}