Skip to content

Commit 11747f1

Browse files
authored
fix(ui): copy selection misaligns on wide (CJK) characters (#548)
1 parent 9296ef6 commit 11747f1

6 files changed

Lines changed: 461 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hunkdiff": patch
3+
---
4+
5+
Fix mouse-selection copy misalignment on lines with wide (CJK, emoji) characters: drag, double-click, and triple-click selections now convert terminal cell columns into string indices before slicing, so the copied text matches the selected cells exactly. File-header rows with wide-character filenames now copy with the same cell alignment, and invisible zero-width characters at a selection boundary round-trip through the clipboard.

src/ui/AppHost.selection.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { act } from "react";
55
import type { AppBootstrap } from "../core/types";
66
import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap";
77
import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers";
8+
import { measureTextWidth } from "./lib/text";
89

910
// These tests drive the DiffPane mouse-drag text-selection path end to end: begin/update/end
1011
// copy-selection, double/triple-click word and line expansion, and the OSC 52 clipboard copy.
@@ -42,6 +43,24 @@ function createSplitSelectionBootstrap(): AppBootstrap {
4243
return { ...bootstrap, initialMode: "split", input: { ...bootstrap.input } };
4344
}
4445

46+
/** Build a diff whose changed line mixes full-width (CJK) and ASCII characters. */
47+
function createWideCharSelectionBootstrap(): AppBootstrap {
48+
return createTestVcsAppBootstrap({
49+
changesetId: "changeset:copy-selection-cjk",
50+
files: [
51+
createTestDiffFile({
52+
id: "i18n",
53+
path: "i18n.ts",
54+
before: lines("export const message = 'hello'; // greeting"),
55+
after: lines("export const message = 'こんにちは'; // greeting"),
56+
context: 1,
57+
}),
58+
],
59+
initialMode: "stack",
60+
initialCopyDecorations: true,
61+
});
62+
}
63+
4564
type Harness = Awaited<ReturnType<typeof testRender>>;
4665

4766
/** Settle pending renders so a frame reflects the latest interaction. */
@@ -158,6 +177,43 @@ describe("DiffPane copy selection", () => {
158177
}
159178
});
160179

180+
test("dragging across wide (CJK) characters copies exactly the selected cells", async () => {
181+
const { setup, copied } = await renderSelectionApp(createWideCharSelectionBootstrap());
182+
183+
try {
184+
const frame = setup.captureCharFrame();
185+
const wide = locateText(frame, "こんにちは");
186+
expect(wide).not.toBeNull();
187+
188+
// Everything left of the code is single-cell glyphs, so the string index of the code
189+
// start is also its screen column; the drag end is then measured in terminal cells.
190+
const row = frame.split("\n")[wide!.y]!;
191+
const startX = row.indexOf("export const message");
192+
expect(startX).toBeGreaterThanOrEqual(0);
193+
const throughWide = "export const message = 'こんにちは";
194+
195+
await act(async () => {
196+
await setup.mockMouse.drag(
197+
startX,
198+
wide!.y,
199+
startX + measureTextWidth(throughWide) - 1,
200+
wide!.y,
201+
MouseButtons.LEFT,
202+
);
203+
});
204+
await flush(setup);
205+
206+
// Cell-aware slicing keeps the copy aligned with the drag: without it, each full-width
207+
// character shifted the endpoint and the clipboard over-included "'; //".
208+
expect(copied.length).toBeGreaterThan(0);
209+
expect(copied[copied.length - 1]).toBe(throughWide);
210+
} finally {
211+
await act(async () => {
212+
setup.renderer.destroy();
213+
});
214+
}
215+
});
216+
161217
test("double-clicking a token selects and copies the word under the pointer", async () => {
162218
const { setup, copied } = await renderSelectionApp(createSelectionBootstrap());
163219

src/ui/components/panes/copySelection.test.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
resolveStackCellGeometry,
2424
resolveSplitPaneWidths,
2525
} from "../../diff/codeColumns";
26+
import { measureTextWidth } from "../../lib/text";
2627

2728
const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07";
2829
const CSI_CLEAR_SCREEN = "\x1b[2J";
@@ -100,6 +101,36 @@ function createMaliciousDiffFile(): DiffFile {
100101
};
101102
}
102103

