Compare commits

..
Author SHA1 Message Date
l0ng-ai b03ee48ad5 fix(vcs): show feedback for explicit Git commands 2026-11-10 09:54:04 +08:00
9 changed files with 204 additions and 272 deletions

No files matched your search

+2
View File
@@ -76,4 +76,6 @@ 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
@@ -0,0 +1,73 @@
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);
});
+2 -5
View File
@@ -310,9 +310,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
/* May use the whole subject cell when space allows (#2447); shrinks with
per-label ellipsis before crowding out the commit message. */
max-width: 100%;
max-width: 50%;
overflow: hidden;
margin-left: auto;
}
@@ -330,8 +328,7 @@
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* Sizes to content (#2447): truncates with ellipsis only when .refs runs
out of room; the full name stays available via the title tooltip. */
max-width: 180px;
}
/* Outlined tag glyph in the ref's colour. */
.ref::before {
@@ -1,32 +0,0 @@
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,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo } from "@/bindings";
import type { BranchInfo, RemoteOp } 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")>()),
@@ -23,9 +25,12 @@ 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 }),
@@ -35,7 +40,10 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
import { _resetI18nForTests, setLocale, txTemplate } from "@/platform/i18n";
import { provideNotificationService } from "@/workbench/contributions";
import { runRemote } from "../ops";
import { useProject } from "@/platform/project";
import { expireNotification, notify, useNotifications } from "@/workbench/notificationStore";
import { runGitOperation, runOp, runRemote } from "../ops";
import { useVcs } from "../store";
type Note = { title: string; level: string };
let notes: Note[] = [];
@@ -48,6 +56,11 @@ beforeEach(() => {
afterEach(() => {
branch = {};
pulled = { files: 0, commits: 0 };
failure = null;
remoteGate = null;
useVcs.setState({ remoteBusy: null });
useNotifications.setState({ items: [] });
useProject.setState({ settings: null });
_resetI18nForTests();
});
@@ -105,3 +118,97 @@ 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);
});
});
+18 -20
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 } from "@/workbench/contributions";
import { maybeEditorService, notifications, type NotificationService } from "@/workbench/contributions";
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
import { progressFraction } from "./model";
import { beginGitWork, conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
@@ -55,6 +55,11 @@ 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
@@ -80,7 +85,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);
notifications().notify({ group: "vcs", level: "info", title: msg });
notify({ level: "info", title: msg });
}
return value;
} catch (e) {
@@ -88,8 +93,7 @@ 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)
notifications().notify({
group: "vcs",
notify({
level: "error",
title: tx`${title} failed`,
body: message,
@@ -160,7 +164,7 @@ export async function runRemote(
handled?: (message: string) => boolean,
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
notify({ level: "warning", title: "Another remote operation is in progress" });
return false;
}
const [progressText, doneTitle] = REMOTE_TITLE[op.op];
@@ -173,8 +177,7 @@ 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);
notifications().notify({
group: "vcs",
notify({
level: "info",
key: "vcs.remote",
title: `${progressTitle}…`,
@@ -231,8 +234,7 @@ 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;
notifications().notify({
group: "vcs",
notify({
level: "info",
key: "vcs.remote",
title:
@@ -256,8 +258,7 @@ export async function runRemote(
notifications().expire?.("vcs.remote");
void executeAction("vcs.resolveConflicts");
} else
notifications().notify({
group: "vcs",
notify({
level: "error",
key: "vcs.remote",
title: `${doneTitle} failed`,
@@ -319,7 +320,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) {
notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", title: "All files are up-to-date" });
notify({ level: "info", key: "vcs.remote", title: "All files are up-to-date" });
return;
}
let files = 0;
@@ -344,8 +345,7 @@ async function notifyUpdated(updated: readonly UpdatedRange[]) {
commits += log.length;
}),
);
notifications().notify({
group: "vcs",
notify({
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) {
notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" });
notify({ level: "warning", title: "Another Git operation is in progress" });
return false;
}
await saveAll();
@@ -402,8 +402,7 @@ 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);
notifications().notify({
group: "vcs",
notify({
level: "info",
key: "vcs.operation",
title: `${progress}…`,
@@ -425,7 +424,7 @@ export async function runGitOperation(
}
logConsole("info", success);
const actions = opts.successActions ? await opts.successActions() : undefined;
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success, actions });
notify({ level: "info", key: "vcs.operation", title: success, actions });
return true;
} catch (e) {
const cancelled = isCancelled(e);
@@ -435,8 +434,7 @@ export async function runGitOperation(
await refreshNow();
const handled = cancelled || (opts.onError ? await opts.onError(message) : false);
if (!handled)
notifications().notify({
group: "vcs",
notify({
level: "error",
key: "vcs.operation",
title: opts.failure,
-2
View File
@@ -17,7 +17,6 @@ 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";
@@ -39,7 +38,6 @@ 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
@@ -1,172 +0,0 @@
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
@@ -1,39 +0,0 @@
/** 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);
};
}