mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Keep terminal CJK text unchanged after returning to a workspace (#6452)
* fix(app): restore terminal surface after tab hide so CJK cells redraw Retained panels use display:none. Coming back remasured without treating that as a surface restore, so Hangul kept 2-cell slots while glyphs painted at ~1 cell (extra gap). Reload WebGL if the context was lost and force a refresh when the host grows from zero. Closes #3434 * fix(server): keep wide-char continuation cells out of terminal snapshots Wide chars (CJK) occupy two xterm cells, and the second cell reports no chars. The snapshot capture collapsed that empty continuation to " ", so every restored Hangul syllable took three columns instead of two. The injected space was real: selecting and copying the restored row carried it into the clipboard. Capture the continuation as "" and have renderTerminalSnapshotToAnsi skip it. No schema change; snapshots from older daemons still replay verbatim. * fix(app): restore retained terminals on presentation Co-authored-by: seob717 <dev.seob717@gmail.com> * fix(terminal): limit restoration changes to confirmed CJK corruption Keep the independently reproduced snapshot fix. Surface recovery behavior remains unverified and belongs with the broader viewport report. Dispose the replay terminal after the regression test. --------- Co-authored-by: Jason <mildreins@gmail.com> Co-authored-by: seob717 <dev.seob717@gmail.com> Co-authored-by: liujin0506 <liujin0506@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
seob717
Jason
liujin0506
parent
b63a325be3
commit
e81d5d02b1
@@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => {
|
||||
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTerminalSnapshotToAnsi wide chars", () => {
|
||||
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
|
||||
// The daemon captures "통" as the glyph followed by an empty continuation
|
||||
// cell. Emitting anything for that cell pushes the rest of the row right.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
|
||||
cursor: { row: 0, col: 5 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
|
||||
});
|
||||
|
||||
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
|
||||
// Old daemons already collapsed the continuation to " ". The serializer
|
||||
// cannot tell that apart from a real space, so it must not guess.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [cells("통 합 !")],
|
||||
cursor: { row: 0, col: 6 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const cell = row[index] ?? { char: " " };
|
||||
// An empty char marks a wide-char continuation cell. The glyph in the
|
||||
// previous cell already advances the cursor two columns, so emitting
|
||||
// anything here would shift the rest of the row one column right.
|
||||
if (cell.char === "") {
|
||||
continue;
|
||||
}
|
||||
const nextStyle = getTerminalStyle(cell);
|
||||
if (!terminalStylesEqual(previousStyle, nextStyle)) {
|
||||
output.push(styleToAnsi(nextStyle));
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as pty from "node-pty";
|
||||
import xterm from "@xterm/headless";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
|
||||
@@ -50,6 +52,10 @@ function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||
return state.grid.map(rowToText);
|
||||
}
|
||||
|
||||
function rowHasChar(row: TerminalRow, char: string): boolean {
|
||||
return row.some((cell) => cell.char === char);
|
||||
}
|
||||
|
||||
// Wait for terminal state to match expected lines
|
||||
async function waitForLines(
|
||||
session: TerminalSession,
|
||||
@@ -523,6 +529,44 @@ describe("createTerminal", () => {
|
||||
expect(withoutFlags.scrollbackWrapped).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => {
|
||||
// Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair
|
||||
// whose second cell has no chars. Capturing that continuation as " " made
|
||||
// every restored syllable three columns wide (paseo#3434).
|
||||
const text = "통합 구현은 전체 테스트를 통과했습니다";
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
workspaceId: "ws-test",
|
||||
cwd: realpathSync(tmpdir()),
|
||||
cols: 80,
|
||||
rows: 10,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다"));
|
||||
|
||||
// Replay the snapshot the way the client does on tab switch and read the
|
||||
// row back the way a selection copy would.
|
||||
const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true });
|
||||
try {
|
||||
await new Promise<void>((resolve) =>
|
||||
restored.write(renderTerminalSnapshotToAnsi(state), resolve),
|
||||
);
|
||||
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text);
|
||||
} finally {
|
||||
restored.dispose();
|
||||
}
|
||||
|
||||
// The continuation cell must stay empty so the restore path can skip it.
|
||||
expect(state.grid[0][0].char).toBe("통");
|
||||
expect(state.grid[0][1].char).toBe("");
|
||||
});
|
||||
|
||||
it("captures exit diagnostics from the terminal buffer", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
|
||||
@@ -580,7 +580,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
|
||||
return {
|
||||
char: cell.getChars() || " ",
|
||||
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
|
||||
// continuation. Collapsing it to " " injects a real space on restore.
|
||||
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
@@ -639,7 +641,9 @@ function extractScrollback(
|
||||
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
rowCells.push({
|
||||
char: cell.getChars() || " ",
|
||||
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
|
||||
// continuation. Collapsing it to " " injects a real space on restore.
|
||||
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
|
||||
Reference in New Issue
Block a user