104+
function createCjkDiffFile(): DiffFile {
105+
const metadata = parseDiffFromFile(
106+
{
107+
name: "i18n.ts",
108+
contents: "export const message = 'hello'; // greeting\n",
109+
cacheKey: "cjk-before",
110+
},
111+
{
112+
name: "i18n.ts",
113+
contents: "export const message = 'こんにちは'; // greeting\n",
114+
cacheKey: "cjk-after",
115+
},
116+
{ context: 3 },
117+
true,
118+
);
119+
120+
return {
121+
id: "i18n",
122+
path: "i18n.ts",
123+
patch: "",
124+
language: "typescript",
125+
stats: {
126+
additions: 1,
127+
deletions: 1,
128+
},
129+
metadata,
130+
agent: null,
131+
};
132+
}
133+
103134
function buildContext(
104135
layout: "stack" | "split" = "stack",
105136
width = 120,
@@ -713,3 +744,241 @@ describe("renderCopySelectionText in split with side", () => {
713744
expect(text).toContain("export const answer = 42;");
714745
});
715746
});
747+
748+
describe("copy selection with wide (CJK) characters", () => {
749+
// "export const message = 'こんにちは" is 29 code units but 34 terminal cells: each full-width
750+
// character covers two cells, so cell columns and code-unit indices drift apart on this line.
751+
const cjkLine = "export const message = 'こんにちは'; // greeting";
752+
const throughWide = "export const message = 'こんにちは";
753+
754+
function buildCjkContext() {
755+
const { context, fileSectionLayouts, sectionGeometry } = buildContext(
756+
"stack",
757+
120,
758+
createCjkDiffFile(),
759+
);
760+
const section = fileSectionLayouts[0]!;
761+
const geometry = sectionGeometry[0]!;
762+
const { gutterWidth } = resolveStackCellGeometry(
763+
context.width,
764+
geometry.lineNumberDigits,
765+
context.showLineNumbers,
766+
DIFF_RAIL_PREFIX_WIDTH,
767+
);
768+
const codeStart = DIFF_RAIL_PREFIX_WIDTH + gutterWidth;
769+
const codeOnlyContext: CopySelectionContext = { ...context, copyDecorations: false };
770+
771+
// Locate the CJK addition row through full-width row copies so the tests do not
772+
// hard-code the planned-row layout.
773+
let visualRow = -1;
774+
for (let row = section.bodyTop; row < section.bodyTop + section.bodyHeight; row += 1) {
775+
const text = renderCopySelectionText({
776+
context: codeOnlyContext,
777+
start: { kind: "review-row", column: 0, visualRow: row },
778+
end: { kind: "review-row", column: context.width - 1, visualRow: row },
779+
});
780+
if (text.includes("こんにちは")) {
781+
visualRow = row;
782+
break;
783+
}
784+
}
785+
expect(visualRow).toBeGreaterThanOrEqual(0);
786+
787+
return { context, codeOnlyContext, codeStart, visualRow };
788+
}
789+
790+
test("code-only selection ending after the wide run copies exactly the selected cells", () => {
791+
const { codeOnlyContext, codeStart, visualRow } = buildCjkContext();
792+
793+
const text = renderCopySelectionText({
794+
context: codeOnlyContext,
795+
start: { kind: "review-row", column: codeStart, visualRow },
796+
end: {
797+
kind: "review-row",
798+
column: codeStart + measureTextWidth(throughWide) - 1,
799+
visualRow,
800+
},
801+
});
802+
803+
expect(text).toBe(throughWide);
804+
});
805+
806+
test("code-only selection starting after the wide run copies exactly the selected cells", () => {
807+
const { codeOnlyContext, codeStart, visualRow } = buildCjkContext();
808+
809+
const text = renderCopySelectionText({
810+
context: codeOnlyContext,
811+
start: {
812+
kind: "review-row",
813+
column: codeStart + measureTextWidth(throughWide),
814+
visualRow,
815+
},
816+
end: { kind: "review-row", column: codeOnlyContext.width - 1, visualRow },
817+
});
818+
819+
expect(text).toBe("'; // greeting");
820+
});
821+
822+
test("decorated selection ending after the wide run copies exactly the selected cells", () => {
823+
const { context, codeStart, visualRow } = buildCjkContext();
824+
825+
const text = renderCopySelectionText({
826+
context,
827+
start: { kind: "review-row", column: codeStart, visualRow },
828+
end: {
829+
kind: "review-row",
830+
column: codeStart + measureTextWidth(throughWide) - 1,
831+
visualRow,
832+
},
833+
});
834+
835+
expect(text).toBe(throughWide);
836+
});
837+
838+
test("double-click on a word after the wide run selects the word measured in cells", () => {
839+
const { context, codeStart, visualRow } = buildCjkContext();
840+
const wordStartCell = measureTextWidth("export const message = 'こんにちは'; // ");
841+
const point: CopySelectionPoint = {
842+
kind: "review-row",
843+
// Click inside "greeting".
844+
column: codeStart + wordStartCell + 2,
845+
visualRow,
846+
};
847+
848+
const result = expandSelectionPoint(point, 2, context);
849+
850+
expect(result).toEqual({
851+
startCol: codeStart + wordStartCell,
852+
endCol: codeStart + wordStartCell + "greeting".length - 1,
853+
});
854+
});
855+
856+
test("double-click on a wide character selects both of its terminal cells", () => {
857+
const { context, codeStart, visualRow } = buildCjkContext();
858+
// "ん" covers two cells; clicking the second cell must still select the whole character.
859+
const wideCharStartCell = measureTextWidth("export const message = 'こ");
860+
const point: CopySelectionPoint = {
861+
kind: "review-row",
862+
column: codeStart + wideCharStartCell + 1,
863+
visualRow,
864+
};
865+
866+
const result = expandSelectionPoint(point, 2, context);
867+
868+
expect(result).toEqual({
869+
startCol: codeStart + wideCharStartCell,
870+
endCol: codeStart + wideCharStartCell + 1,
871+
});
872+
});
873+
874+
test("code-only triple-click covers the full cell width of the line", () => {
875+
const { codeOnlyContext, codeStart, visualRow } = buildCjkContext();
876+
const point: CopySelectionPoint = {
877+
kind: "review-row",
878+
column: codeStart + 2,
879+
visualRow,
880+
};
881+
882+
const result = expandSelectionPoint(point, 3, codeOnlyContext);
883+
884+
expect(result).toEqual({
885+
startCol: codeStart,
886+
endCol: codeStart + measureTextWidth(cjkLine) - 1,
887+
});
888+
});
889+
890+
test("pinned-header selection stays cell-aligned for wide-character filenames", () => {
891+
const metadata = parseDiffFromFile(
892+
{ name: "日本語.ts", contents: "export const a = 1;\n", cacheKey: "cjk-path-before" },
893+
{ name: "日本語.ts", contents: "export const a = 2;\n", cacheKey: "cjk-path-after" },
894+
{ context: 3 },
895+
true,
896+
);
897+
const file: DiffFile = {
898+
id: "cjk-path",
899+
path: "日本語.ts",
900+
patch: "",
901+
language: "typescript",
902+
stats: { additions: 1, deletions: 1 },
903+
metadata,
904+
agent: null,
905+
};
906+
const { context, fileSectionLayouts } = buildContext("stack", 120, file);
907+
const nextVisualRow = fileSectionLayouts[0]!.bodyTop;
908+
909+
const headerCells = (startColumn: number, endColumn: number) =>
910+
renderCopySelectionText({
911+
context,
912+
start: { kind: "pinned-header", column: startColumn, fileId: "cjk-path", nextVisualRow },
913+
end: { kind: "pinned-header", column: endColumn, fileId: "cjk-path", nextVisualRow },
914+
});
915+
916+
// The label starts after one padding cell: "日本語.ts" spans cells 1..9.
917+
expect(headerCells(1, 9)).toBe("日本語.ts");
918+
919+
// DiffFileHeaderRow right-aligns the stats, so "-1" ends at cell width - 3 (one trailing
920+
// stats space plus one padding cell). A code-unit gap would shift these cells onto "+1".
921+
expect(headerCells(context.width - 4, context.width - 3)).toBe("-1");
922+
});
923+
});
924+
925+
describe("copy selection with zero-width characters", () => {
926+
test("selection starting at a zero-width boundary keeps the invisible character", () => {
927+
// A mid-line U+200B survives rendering (only leading zero-width clusters are dropped by
928+
// sliceTextByWidth), so copying must round-trip it for the invisible bug to stay visible
929+
// in the pasted text.
930+
const metadata = parseDiffFromFile(
931+
{ name: "zero.ts", contents: "const x = 1;\n", cacheKey: "zero-before" },
932+
{
933+
name: "zero.ts",
934+
contents: "const x = 1;\nconst zw = 'a\u200bb';\n",
935+
cacheKey: "zero-after",
936+
},
937+
{ context: 3 },
938+
true,
939+
);
940+
const file: DiffFile = {
941+
id: "zero",
942+
path: "zero.ts",
943+
patch: "",
944+
language: "typescript",
945+
stats: { additions: 1, deletions: 0 },
946+
metadata,
947+
agent: null,
948+
};
949+
const { context, fileSectionLayouts, sectionGeometry } = buildContext("stack", 120, file);
950+
const section = fileSectionLayouts[0]!;
951+
const geometry = sectionGeometry[0]!;
952+
const { gutterWidth } = resolveStackCellGeometry(
953+
context.width,
954+
geometry.lineNumberDigits,
955+
context.showLineNumbers,
956+
DIFF_RAIL_PREFIX_WIDTH,
957+
);
958+
const codeStart = DIFF_RAIL_PREFIX_WIDTH + gutterWidth;
959+
const codeOnlyContext: CopySelectionContext = { ...context, copyDecorations: false };
960+
961+
let visualRow = -1;
962+
for (let row = section.bodyTop; row < section.bodyTop + section.bodyHeight; row += 1) {
963+
const text = renderCopySelectionText({
964+
context: codeOnlyContext,
965+
start: { kind: "review-row", column: 0, visualRow: row },
966+
end: { kind: "review-row", column: context.width - 1, visualRow: row },
967+
});
968+
if (text.includes("zw")) {
969+
visualRow = row;
970+
break;
971+
}
972+
}
973+
expect(visualRow).toBeGreaterThanOrEqual(0);
974+
975+
// "const zw = 'a" is 13 cells; the zero-width character sits at that boundary before "b".
976+
const text = renderCopySelectionText({
977+
context: codeOnlyContext,
978+
start: { kind: "review-row", column: codeStart + 13, visualRow },
979+
end: { kind: "review-row", column: codeOnlyContext.width - 1, visualRow },
980+
});
981+
982+
expect(text).toBe("\u200bb';");
983+
});
984+
});

0 commit comments

Comments
 (0)