@@ -23,6 +23,7 @@ import {
2323 resolveStackCellGeometry ,
2424 resolveSplitPaneWidths ,
2525} from "../../diff/codeColumns" ;
26+ import { measureTextWidth } from "../../lib/text" ;
2627
2728const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07" ;
2829const 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+
103134function 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