Compare commits

..
Author SHA1 Message Date
arookieofcandClaude Code 6f262e0cc0 chore: fix README formatting (pre-existing oxfmt violation blocking CI)
Co-Authored-By: Claude Code <[email protected]>
2026-11-10 11:04:10 +08:00
arookieofcandClaude Code 4d39216b00 fix(vcs): let Git Log ref labels size to content (#2447)
Remove the unconditional 180px per-label cap and let the refs row use
the full subject cell when space allows, shrinking with ellipsis (full
name kept in the title tooltip) only when space truly runs out.

Co-Authored-By: Claude Code <[email protected]>
2026-11-10 10:47:09 +08:00
l0ng-ai 97ff542cdd fix(editor): prevent duplicate Linux clipboard commands (#2392) 2026-11-10 10:03:35 +08:00
9 changed files with 272 additions and 204 deletions

No files matched your search

-2
View File
@@ -76,6 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
-73
View File
@@ -1,73 +0,0 @@
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
test("shows Update failures from the branch menu even when VCS notifications are muted", async ({
workbench: page,
}) => {
await stubDesktop(page);
await page.evaluate(async () => {
const root = "/dev/acme-api";
const branch = { head: "main", upstream: "origin/main", behind: 68, ahead: 0, operation: null, headCommit: "c1" };
const branches = [
{
name: "main",
current: true,
remote: false,
upstream: "origin/main",
behind: 68,
ahead: 0,
commit: "c1",
lastCommitMs: 0,
},
];
const h = window.__ipc.handlers;
h.vcs_branch_info = () => branch;
h.vcs_branches = () => branches;
h.vcs_roots = () => [{ root, head: "main", headCommit: "c1", operation: null }];
h.vcs_status = () => [];
h.vcs_log = () => [];
h.vcs_tags = () => [];
h.vcs_config_get = () => null;
h.vcs_merge_base = () => "c1";
h.vcs_remote = () => {
throw {
kind: "invalid",
message:
"The following untracked working tree files would be overwritten by merge: e2e/settingsFormLayout.spec.ts",
};
};
const projectModule = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ projectModule);
useProject.setState({
settings: {
...useProject.getState().settings,
notifications: { groups: { vcs: { display: "none", log: true } } },
},
});
const storeModule = "/src/features/vcs/store.ts";
const { useVcs } = await import(/* @vite-ignore */ storeModule);
useVcs.setState({
available: true,
root,
repo: root,
roots: [root],
branch,
branches,
branchesLoaded: true,
});
const contextModule = "/src/platform/context.ts";
const { setContext } = await import(/* @vite-ignore */ contextModule);
setContext("vcsAvailable", true);
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("vcs.showLog");
});
const branches = page.getByRole("tree", { name: "Branches" });
await branches.getByRole("treeitem", { name: /main current/ }).click({ button: "right" });
await page.getByRole("menuitem", { name: "Update", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Update Project", exact: true });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "OK", exact: true }).click();
await expect(page.getByText("Update failed", { exact: true })).toBeVisible();
await expect(page.getByText(/untracked working tree files would be overwritten by merge/)).toBeVisible();
expect(await ipcCalls(page, "vcs_remote")).toHaveLength(1);
});
+5 -2
View File
@@ -310,7 +310,9 @@
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 50%;
/* May use the whole subject cell when space allows (#2447); shrinks with
per-label ellipsis before crowding out the commit message. */
max-width: 100%;
overflow: hidden;
margin-left: auto;
}
@@ -328,7 +330,8 @@
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 180px;
/* Sizes to content (#2447): truncates with ellipsis only when .refs runs
out of room; the full name stays available via the title tooltip. */
}
/* Outlined tag glyph in the ref's colour. */
.ref::before {
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { RefLabels } from "../GitLog";
const css = readFileSync("src/features/vcs/GitLog.module.css", "utf8");
const LONG_LOCAL = "feature-connection-storm-v2";
const LONG_REMOTE = "gitlab/feature-connection-storm-v2";
const REMOTES = new Set([LONG_REMOTE]);
describe("RefLabels long branch names (#2447)", () => {
it("renders full names with title tooltips in regular mode", () => {
render(<RefLabels refs={[LONG_LOCAL, LONG_REMOTE]} remoteNames={REMOTES} />);
expect(screen.getByTitle(LONG_LOCAL)).toHaveTextContent(LONG_LOCAL);
expect(screen.getByTitle(LONG_REMOTE)).toHaveTextContent(LONG_REMOTE);
});
it("absorbs the remote into the local label in compact mode", () => {
render(<RefLabels refs={[LONG_LOCAL, LONG_REMOTE]} compact remoteNames={REMOTES} />);
expect(screen.getByTitle(`${LONG_LOCAL}, ${LONG_REMOTE}`)).toHaveTextContent(`gitlab & ${LONG_LOCAL}`);
});
it("lets labels size to content: no fixed pixel cap, ellipsis and clipping kept", () => {
const refBlocks = [...css.matchAll(/\.ref\s*\{([^}]*)\}/g)].map((m) => m[1]);
expect(refBlocks.length).toBeGreaterThan(0);
for (const block of refBlocks) expect(block).not.toMatch(/max-width:\s*\d+px/);
expect(refBlocks[0]).toMatch(/text-overflow:\s*ellipsis/);
const refsBlock = css.match(/\.refs\s*\{([^}]*)\}/)?.[1] ?? "";
expect(refsBlock).toMatch(/overflow:\s*hidden/);
});
});
@@ -1,11 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo, RemoteOp } from "@/bindings";
import type { BranchInfo } from "@/bindings";
let branch: Partial<BranchInfo> = {};
/** What a pull brings in: the head moves to `c2`, with these changed files and commits. */
let pulled = { files: 0, commits: 0 };
let failure: { kind: string; message: string } | null = null;
let remoteGate: Promise<void> | null = null;
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
@@ -25,12 +23,9 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch },
}),
vcsRemote: async () => {
await remoteGate;
if (failure) return { status: "error", error: failure };
if (pulled.commits) branch = { ...branch, headCommit: "c2" };
return { status: "ok", data: null };
},
vcsOperation: async () => (failure ? { status: "error", error: failure } : { status: "ok", data: null }),
vcsDiffRevisions: async () => ({ status: "ok", data: Array.from({ length: pulled.files }, (_, i) => ({ i })) }),
vcsLog: async () => ({ status: "ok", data: Array.from({ length: pulled.commits }, (_, i) => ({ i })) }),
cancelRequest: async () => ({ status: "ok", data: null }),
@@ -40,10 +35,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
import { _resetI18nForTests, setLocale, txTemplate } from "@/platform/i18n";
import { provideNotificationService } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
import { expireNotification, notify, useNotifications } from "@/workbench/notificationStore";
import { runGitOperation, runOp, runRemote } from "../ops";
import { useVcs } from "../store";
import { runRemote } from "../ops";
type Note = { title: string; level: string };
let notes: Note[] = [];
@@ -56,11 +48,6 @@ beforeEach(() => {
afterEach(() => {
branch = {};
pulled = { files: 0, commits: 0 };
failure = null;
remoteGate = null;
useVcs.setState({ remoteBusy: null });
useNotifications.setState({ items: [] });
useProject.setState({ settings: null });
_resetI18nForTests();
});
@@ -118,97 +105,3 @@ describe("Update Project result notification", () => {
expect(txTemplate(await updateTitle())).toBe("已在 2 个提交中更新 3 个文件");
});
});
describe.each([
["VCS popups disabled", { groups: { vcs: { display: "none", log: true } } }],
["all balloons disabled", { balloons: false }],
["VCS popups and logging disabled", { groups: { vcs: { display: "none", log: false } } }],
])("Git command feedback with %s", (_name, prefs) => {
beforeEach(() => {
useProject.setState({ settings: { notifications: prefs } as never });
provideNotificationService({ notify, expire: expireNotification });
});
it("shows the untracked-file error that prevented Update", async () => {
failure = {
kind: "invalid",
message:
"The following untracked working tree files would be overwritten by merge: e2e/settingsFormLayout.spec.ts",
};
expect(await runRemote({ op: "pull", rebase: false }, undefined, ["/p"])).toBe(false);
expect(useNotifications.getState().items.find((n) => n.title === "Update failed")).toMatchObject({
balloon: true,
body: failure.message,
});
});
it.each<[RemoteOp, string]>([
[{ op: "fetch" }, "Fetch successful"],
[{ op: "push", force_with_lease: false, set_upstream: false, tags: null, no_verify: false }, "Push successful"],
[{ op: "pull", rebase: false }, "All files are up-to-date"],
])("shows progress and the result of $op", async (op, title) => {
let finish!: () => void;
remoteGate = new Promise((resolve) => (finish = resolve));
const running = runRemote(op, undefined, ["/p"]);
try {
expect(useNotifications.getState().items.find((n) => n.key === "vcs.remote")).toMatchObject({
balloon: true,
sticky: true,
});
} finally {
finish();
}
expect(await running).toBe(true);
expect(useNotifications.getState().items.find((n) => n.title === title)?.balloon).toBe(true);
});
it("shows the files and commits that Update brought in", async () => {
pulled = { files: 3, commits: 2 };
expect(await runRemote({ op: "pull", rebase: false }, undefined, ["/p"])).toBe(true);
expect(useNotifications.getState().items.find((n) => n.title === "3 files updated in 2 commits")?.balloon).toBe(
true,
);
});
it("explains why another remote operation cannot start", async () => {
useVcs.setState({ remoteBusy: "Fetching" });
expect(await runRemote({ op: "fetch" })).toBe(false);
expect(useNotifications.getState().items[0]).toMatchObject({
title: "Another remote operation is in progress",
balloon: true,
});
});
it("keeps cancelled and caller-handled failures quiet", async () => {
failure = { kind: "cancelled", message: "cancelled" };
expect(await runRemote({ op: "fetch" })).toBe(false);
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
failure = { kind: "invalid", message: "rejected" };
expect(await runRemote({ op: "fetch" }, undefined, ["/p"], () => true)).toBe(false);
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
});
it("shows local command failures but respects quiet callers", async () => {
const fail = () => Promise.reject(new Error("index locked"));
await runOp("Commit", fail);
expect(useNotifications.getState().items[0]).toMatchObject({ title: "Commit failed", balloon: true });
useNotifications.setState({ items: [] });
await runOp("Commit", fail, { quiet: true });
expect(useNotifications.getState().items).toEqual([]);
});
it("shows long local operation results", async () => {
const op = { op: "cherryPick", commits: ["c1"] } as const;
const options = { progress: "Cherry-picking", success: "Cherry-picked c1", failure: "Cherry-pick failed" };
expect(await runGitOperation({ ...op, commits: [...op.commits] }, options)).toBe(true);
expect(useNotifications.getState().items[0]).toMatchObject({ title: options.success, balloon: true });
failure = { kind: "invalid", message: "invalid revision" };
expect(await runGitOperation({ ...op, commits: [...op.commits] }, options)).toBe(false);
expect(useNotifications.getState().items[0]).toMatchObject({ title: options.failure, balloon: true });
});
it("still suppresses unsolicited VCS notifications", () => {
notify({ group: "vcs", level: "info", title: "Background refresh" });
expect(useNotifications.getState().items.some((n) => n.balloon)).toBe(false);
});
});
+20 -18
View File
@@ -9,7 +9,7 @@ import { executeAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { attachDocument, editorsFor } from "@/editor/api";
import { maybeEditorService, notifications, type NotificationService } from "@/workbench/contributions";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
import { progressFraction } from "./model";
import { beginGitWork, conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
@@ -55,11 +55,6 @@ export function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** Feedback for an explicit Git command stays visible when background VCS notifications are muted. */
function notify(n: Omit<Parameters<NotificationService["notify"]>[0], "group" | "userInitiated">) {
notifications().notify({ ...n, group: "vcs", userInitiated: true });
}
// ---------------------------------------------------------------------------
// Local operations
@@ -85,7 +80,7 @@ export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOpti
const msg = typeof opts.success === "function" ? opts.success(value) : opts.success;
if (msg) {
logConsole("info", msg);
notify({ level: "info", title: msg });
notifications().notify({ group: "vcs", level: "info", title: msg });
}
return value;
} catch (e) {
@@ -93,7 +88,8 @@ export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOpti
logConsole("err", message);
const handled = opts.onError ? await opts.onError(message) : false;
if (!opts.quiet && !handled)
notify({
notifications().notify({
group: "vcs",
level: "error",
title: tx`${title} failed`,
body: message,
@@ -164,7 +160,7 @@ export async function runRemote(
handled?: (message: string) => boolean,
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notify({ level: "warning", title: "Another remote operation is in progress" });
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
return false;
}
const [progressText, doneTitle] = REMOTE_TITLE[op.op];
@@ -177,7 +173,8 @@ export async function runRemote(
const task = (message: string | null, fraction: number | null, done: boolean) =>
upsertTaskSoon({ id: taskId, title: progressTitle, message, fraction, done, cancel });
task(detail ?? null, null, false);
notify({
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title: `${progressTitle}…`,
@@ -234,7 +231,8 @@ export async function runRemote(
else {
// One repository's push names what went out; a partial multi-repo push stays generic.
const one = pushed.length === 1 ? pushed[0] : null;
notify({
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title:
@@ -258,7 +256,8 @@ export async function runRemote(
notifications().expire?.("vcs.remote");
void executeAction("vcs.resolveConflicts");
} else
notify({
notifications().notify({
group: "vcs",
level: "error",
key: "vcs.remote",
title: `${doneTitle} failed`,
@@ -320,7 +319,7 @@ const pushedTitle = (n: number, to: string) =>
/** IDEA's Update Project result: "N files updated in M commits" with View Commits. */
async function notifyUpdated(updated: readonly UpdatedRange[]) {
if (!updated.length) {
notify({ level: "info", key: "vcs.remote", title: "All files are up-to-date" });
notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", title: "All files are up-to-date" });
return;
}
let files = 0;
@@ -345,7 +344,8 @@ async function notifyUpdated(updated: readonly UpdatedRange[]) {
commits += log.length;
}),
);
notify({
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.remote",
title: `${filesText(files)} updated in ${commitsText(commits)}`,
@@ -389,7 +389,7 @@ export async function runGitOperation(
repo = currentRepo(),
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notify({ level: "warning", title: "Another Git operation is in progress" });
notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" });
return false;
}
await saveAll();
@@ -402,7 +402,8 @@ export async function runGitOperation(
const task = (message: string | null, done: boolean) =>
upsertTaskSoon({ id: taskId, title: progress, message, fraction: done ? 1 : null, done, cancel });
task(null, false);
notify({
notifications().notify({
group: "vcs",
level: "info",
key: "vcs.operation",
title: `${progress}…`,
@@ -424,7 +425,7 @@ export async function runGitOperation(
}
logConsole("info", success);
const actions = opts.successActions ? await opts.successActions() : undefined;
notify({ level: "info", key: "vcs.operation", title: success, actions });
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success, actions });
return true;
} catch (e) {
const cancelled = isCancelled(e);
@@ -434,7 +435,8 @@ export async function runGitOperation(
await refreshNow();
const handled = cancelled || (opts.onError ? await opts.onError(message) : false);
if (!handled)
notify({
notifications().notify({
group: "vcs",
level: "error",
key: "vcs.operation",
title: opts.failure,
+2
View File
@@ -17,6 +17,7 @@ import { installRepeatedDoubleClick } from "./platform/doubleClick";
import { installErrorLogging, logError } from "./platform/errorLog";
import { guessOs } from "./platform/platformInfo";
import { installReorderedTapClick } from "./platform/reordered-tap-click";
import { installWebKitClipboardGuard } from "./platform/webkitClipboard";
import { installPageSwipeTracking } from "./platform/pageSwipe";
import { installImeKeyGuard } from "./platform/ime";
import { installKeyboardDispatcher } from "./platform/keymap";
@@ -38,6 +39,7 @@ installRepeatedDoubleClick();
// second press like the release before it, which the shim would take for a reordered tap and
// click twice.
if (guessOs() === "macos" && !/Chrome|Firefox/.test(navigator.userAgent)) installReorderedTapClick();
if (guessOs() === "linux" && isTauri()) installWebKitClipboardGuard();
installWindowDrag();
installPointerFocus();
installImeKeyGuard();
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { installWebKitClipboardGuard } from "./webkitClipboard";
const views: EditorView[] = [];
let dispose: () => void;
beforeEach(() => {
dispose = installWebKitClipboardGuard();
});
afterEach(() => {
dispose();
views.splice(0).forEach((view) => view.destroy());
document.body.replaceChildren();
});
function editor(anchor = 5, head = anchor) {
const view = new EditorView({
doc: "first\nsecond\nthird",
selection: EditorSelection.single(anchor, head),
parent: document.body,
});
views.push(view);
view.focus();
return view;
}
function key(target: HTMLElement, type = "keydown", init: KeyboardEventInit = { key: "x", ctrlKey: true }) {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
function clipboard(target: HTMLElement, type = "cut") {
const data = new Map<string, string>();
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, { clipboardData: { clearData: () => data.clear(), setData: data.set.bind(data) } });
target.dispatchEvent(event);
return data.get("text/plain");
}
describe("WebKitGTK clipboard events", () => {
it.each([
{ key: "x", ctrlKey: true },
{ key: "Delete", shiftKey: true },
])("cuts only the caret line for one $key keydown and two cut events (#962)", (shortcut) => {
const view = editor();
key(view.contentDOM, "keydown", shortcut);
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBeUndefined();
expect(view.state.doc.toString()).toBe("second\nthird");
});
it.each([0, 2, 5, 6, 12, 18])("protects a caret at offset %i", (position) => {
const view = editor(position);
const line = view.state.doc.lineAt(position);
const expected = view.state.doc.toString().slice(0, line.from) + view.state.doc.toString().slice(line.to + 1);
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe(line.text);
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe(expected);
});
it("preserves a selected-text cut and the following line", () => {
const view = editor(0, 5);
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("\nsecond\nthird");
});
it("cuts multiple caret lines once", () => {
const view = editor();
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.allowMultipleSelections.of(true)) });
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(5), EditorSelection.cursor(12)]) });
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first\nsecond");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("third");
});
it("keeps a read-only document unchanged", () => {
const view = editor();
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) });
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
});
it("suppresses duplicate copies without changing the document", () => {
const view = editor();
key(view.contentDOM, "keydown", { key: "c", ctrlKey: true });
expect(clipboard(view.contentDOM, "copy")).toBe("first");
expect(clipboard(view.contentDOM, "copy")).toBeUndefined();
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
});
it.each(["cut", "copy"])("cancels the keypress fallback after the first %s", (type) => {
const view = editor();
const down = key(view.contentDOM);
expect(down.defaultPrevented).toBe(false);
clipboard(view.contentDOM, type);
expect(down.defaultPrevented).toBe(true);
});
it("allows successive key presses and auto-repeat", () => {
const view = editor();
for (const repeat of [false, true]) {
key(view.contentDOM, "keydown", { key: "x", ctrlKey: true, repeat });
expect(clipboard(view.contentDOM)).toBe(repeat ? "second" : "first");
clipboard(view.contentDOM);
}
expect(view.state.doc.toString()).toBe("third");
});
it.each(["keyup", "pointerdown", "blur"])("allows menu cuts after %s", (type) => {
const view = editor();
key(view.contentDOM);
clipboard(view.contentDOM);
if (type === "blur") window.dispatchEvent(new Event(type));
else view.contentDOM.dispatchEvent(new Event(type, { bubbles: true }));
expect(clipboard(view.contentDOM)).toBe("second");
});
it("does not suppress repeated menu cuts without a keydown", () => {
const view = editor();
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBe("second");
});
it("allows multiple explicit cuts from an action that handled its key", () => {
const view = editor();
view.contentDOM.addEventListener("keydown", (event) => event.preventDefault());
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBe("second");
});
it("does not carry a key press into another editor", () => {
const first = editor();
const second = editor();
key(first.contentDOM);
clipboard(first.contentDOM);
expect(clipboard(second.contentDOM)).toBe("first");
expect(clipboard(second.contentDOM)).toBe("second");
});
it("does not intercept plain inputs or unrelated clipboard events", () => {
const input = document.body.appendChild(document.createElement("input"));
let cuts = 0;
input.addEventListener("cut", () => cuts++);
key(input);
clipboard(input);
clipboard(input);
expect(cuts).toBe(2);
const view = editor();
let pastes = 0;
view.contentDOM.addEventListener("paste", () => pastes++);
key(view.contentDOM);
for (let i = 0; i < 2; i++) view.contentDOM.dispatchEvent(new Event("paste", { bubbles: true }));
expect(pastes).toBe(2);
});
it("removes the guard on disposal", () => {
const view = editor();
key(view.contentDOM);
clipboard(view.contentDOM);
dispose();
expect(clipboard(view.contentDOM)).toBe("second");
});
});
+39
View File
@@ -0,0 +1,39 @@
/** WebKitGTK 2.54 can dispatch copy/cut twice for one keydown with a collapsed selection. */
export function installWebKitClipboardGuard(): () => void {
let pending: { editor: Element; keydown: KeyboardEvent; seen: Set<string> } | null = null;
const reset = () => {
pending = null;
};
const onKeyDown = (event: KeyboardEvent) => {
const editor = event.target instanceof Element ? event.target.closest(".cm-content") : null;
pending = editor ? { editor, keydown: event, seen: new Set() } : null;
};
const onClipboard = (event: Event) => {
if (!pending || !(event.target instanceof Element) || event.target.closest(".cm-content") !== pending.editor)
return;
// Actions that already handled the key may intentionally run several clipboard commands (macros).
if (pending.seen.size === 0 && pending.keydown.defaultPrevented) return;
if (pending.seen.has(event.type)) {
event.preventDefault();
event.stopImmediatePropagation();
} else {
pending.seen.add(event.type);
// The native command can report failure after CodeMirror handles it. Stop its keypress fallback too.
pending.keydown.preventDefault();
}
};
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", reset, true);
window.addEventListener("pointerdown", reset, true);
window.addEventListener("blur", reset, true);
window.addEventListener("copy", onClipboard, true);
window.addEventListener("cut", onClipboard, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", reset, true);
window.removeEventListener("pointerdown", reset, true);
window.removeEventListener("blur", reset, true);
window.removeEventListener("copy", onClipboard, true);
window.removeEventListener("cut", onClipboard, true);
};
}