Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Every break is a mark that a click cycles keep → space → none; a scope or fo
- **Never show while its Wall travels**, its pane hidden, or another pane zoomed over it.
- **Never take focus**; only an actual scrollbar press keeps its default (rationale). Presses inside it count as inside its pane (`anchoredTarget`); its `mousedown` and `contextmenu` never reach the pane.
- **Esc**, a click outside the editor, a content change or a resize it cannot follow (§3.4), a confirmed copy, or **any input the terminal receives** — typing, a paste, Pocket's input bar — dismisses it and cancels the selection. Source of truth: `writeUserInput` in `lib/src/lib/terminal-lifecycle.ts`, pinned by `lib/src/lib/terminal-lifecycle.selection.test.ts`.
- **Must confirm only after a successful clipboard write, and only for the selection copied**; the selection then clears, however it moved meanwhile (rationale).
- **Must confirm only after a successful clipboard write, and only for the selection copied**; the selection then clears, even if its edges moved or reflow ran while the write was pending (rationale).
- **Must leave empty copies idle without writing.** **Must say a failed write failed**, keeping the selection for a retry.
- **Must write through the adapter's native `writeClipboardText` where it has one, and never fall back from it** (rationale). **Pocket's adapter must have none**: its copies belong on the phone. Without one, the write takes the Clipboard API, and `execCommand('copy')` where that is missing or refuses (rationale).
- **Must open the clipboard-failure report when a write fails every way it may take**, unless its caller opts out. **Its details never carry the copied text**; they go to the tracking issue the report links.
Expand Down
11 changes: 11 additions & 0 deletions lib/src/lib/copy-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ describe('copySelection', () => {
}
});

it.each([true, false])('reports a pending copy after nudging the same selection (copied: %s)', async copied => {
let complete!: (result: boolean) => void;
vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise(resolve => { complete = resolve; }));
select({ endRow: 2, endCol: 11 });
const pending = copySelection(ID);
nudgeCopyEdge(ID, 'end', 1);
complete(copied);
await pending;
expect(getMouseSelectionState(ID)).toMatchObject({ selection: { endCol: 16 }, copyOutcome: copied ? 'copied' : 'failed' });
});

it('says a failed write failed, for that selection', async () => {
vi.mocked(writeTextToClipboard).mockResolvedValue(false);
select({});
Expand Down
4 changes: 2 additions & 2 deletions lib/src/lib/copy-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { COPY_FLASH_MS, failCopy, flashCopy, getMouseSelectionState, TOUCH_COPY_
* says so instead, for that selection, and keeps it.
*/
export async function copySelection(terminalId: string, { touch = false }: { touch?: boolean } = {}): Promise<void> {
const { selection, copyEditor, programCopy } = getMouseSelectionState(terminalId);
const { selection, selectionIdentity, copyEditor, programCopy } = getMouseSelectionState(terminalId);
if (!selection || !copyEditor) return;
const text = editorRendering(copyEditor, programCopy).text;
if (!text) return;
const copied = await writeTextToClipboard(text);
if (getMouseSelectionState(terminalId).selection !== selection) return;
if (getMouseSelectionState(terminalId).selectionIdentity !== selectionIdentity) return;
if (!copied) {
failCopy(terminalId);
return;
Expand Down
1 change: 1 addition & 0 deletions lib/src/lib/mouse-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('mouse-selection: default state', () => {
bracketedPaste: false,
override: 'off',
selection: null,
selectionIdentity: null,
hintToken: null,
copyEditor: null,
programCopy: null,
Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/mouse-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface MouseSelectionState {
bracketedPaste: boolean;
override: OverrideState;
selection: Selection | null;
/** Stable through edge movement and reflow; cleared with the selection. */
selectionIdentity: object | null;
/** The token under a drag's head, in absolute buffer rows. */
hintToken: BufferToken | null;
/** Open while non-null: always the editor written with this exact
Expand All @@ -84,6 +86,7 @@ export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze(
bracketedPaste: false,
override: 'off',
selection: null,
selectionIdentity: null,
hintToken: null,
copyEditor: null,
programCopy: null,
Expand All @@ -98,6 +101,7 @@ let cachedSnapshot: Map<string, MouseSelectionState> | null = null;
* one cannot leave another's editor, offer, or flash behind. */
function clearSelection(s: MouseSelectionState): void {
s.selection = null;
s.selectionIdentity = null;
s.copyEditor = null;
s.programCopy = null;
s.copyOutcome = null;
Expand Down Expand Up @@ -182,6 +186,7 @@ export function setSelection(id: string, selection: Selection | null, copyEditor
else {
// A replaced selection keeps an in-flight flash and the drag's hint, but
// not the program's offer, so its editor cannot show that offer either.
s.selectionIdentity ??= {};
s.selection = selection;
s.programCopy = null;
s.copyEditor = selection.dragging || !copyEditor ? null
Expand Down Expand Up @@ -221,6 +226,7 @@ export function beginDrag(
// Clearing the in-flight copy flash too keeps its timer from clearing this
// new selection when it fires.
clearSelection(s);
s.selectionIdentity = {};
s.selection = {
startRow: args.row,
startCol: args.col,
Expand Down
23 changes: 23 additions & 0 deletions lib/src/lib/terminal-lifecycle.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ vi.mock('./platform', async () => {
return { ...actual, getPlatform: () => fakePlatform };
});

vi.mock('./clipboard', () => ({ writeTextToClipboard: vi.fn() }));
import { writeTextToClipboard } from './clipboard';
import { copySelection } from './copy-selection';

import { flipCopyBreak, openCopyEditor, setCopyFormat, setCopyScope } from './copy-editor';
import type { EditorFormat } from './copy-text';
import { finalizedSelection, WRAPPING } from './copy-text-fixtures';
Expand Down Expand Up @@ -130,6 +134,25 @@ describe('a terminal resize', () => {
}
});

it.each([true, false])('keeps a pending clipboard result through reflow (copied: %s)', async copied => {
const id = 'resize-copy-' + copied;
const terminal = await openEdited(id);
let complete!: (result: boolean) => void;
vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise(resolve => { complete = resolve; }));
vi.useFakeTimers();
try {
const pending = copySelection(id);
terminal.resize(10, 10);
complete(copied);
await pending;
expect(getMouseSelectionState(id)).toMatchObject({ selection: { endRow: 2, endCol: 1 }, copyOutcome: copied ? 'copied' : 'failed' });
vi.advanceTimersByTime(1500);
expect(getMouseSelectionState(id).selection === null).toBe(copied);
} finally {
vi.useRealTimers();
}
});

it('still cancels a block selection, and one the program owns', async () => {
for (const [id, over] of [['resize-5', { shape: 'block' }], ['resize-6', { owner: 'program' }]] as const) {
const terminal = await openEdited(id, { over });
Expand Down
Loading