дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
ea8e04e448
коміт
62a39dadb7
9 files changed
+259
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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+."]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача