refactor(split): a tile's Ctrl+C copies through the primary pane's copy helpers

TerminalTile carried its own copy of the smart-copy branch (clean the
selection with this session's gutter, copy, clear, toast). PR 1 gave
cleanedTerminalSelection and copyTerminalSelection a `{ terminal, sessionId }`
target for exactly this, and nothing passed it. The tile now calls both with
its own terminal and session, and its copy code is gone.

Two things change for a tile, both to the primary pane's rule: a clipboard
write that fails keeps the selection (nothing was copied, so it stays for a
retry) instead of clearing it, and focus returns to the tile's xterm after
the copy, which matters when the execCommand fallback focused a temporary
textarea. Pinned in terminal-tile-input with the write failing and
succeeding, and the no-selection Ctrl+C / Ctrl+Shift+C split.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 09:37:03 +02:00
parent ec5e4aa3e1
commit b965c3d346
2 changed files with 91 additions and 43 deletions
+73
View File
@@ -125,6 +125,18 @@ class FakeTerminal {
type(data: string) {
this.dataCb?.(data);
}
/** What a drag selected; '' is no selection. */
selection = '';
hasSelection() {
return this.selection !== '';
}
getSelection() {
return this.selection;
}
clearSelection = vi.fn(() => {
this.selection = '';
});
focus = vi.fn();
}
const fetchMock = vi.fn();
@@ -695,6 +707,67 @@ describe('TerminalTile links and paste follow THIS pane', () => {
});
});
describe("TerminalTile Ctrl+C copies through the primary pane's copy helpers", () => {
const ctrlC = (extra: Record<string, unknown> = {}) => ({
type: 'keydown',
key: 'c',
code: 'KeyC',
ctrlKey: true,
preventDefault: vi.fn(),
...extra,
});
it("copies THIS pane's selection; a failed write keeps it and focus returns to this pane", async () => {
const app = makeApp();
const copyText = vi.fn(async () => false);
app._copyText = copyText;
const { term } = await connectTile(app);
term.selection = 'npm run build';
const ev = ctrlC();
expect(term.keyHandler!(ev)).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(ev.preventDefault).toHaveBeenCalled();
expect(copyText).toHaveBeenCalledWith('npm run build');
expect(app.showToast).toHaveBeenCalledWith('Failed to copy', 'error');
// As in the primary pane: nothing was copied, so the selection stays for a retry.
expect(term.clearSelection).not.toHaveBeenCalled();
expect(term.selection).toBe('npm run build');
// The execCommand fallback focuses a temporary textarea; the keyboard comes back here.
expect(term.focus).toHaveBeenCalled();
});
it('a successful write clears the selection (a second Ctrl+C interrupts) and refocuses this pane', async () => {
const app = makeApp();
app._copyText = vi.fn(async () => true);
const { term } = await connectTile(app);
term.selection = 'npm run build';
expect(term.keyHandler!(ctrlC())).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(app.showToast).toHaveBeenCalledWith('Copied to clipboard', 'success');
expect(term.clearSelection).toHaveBeenCalled();
expect(term.focus).toHaveBeenCalled();
});
it('with nothing selected, Ctrl+C reaches the PTY and Ctrl+Shift+C does not', async () => {
const app = makeApp();
const copyText = vi.fn(async () => true);
app._copyText = copyText;
const { term } = await connectTile(app);
const plain = ctrlC();
expect(term.keyHandler!(plain)).toBe(true);
expect(plain.preventDefault).not.toHaveBeenCalled();
const shifted = ctrlC({ key: 'C', shiftKey: true });
expect(term.keyHandler!(shifted)).toBe(false);
expect(shifted.preventDefault).toHaveBeenCalled();
expect(copyText).not.toHaveBeenCalled();
});
});
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
const app = makeApp();