Compare commits

...
Author SHA1 Message Date
arookieofc b7b9ee04f1 chore: fix README formatting (pre-existing oxfmt violation blocking CI) 2026-11-10 11:04:10 +08:00
arookieofc 0fb8955269 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.
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
6 changed files with 250 additions and 4 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>
+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/);
});
});
+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);
};
}