From 9a3cb242e31ec3e077f17aec53da437ac451d920 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 12 May 2026 22:15:29 -0400 Subject: [PATCH 1/6] [issues/547] Single clipboard write per R-* operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Architectural refactoring that gives ClipboardRouter sole ownership of the clipboard write. Insert factories now only execute paste commands — never touch the clipboard. Eliminates double clipboard writes, nested ClipboardPreservers, and double padding risk. No user-visible behavior changes. ## Changes - ClipboardRouter owns the sole clipboard write per operation; insert factories (AIAssistantInsertFactory, TerminalInsertFactory) only execute paste commands — their constructors drop from 4→2 and 3→2 params respectively - Two-delay model replaces the old single delay: FOCUS_TO_PASTE_DELAY_MS (pre-paste, 200ms) for focus readiness, CLIPBOARD_POST_PASTE_DELAY_MS (post-paste, 200ms) for webview async clipboard read across Electron IPC boundary - Padding pre-applied at all 4 call sites (LinkGenerator, TextSelectionPaster, FilePathPaster, TerminalSelectionService) — performPaste() removed from PasteDestinationManager - chatPasteConstants.ts renamed to pasteTimingConstants.ts; multi-command paste array replaced with single editor.action.clipboardPasteAction - focusAndPasteCommands → focusCommands in BuiltinAiAssistantDef type (backward-compatible: CustomAiAssistantConfig.focusAndPasteCommands preserved) - New ADR-0003 with MermaidJS sequence diagram documenting single clipboard write, two-delay model, and webview race ## QA & Tests - 15 new QA test cases: built-in AI assistant binding/sending (claude-code-001–005, cursor-ai-001–004, github-copilot-chat-001–004), clipboard-preservation-010, ubuntu-ctrl-keybindings-001 - New builtInAiAssistants.test.ts [assisted] integration tests for cold/warm paste verification across Claude Code, Cursor, and Copilot Chat - New platformKeybindings.test.ts integration test for Ubuntu Ctrl keybinding parity - QA YAML precondition cleanup: removed redundant "Extension installed from .vsix build" lines; added labels (requires-extensions, clipboard, ubuntu, cursor) to relevant TCs - Removed logBasedUiValidation.test.ts — its assertions now live in individual suite tests - Test infrastructure: new standardSuite.ts helper, settingsHelpers improvements - Coverage maintained at 98.2% statements, 95.2% branches ## Test Plan - [x] All existing tests pass (pnpm test — 98.2%/95.2%/95.8%/98.3% coverage) - [x] New tests added for: AIAssistantInsertFactory simplified constructor, FocusCapabilityFactory, ComposablePasteDestination paste flow, VscodeAdapter paste methods, padding call sites, dest build pipeline - [x] pnpm test:release (VS Code host integration tests) — standard suite passes Closes https://github.com/couimet/rangeLink/issues/547 --- docs/ADR/0003-single-clipboard-write.md | 127 ++++++++++ docs/ADR/README.md | 1 + .../qa/qa-test-cases-v1.1.0.yaml | 226 +++++++++++++++++- .../suite/builtInAiAssistants.test.ts | 140 +++++++++++ .../suite/customAiAssistants.test.ts | 2 +- .../suite/sendFilePath.test.ts | 18 +- ...osablePasteDestination.integration.test.ts | 150 +++--------- .../ComposablePasteDestination.test.ts | 86 ++----- .../destinations/DestinationRegistry.test.ts | 26 +- .../PasteDestinationManager.test.ts | 106 ++++---- .../FocusCapabilityFactory.test.ts | 15 +- .../aiAssistantInsertFactory.test.ts | 158 +----------- .../manualPasteInsertFactory.test.ts | 46 +--- .../terminalInsertFactory.test.ts | 58 ++--- .../helpers/destinationTestHelpers.ts | 13 +- .../ide/vscode/VscodeAdapter.test.ts | 94 ++------ .../src/bookmarks/BookmarkService.ts | 7 - .../__tests__/BookmarkService.test.ts | 1 - .../src/constants/chatPasteConstants.ts | 25 -- .../src/constants/index.ts | 2 +- .../src/constants/pasteTimingConstants.ts | 20 ++ .../src/createWiringServices.ts | 2 +- .../ComposablePasteDestination.ts | 32 ++- .../src/destinations/PasteDestination.ts | 20 +- .../destinations/PasteDestinationManager.ts | 43 ++-- .../capabilities/FocusCapabilityFactory.ts | 34 +-- .../aiAssistantInsertFactory.ts | 46 +--- .../manualPasteInsertFactory.ts | 27 +-- .../insertFactories/terminalInsertFactory.ts | 36 ++- .../src/destinations/destinationBuilders.ts | 18 +- .../src/ide/vscode/VscodeAdapter.ts | 72 +++--- .../src/services/ClipboardRouter.ts | 2 +- .../src/services/FilePathPaster.ts | 10 +- .../src/services/LinkGenerator.ts | 10 +- .../src/services/TerminalSelectionService.ts | 10 +- .../src/services/TextSelectionPaster.ts | 10 +- 36 files changed, 868 insertions(+), 825 deletions(-) create mode 100644 docs/ADR/0003-single-clipboard-write.md create mode 100644 packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts delete mode 100644 packages/rangelink-vscode-extension/src/constants/chatPasteConstants.ts create mode 100644 packages/rangelink-vscode-extension/src/constants/pasteTimingConstants.ts diff --git a/docs/ADR/0003-single-clipboard-write.md b/docs/ADR/0003-single-clipboard-write.md new file mode 100644 index 000000000..6ce89a64f --- /dev/null +++ b/docs/ADR/0003-single-clipboard-write.md @@ -0,0 +1,127 @@ +# ADR-0003: Single Clipboard Write per Operation + +- **Status:** Accepted +- **Date:** 2026-05-11 +- **Deciders:** @couimet + +## Context + +RangeLink operations (R-L, R-V, R-F) involve writing link text to the clipboard and then pasting it at the destination (terminal, AI assistant panel, text editor). Before this refactoring, two separate `ClipboardPreserver` wrappers ran per operation — an outer one in `ClipboardRouter.executeCopyAndSend()` and an inner one inside the individual `InsertFactory.forTarget()` implementations. This caused: + +- **Double clipboard writes.** Both `ClipboardRouter` and the insert factory wrote text to the clipboard. The second write could race with VS Code's paste command in webview-based destinations, where the paste reads from clipboard asynchronously across an Electron IPC boundary. +- **Nested ClipboardPreservers.** Each `ClipboardPreserver` called `vscode.env.clipboard.writeText()` to save and restore the user's prior clipboard. Two nestings meant four clipboard API calls per operation (save + write + restore + save + write + restore). +- **Double padding.** `applySmartPadding()` ran in both `PasteDestinationManager.performPaste()` and the individual call sites, making it impossible to reason about whether a given code path had padding applied once, twice, or not at all. +- **Multi-command paste fallback.** `ManualPasteInsertFactory.forTarget()` tried multiple paste commands (`editor.action.clipboardPasteAction`, `cursorAi.paste`, etc.) in sequence. The fallback logic was only needed because the clipboard write and paste command could desynchronize under the nested-preserver model. +- **Custom AI assistant paste commands in Tier 2.** `focusAndPasteCommands` included both focus AND paste command arrays, but the paste commands (`editor.action.clipboardPasteAction`) were always the same for built-in assistants. The differentiating factor was whether the destination used auto-paste or manual paste — not which paste commands to try. + +The refactoring gives `ClipboardRouter` sole ownership of the clipboard write. `InsertFactory` implementations only execute paste commands (never write to clipboard). This eliminates every problem above: single write, single preserve, single padding, single paste command. + +## Decision + +### 1. ClipboardRouter owns the sole clipboard write + +`ClipboardRouter.executeCopyAndSend()` performs one `vscode.env.clipboard.writeText()` wrapped in a single `ClipboardPreserver`. The text is pre-padded by the caller. After the clipboard write succeeds, the router delegates to `PasteDestinationManager.sendLinkToDestination()` which focuses the destination and calls the insert factory — no clipboard access at all. + +### 2. Insert factories execute paste, never write clipboard + +| Factory | Before | After | +| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------ | +| `AIAssistantInsertFactory` | Wrote clipboard, executed paste commands | Calls `pasteTextFromClipboard()` only | +| `TerminalInsertFactory` | Wrote clipboard, called `pasteIntoTerminal(terminal, text)` | Calls `pasteIntoTerminal(terminal)` only | +| `EditorInsertFactory` | Called `insertTextAtCursor(editor, text)` | Unchanged (editor insert is direct, not via clipboard) | + +`_text` is accepted but ignored by AI assistant and terminal insert factories since the text is already on the clipboard. + +### 3. Two-delay model + +``` +┌────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Focus cmd │────▶│ FOCUS_DELAY │────▶│ Clipboard Paste │ +│ executed │ │ (pre-paste) │ │ Command │ +└────────────┘ └──────────────┘ └────────┬────────┘ + │ + ┌────────────▼────────────┐ + │ CLIPBOARD_POST_PASTE │ + │ _DELAY (post-paste) │ + └─────────────────────────┘ +``` + +- **Pre-paste (`FOCUS_TO_PASTE_DELAY_MS = 200`):** Applied in `AIAssistantFocusCapability.focus()` after a successful focus command executes, giving the target panel time to load its webview and become ready for clipboard paste. Only applies when focus actually runs — skipped on "warm" sends where the panel is already visible. +- **Post-paste (`CLIPBOARD_POST_PASTE_DELAY_MS = 200`):** Applied inside `VscodeAdapter.pasteTextFromClipboard()` after `editor.action.clipboardPasteAction` succeeds. Gives webview-based assistants (Claude Code, Copilot Chat) time to read from the async clipboard across the Electron IPC boundary before the outer `ClipboardPreserver` restores the user's prior clipboard. + +### 4. Padding pre-applied at call sites + +Four call sites apply `applySmartPadding()` to both `content.clipboard` and `content.send` before passing to `ClipboardRouter.executeCopyAndSend()`: `LinkGenerator`, `TextSelectionPaster`, `FilePathPaster`, `TerminalSelectionService`. `PasteDestinationManager.performPaste()` is removed entirely — padding is a presentation concern, not a paste concern. + +### 5. `focusAndPasteCommands` renamed to `focusCommands` + +The `BuiltinAiAssistantDef` type uses `focusCommands` instead of `focusAndPasteCommands` to reflect that paste commands are no longer configurable per assistant. The paste command is always `editor.action.clipboardPasteAction`. The user-facing `CustomAiAssistantConfig.focusAndPasteCommands` is preserved for backward compatibility — it maps to the same behavior but under the simplified internal interface. + +### 6. Sequence diagram + +```mermaid +sequenceDiagram + participant User + participant Service + participant ClipboardRouter + participant ClipboardPreserver + participant VSCodeClipboard as vscode.env.clipboard + participant DestinationMgr + participant FocusCap + participant InsertFactory + participant VSCodePaste as vscode.commands + + User->>Service: R-L (Cmd+R Cmd+L) + Service->>Service: applySmartPadding(text) + Service->>ClipboardRouter: executeCopyAndSend(paddedText, destination) + ClipboardRouter->>ClipboardPreserver: save() + ClipboardPreserver->>VSCodeClipboard: readText() → saved + ClipboardRouter->>VSCodeClipboard: writeText(paddedText) + ClipboardRouter->>DestinationMgr: sendLinkToDestination(formattedLink) + DestinationMgr->>FocusCap: focus() + FocusCap->>VSCodePaste: executeCommand(focusCmd) + Note over FocusCap: FOCUS_TO_PASTE_DELAY_MS (200ms) + FocusCap-->>DestinationMgr: FocusResult + DestinationMgr->>InsertFactory: inserter() + InsertFactory->>VSCodePaste: executeCommand('editor.action.clipboardPasteAction') + Note over InsertFactory: CLIPBOARD_POST_PASTE_DELAY_MS (200ms) + InsertFactory-->>DestinationMgr: success + DestinationMgr-->>ClipboardRouter: success + ClipboardRouter->>ClipboardPreserver: restore() + ClipboardPreserver->>VSCodeClipboard: writeText(saved) + ClipboardRouter-->>Service: result +``` + +**Confidence:** + +- Single clipboard write: HIGH — validated with full integration test suite +- Two-delay model: MEDIUM — delay values (200ms) are empirical, tuned against VS Code on macOS; may need adjustment for other platforms or slower machines +- Padding pre-application: HIGH — verified at all 4 call sites via unit tests +- Tier 2 rename: HIGH — backward-compatible with `CustomAiAssistantConfig.focusAndPasteCommands` preserved + +## Alternatives Considered + +- **Add `marginMs` to `ClipboardPreserver` instead of inline delays** — rejected: the post-paste delay is specific to webview clipboard reading, not restoration timing. Adding a general margin to `ClipboardPreserver` would delay all destinations, including terminals that don't need it. +- **Poll for clipboard readiness** — rejected: no API exists to check "is the clipboard content readable by the webview?"; polling `vscode.env.clipboard.readText()` would just return our own write. The delay is a pragmatic worst-case budget. +- **Keep inner ClipboardPreserver for Tier 3 (manual paste)** — rejected: `ClipboardRouter` already wraps everything. Tier 3 destinations skip restoration via `isClipboardRestorationApplicable(pasteSucceeded: false)`. + +## Consequences + +### Positive + +- **Predictable clipboard lifecycle.** One write, one restore. No nested saves that could restore the wrong content. +- **No double-padding risk.** Padding is applied exactly once at the call site, before any clipboard or send operation. +- **Simpler insert factories.** `AIAssistantInsertFactory` constructor drops from 4 params to 2. `TerminalInsertFactory` drops from 3 to 2. +- **Simpler testing.** Test assertions no longer need to track which component wrote to the clipboard or applied padding. +- **Architectural clarity.** `ClipboardRouter` is the single source of truth for "what is on the clipboard right now." + +### Negative + +- **Tight coupling to `editor.action.clipboardPasteAction`.** If VS Code ever changes its paste command ID or behavior, all AI assistant destinations are affected simultaneously. Mitigation: `VscodeAdapter.pasteTextFromClipboard()` is the single adapter method; a change only needs one line. +- **Fixed delays are fragile.** The 200ms delays are platform-dependent and could break on slower hardware, remote workspaces, or high-CPU scenarios. Mitigation: both delays accept overrides (`CLIPBOARD_POST_PASTE_DELAY_MS`, `FOCUS_TO_PASTE_DELAY_MS` are exported constants; `pasteTextFromClipboard(postPasteDelayMs?)` accepts optional override). + +### Neutral + +- `BuiltinAiAssistantDef.focusAndPasteCommands` renamed to `focusCommands`. Developers referencing the internal type need to update. User-facing config (`customAiAssistants[].focusAndPasteCommands`) is unchanged. +- `PasteDestinationManager.performPaste()` removed. Callers that previously depended on padding being applied internally (none in practice — all call sites already applied padding) need no changes. +- `ManualPasteInsertFactory.forTarget()` uses single command instead of array. The multi-command fallback was a workaround for the double-write race; with a single clipboard write the first paste command always works. diff --git a/docs/ADR/README.md b/docs/ADR/README.md index 85f5ae33b..599d9bf6a 100644 --- a/docs/ADR/README.md +++ b/docs/ADR/README.md @@ -25,6 +25,7 @@ We follow the format from [adr.github.io](https://adr.github.io/): | --------------------------------------------------------- | --------------------------------------- | -------- | | [0001](./0001-result-vs-exception-convention.md) | Result vs Exception Convention | Accepted | | [0002](./0002-three-tier-custom-ai-assistant-commands.md) | Three-Tier Custom AI Assistant Commands | Accepted | +| [0003](./0003-single-clipboard-write.md) | Single Clipboard Write per Operation | Accepted | ## Future ADRs diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index aa0b915ea..96a2a89a4 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -3,7 +3,7 @@ # Scope: Changes accumulated between the vscode-extension-v1.0.0 release tag and the current # main branch tip, targeting v1.1.0. # -# Source of truth for this QA cycle. Run `pnpm generate:qa-issue -- qa/qa-test-cases-v1.1.0.yaml` +# Source of truth for this QA cycle. Run `pnpm generate:qa-issue -- qa/qa-test-cases-v1.1.0-003.yaml` # to create the corresponding GitHub issue tracker (parent issue + per-section sub-issues). # # Schema: @@ -699,6 +699,26 @@ test_cases: expected_result: 'Clipboard contains the sentinel value. assertClipboardRestored passes — no preserve cycle was triggered because the operation was cancelled before any transport.' automated: assisted + - id: clipboard-preservation-010 + labels: + - clipboard + feature: 'Clipboard Preservation' + scenario: 'AI assistant auto-paste fails (focus command throws): RangeLink stays in clipboard for manual paste' + preconditions: + - 'Custom AI assistant "Dummy AI (Focus-Fail)" configured in workspace settings with focusPasteCommands: [dummyAi.focusFail] (registered command that throws intentionally)' + - 'Dummy AI extension installed and active' + - 'rangelink.clipboard.preserve = "always" (default)' + - 'A file is open in the editor' + steps: + - 'Test writes CLIPBOARD_SENTINEL to clipboard' + - 'Human: Cmd+R Cmd+D → select "Dummy AI (Focus-Fail)" from picker' + - 'Human: select lines 1-3 in test file and press Cmd+R Cmd+L' + - 'The focus command (dummyAi.focusFail) throws — extension logs "Focus failed, cannot paste link"' + - 'Test calls assertClipboardChanged — clipboard must contain the RangeLink, NOT the sentinel' + expected_result: 'Clipboard contains the generated RangeLink (not CLIPBOARD_SENTINEL). isClipboardRestorationApplicable returns false because getUserInstruction(Failure) is defined. The "Paste manually" warning toast was shown.' + command_to_run: 'pnpm test:release:grep "clipboard-preservation-010"' + automated: false + # --------------------------------------------------------------------------- # Section 6 — Send File Path Commands # --------------------------------------------------------------------------- @@ -2997,6 +3017,210 @@ test_cases: expected_result: 'Panel auto-initializes on first insert; tier1 textarea contains the generated link; tier2 is empty' automated: assisted + # --------------------------------------------------------------------------- + # Section — Built-in AI Assistants + # --------------------------------------------------------------------------- + + - id: claude-code-001 + feature: 'Built-in AI Assistants' + scenario: 'Claude Code Chat appears in destination picker when extension is active' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'No destination currently bound' + steps: + - 'Open destination picker via R-D or Command Palette → "Bind to Destination"' + - 'Confirm "Claude Code Chat" item appears first in the AI Assistants group' + expected_result: '"Claude Code Chat" is listed as the first item in the AI Assistants group of the destination picker' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-001"' + automated: false + + - id: claude-code-002 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Binding to Claude Code Chat and sending a link delivers content to chat' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'A file is open in the editor with a selection' + steps: + - 'Run Command Palette → "RangeLink: Bind to Claude Code" (or select from R-D picker)' + - 'Confirm "✓ RangeLink bound to Claude Code Chat" status bar message appears' + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm Claude Code chat panel opens and receives the RangeLink payload' + expected_result: 'Claude Code chat opens and receives the RangeLink payload for the selected range' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-002"' + automated: false + + - id: claude-code-003 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Second invocation of Claude Code Chat uses warm path (no cold-start refocus loop)' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'RangeLink is bound to Claude Code Chat' + steps: + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux) — first send (cold start)' + - 'Select a different range of code and press Cmd+R Cmd+L again — second send (warm path)' + - 'Confirm second send is faster (no ~2.5s refocus delay)' + expected_result: 'Second invocation uses warm path: no cold-start refocus signals, only a short 200ms delay before paste' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-003"' + automated: false + + - id: cursor-ai-001 + feature: 'Built-in AI Assistants' + scenario: 'Cursor AI Assistant appears in destination picker when running in Cursor IDE' + preconditions: + - 'Editor is Cursor IDE (cursor-ai detection is IDE-based, not extension-based)' + - 'No destination currently bound' + steps: + - 'Open destination picker via R-D or Command Palette → "Bind to Destination"' + - 'Confirm "Cursor AI Assistant" item appears in the AI Assistants group' + expected_result: '"Cursor AI Assistant" is listed in the AI Assistants group of the destination picker' + automated: false + + - id: cursor-ai-002 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Binding to Cursor AI and sending a link delivers content to chat' + preconditions: + - 'Editor is Cursor IDE' + - 'A file is open in the editor with a selection' + steps: + - 'Run Command Palette → "RangeLink: Bind to Cursor AI" (or select from R-D picker)' + - 'Confirm "✓ RangeLink bound to Cursor AI Assistant" status bar message appears' + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm Cursor AI chat panel opens and receives the link + selected text' + expected_result: 'Cursor AI chat opens and contains the RangeLink and selected code snippet' + automated: false + + - id: github-copilot-chat-001 + feature: 'Built-in AI Assistants' + scenario: 'GitHub Copilot Chat appears in destination picker when extension is active' + preconditions: + - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' + - 'No destination currently bound' + steps: + - 'Open destination picker via R-D or Command Palette → "Bind to Destination"' + - 'Confirm "GitHub Copilot Chat" item appears in the AI Assistants group' + expected_result: '"GitHub Copilot Chat" is listed in the AI Assistants group of the destination picker' + automated: false + + - id: github-copilot-chat-002 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Binding to GitHub Copilot Chat and sending a link delivers content to chat' + preconditions: + - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' + - 'A file is open in the editor with a selection' + steps: + - 'Run Command Palette → "RangeLink: Bind to GitHub Copilot Chat" (or select from R-D picker)' + - 'Confirm "✓ RangeLink bound to GitHub Copilot Chat" status bar message appears' + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm GitHub Copilot Chat panel opens and receives the link + selected text' + expected_result: 'GitHub Copilot Chat opens and contains the RangeLink and selected code snippet' + automated: false + + - id: claude-code-004 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Cold panel paste verification — content arrives in Claude Code chat input after a single R-L since bind' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'RangeLink is bound to Claude Code Chat' + - 'No send has been performed since binding (cold start)' + steps: + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the Claude Code chat panel receives the link and selected code' + - 'Confirm logs show the single paste command (editor.action.clipboardPasteAction) succeeded' + expected_result: 'Content arrives in Claude Code chat input. Logs show paste command succeeded with a post-paste delay. Single clipboard write architecture: no double-write.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-004"' + automated: assisted + + - id: claude-code-005 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Warm panel paste verification — content arrives in Claude Code chat input on second R-L without cold-start refocus signals' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'RangeLink is bound to Claude Code Chat' + - 'At least one send has been performed since binding (warm path)' + steps: + - 'Select a different range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the Claude Code chat panel receives the link and selected code' + - 'Confirm logs show NO cold-start refocus signals in the warm send' + expected_result: 'Content arrives in Claude Code chat input. Logs show no cold-start refocus signals. Paste command succeeds with minimal delay.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-005"' + automated: assisted + + - id: cursor-ai-003 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Cold panel paste verification — content arrives in Cursor AI chat input after a single R-L since bind' + preconditions: + - 'Editor is Cursor IDE' + - 'RangeLink is bound to Cursor AI' + - 'No send has been performed since binding (cold start)' + steps: + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the Cursor AI chat panel receives the link and selected code' + - 'Confirm logs show the single paste command (editor.action.clipboardPasteAction) succeeded' + expected_result: 'Content arrives in Cursor AI chat input. Logs show paste command succeeded. Cursor AI is unaffected by the webview async clipboard read issue (native input).' + automated: false + + - id: cursor-ai-004 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Warm panel paste verification — content arrives in Cursor AI chat input on second R-L without cold-start refocus signals' + preconditions: + - 'Editor is Cursor IDE' + - 'RangeLink is bound to Cursor AI' + - 'At least one send has been performed since binding (warm path)' + steps: + - 'Select a different range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the Cursor AI chat panel receives the link and selected code' + - 'Confirm logs show NO cold-start refocus signals in the warm send' + expected_result: 'Content arrives in Cursor AI chat input. Logs show no cold-start refocus signals. Paste command succeeds with minimal delay.' + automated: false + + - id: github-copilot-chat-003 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Cold panel paste verification — content arrives in GitHub Copilot Chat input after a single R-L since bind' + preconditions: + - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' + - 'RangeLink is bound to GitHub Copilot Chat' + - 'No send has been performed since binding (cold start)' + steps: + - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the GitHub Copilot Chat panel receives the link and selected code' + - 'Confirm logs show the single paste command (editor.action.clipboardPasteAction) succeeded' + expected_result: 'Content arrives in GitHub Copilot Chat input. Logs show paste command succeeded with a post-paste delay. Single clipboard write architecture: no double-write.' + automated: false + + - id: github-copilot-chat-004 + labels: + - clipboard + feature: 'Built-in AI Assistants' + scenario: 'Warm panel paste verification — content arrives in GitHub Copilot Chat input on second R-L without cold-start refocus signals' + preconditions: + - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' + - 'RangeLink is bound to GitHub Copilot Chat' + - 'At least one send has been performed since binding (warm path)' + steps: + - 'Select a different range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' + - 'Confirm the GitHub Copilot Chat panel receives the link and selected code' + - 'Confirm logs show NO cold-start refocus signals in the warm send' + expected_result: 'Content arrives in GitHub Copilot Chat input. Logs show no cold-start refocus signals. Paste command succeeds with minimal delay.' + automated: false + # --------------------------------------------------------------------------- # Section — Release Notifier # --------------------------------------------------------------------------- diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts new file mode 100644 index 000000000..31ce26732 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert'; + +import * as vscode from 'vscode'; + +import { CMD_BIND_TO_CLAUDE_CODE, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { + activateExtension, + cleanupFiles, + closeAllEditors, + createLogger, + createWorkspaceFile, + getLogCapture, + openEditor, + printAssistedBanner, + settle, + waitForHuman, +} from '../helpers'; + +suite('Built-in AI Assistants', () => { + const log = createLogger('builtInAiAssistants'); + const tmpFileUris: vscode.Uri[] = []; + + suiteSetup(async () => { + await activateExtension(); + printAssistedBanner(); + }); + + teardown(async () => { + await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); + await closeAllEditors(); + cleanupFiles(tmpFileUris); + tmpFileUris.length = 0; + await settle(); + }); + + test('[assisted] claude-code-004: cold panel paste — content arrives in Claude Code chat after first R-L since bind', async () => { + const fileUri = createWorkspaceFile('cc-004', 'line 1\nline 2\nline 3\n'); + tmpFileUris.push(fileUri); + await openEditor(fileUri); + await settle(); + + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-cc-004'); + + await waitForHuman( + 'claude-code-004', + 'Cold paste: select lines 1-2 and press Cmd+R Cmd+L, verify link appears in Claude Code chat, then Cancel', + [ + '1. Click into the test file (cc-004) to focus it', + '2. Select exactly lines 1-2 (click line 1, shift-click end of line 2)', + '3. Press Cmd+R Cmd+L — the RangeLink should appear in Claude Code chat input', + '4. Visually confirm the link appears in Claude Code', + '5. Press Cancel to continue (assertions happen automatically)', + ], + ); + + await settle(); + const lines = logCapture.getLinesSince('before-cc-004'); + + const pasteSuccessLog = lines.find( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok(pasteSuccessLog, 'Expected paste link success log after cold paste send'); + + const clipboardPasteLog = lines.find( + (line) => + line.includes('VscodeAdapter.pasteTextFromClipboard') && + line.includes('Clipboard paste succeeded'), + ); + assert.ok(clipboardPasteLog, 'Expected Clipboard paste succeeded log'); + + log('✓ Cold paste: focus + clipboard paste executed, content sent to Claude Code'); + }); + + test('[assisted] claude-code-005: warm panel paste — second R-L delivers content without cold-start refocus', async () => { + const fileUri = createWorkspaceFile('cc-005', 'line 1\nline 2\nline 3\nline 4\n'); + tmpFileUris.push(fileUri); + await openEditor(fileUri); + await settle(); + + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await settle(); + + // First send (cold) — warms the panel + await waitForHuman( + 'claude-code-005-cold', + 'First send (cold): select lines 1-2 and press Cmd+R Cmd+L, confirm link appears, then Cancel', + [ + '1. Click into the test file (cc-005)', + '2. Select exactly lines 1-2 (click line 1, shift-click end of line 2)', + '3. Press Cmd+R Cmd+L — the RangeLink should appear in Claude Code chat', + '4. Visually confirm the link appears in Claude Code', + '5. Press Cancel to continue', + ], + ); + await settle(); + + // Select lines 3-4 for warm send + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(2, 0, 3, 7); + await settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-cc-005'); + + await waitForHuman( + 'claude-code-005-warm', + 'Second send (warm): verify lines 3-4 selected, press Cmd+R Cmd+L, confirm no refocus flicker, then Cancel', + [ + '1. Lines 3-4 should already be selected in cc-005', + '2. Press Cmd+R Cmd+L — the RangeLink should appear in Claude Code chat', + '3. Visually confirm NO refocus flickering or delay (panel already warm)', + '4. Press Cancel to continue', + ], + ); + await settle(); + + const lines = logCapture.getLinesSince('before-cc-005'); + + const pasteSuccessLog = lines.find( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok(pasteSuccessLog, 'Expected paste link success log on warm send'); + + const clipboardPasteLog = lines.find( + (line) => + line.includes('VscodeAdapter.pasteTextFromClipboard') && + line.includes('Clipboard paste succeeded'), + ); + assert.ok(clipboardPasteLog, 'Expected Clipboard paste succeeded log on warm send'); + + log('✓ Warm paste: content arrived without cold-start refocus'); + }); +}); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts index 8abf437e3..9e361722e 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts @@ -443,7 +443,7 @@ standardSuite('Custom AI Assistants — Paste Flow', { assisted: true }, (log) = const manualPasteLog = lines.find( (line) => line.includes('ManualPasteInsertFactory.insert') && - line.includes('Link copied to clipboard for manual paste'), + line.includes('Link ready for manual paste'), ); assert.ok(manualPasteLog, 'Expected ManualPasteInsertFactory success log'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts index c7fa32965..4dfbf703a 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts @@ -231,7 +231,7 @@ standardSuite('Send File Path', { assisted: true }, (log) => { log('✓ Text editor destination receives auto-quoted path (spaces → single quotes)'); }); - test('send-file-path-007: clipboard write uses unquoted path even when terminal receives quoted version', async () => { + test('send-file-path-007: clipboard and terminal both receive the quoted path (single clipboard write)', async () => { await vscode.workspace .getConfiguration('rangelink') .update('clipboard.preserve', 'never', vscode.ConfigurationTarget.Global); @@ -265,20 +265,23 @@ standardSuite('Send File Path', { assisted: true }, (log) => { ); assert.ok( quotedLog, - 'Expected "Quoted path for unsafe characters" log — proves terminal received a quoted path while the original path was unquoted', + 'Expected "Quoted path for unsafe characters" log — path with spaces must be quoted for terminal safety', ); assertTerminalBufferContains(capturing.getCapturedText(), `'${relativePath}'`); assert.ok( clipboard.includes(relativePath), `Expected clipboard to include the file path, got: ${JSON.stringify(clipboard)}`, ); - log('✓ Log shows path was quoted for terminal; terminal and clipboard both contain the path'); + log('✓ Both terminal and clipboard receive the quoted path (single clipboard write)'); }); - test('send-file-path-008: self-paste shows info notification and copies path to clipboard without modifying file', async () => { + test('send-file-path-008: self-paste shows info notification and copies smart-padded path to clipboard without modifying file', async () => { await vscode.workspace .getConfiguration('rangelink') .update('clipboard.preserve', 'never', vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('rangelink') + .update('smartPadding.pasteFilePath', 'both', vscode.ConfigurationTarget.Global); const fileUri = createWorkspaceFile('sfp-008-self', 'original content\n'); tmpFileUris.push(fileUri); @@ -301,14 +304,15 @@ standardSuite('Send File Path', { assisted: true }, (log) => { message: 'Selected text copied to clipboard. Cannot paste to same file.', }); const clipboard = await vscode.env.clipboard.readText(); + const expectedPadded = ` ${relativePath} `; assert.strictEqual( clipboard, - relativePath, - `Expected clipboard to contain path "${relativePath}" after self-paste`, + expectedPadded, + `Expected clipboard to contain smart-padded path "${JSON.stringify(expectedPadded)}" after self-paste, got: ${JSON.stringify(clipboard)}`, ); const doc = await vscode.workspace.openTextDocument(fileUri); assert.ok(!doc.isDirty, 'Expected file to remain unmodified after self-paste'); - log('✓ Self-paste: info notification shown, path on clipboard, file unchanged'); + log('✓ Self-paste: info notification shown, smart-padded path on clipboard, file unchanged'); }); test('[assisted] send-file-path-009: unbound — R-F opens destination picker before sending', async () => { diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts index 7cbf240fd..855b955a2 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts @@ -1,15 +1,5 @@ -/** - * Integration tests for ComposablePasteDestination. - * - * These tests use REAL FocusCapability implementations (not mocks) to verify - * end-to-end orchestration works correctly. The VscodeAdapter is mocked - * to control IDE behavior without requiring a real VSCode instance. - * - * Purpose: Ensure mocks used in unit tests accurately represent real behavior. - */ import { createMockLogger } from 'barebone-logger-testing'; -import type { ClipboardPreserver } from '../../clipboard/ClipboardPreserver'; import { AIAssistantFocusCapability, AIAssistantInsertFactory, @@ -21,7 +11,6 @@ import { TerminalInsertFactory, } from '../../destinations'; import { - createMockClipboardPreserver, createMockDocument, createMockEditor, createMockFormattedLink, @@ -32,11 +21,9 @@ import { describe('ComposablePasteDestination Integration Tests', () => { let mockLogger: ReturnType; - let mockClipboardPreserver: jest.Mocked; beforeEach(() => { mockLogger = createMockLogger(); - mockClipboardPreserver = createMockClipboardPreserver(); }); describe('Terminal-like destination (real TerminalFocusCapability)', () => { @@ -47,11 +34,7 @@ describe('ComposablePasteDestination Integration Tests', () => { processId: Promise.resolve(12345), }); - const insertFactory = new TerminalInsertFactory( - mockAdapter, - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new TerminalInsertFactory(mockAdapter, mockLogger); const focusCapability = new TerminalFocusCapability( mockAdapter, mockTerminal, @@ -62,7 +45,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const showTerminalSpy = jest.spyOn(mockAdapter, 'showTerminal'); const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') + .spyOn(mockAdapter, 'pasteIntoTerminal') .mockResolvedValue(undefined); const destination = ComposablePasteDestination.createForTesting({ @@ -79,24 +62,20 @@ describe('ComposablePasteDestination Integration Tests', () => { const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await destination.pasteLink(formattedLink, 'both'); + const result = await destination.pasteLink(formattedLink); expect(result).toBe(true); expect(showTerminalSpy).toHaveBeenCalledTimes(1); expect(showTerminalSpy).toHaveBeenCalledWith(mockTerminal, 'steal-focus'); expect(pasteTextSpy).toHaveBeenCalledTimes(1); - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, ' src/file.ts#L10 '); + expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal); }); it('should verify focus happens before text insertion', async () => { const mockAdapter = createMockVscodeAdapter(); const mockTerminal = createMockTerminal({ name: 'Test Terminal' }); - const insertFactory = new TerminalInsertFactory( - mockAdapter, - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new TerminalInsertFactory(mockAdapter, mockLogger); const focusCapability = new TerminalFocusCapability( mockAdapter, mockTerminal, @@ -111,7 +90,7 @@ describe('ComposablePasteDestination Integration Tests', () => { callOrder.push('focus'); }); - jest.spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard').mockImplementation(async () => { + jest.spyOn(mockAdapter, 'pasteIntoTerminal').mockImplementation(async () => { callOrder.push('insert'); }); @@ -127,7 +106,7 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link'), 'both'); + await destination.pasteLink(createMockFormattedLink('test-link')); expect(callOrder).toStrictEqual(['focus', 'insert']); }); @@ -137,12 +116,7 @@ describe('ComposablePasteDestination Integration Tests', () => { it('should complete end-to-end paste flow with clipboard and commands', async () => { const mockAdapter = createMockVscodeAdapter(); - const insertFactory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new AIAssistantInsertFactory(mockAdapter, mockLogger); const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['ai.assistant.focus'], @@ -154,9 +128,9 @@ describe('ComposablePasteDestination Integration Tests', () => { const executeCommandSpy = jest .spyOn(mockAdapter, 'executeCommand') .mockResolvedValue(undefined); - const clipboardSpy = jest - .spyOn(mockAdapter, 'writeTextToClipboard') - .mockResolvedValue(undefined); + const pasteClipboardSpy = jest + .spyOn(mockAdapter, 'pasteTextFromClipboard') + .mockResolvedValue(true); const destination = ComposablePasteDestination.createForTesting({ id: 'claude-code', @@ -172,24 +146,18 @@ describe('ComposablePasteDestination Integration Tests', () => { const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await destination.pasteLink(formattedLink, 'both'); + const result = await destination.pasteLink(formattedLink); expect(result).toBe(true); - expect(executeCommandSpy).toHaveBeenCalledTimes(2); - expect(executeCommandSpy).toHaveBeenNthCalledWith(1, 'ai.assistant.focus'); - expect(clipboardSpy).toHaveBeenCalledWith(' src/file.ts#L10 '); - expect(executeCommandSpy).toHaveBeenNthCalledWith(2, 'editor.action.clipboardPasteAction'); + expect(executeCommandSpy).toHaveBeenCalledTimes(1); + expect(executeCommandSpy).toHaveBeenCalledWith('ai.assistant.focus'); + expect(pasteClipboardSpy).toHaveBeenCalledTimes(1); }); it('should try focus commands in order until success', async () => { const mockAdapter = createMockVscodeAdapter(); - const insertFactory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new AIAssistantInsertFactory(mockAdapter, mockLogger); const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second', 'command.third'], @@ -201,9 +169,8 @@ describe('ComposablePasteDestination Integration Tests', () => { const executeCommandSpy = jest .spyOn(mockAdapter, 'executeCommand') .mockRejectedValueOnce(new Error('First failed')) - .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); + jest.spyOn(mockAdapter, 'pasteTextFromClipboard').mockResolvedValue(true); const destination = ComposablePasteDestination.createForTesting({ id: 'claude-code', @@ -217,24 +184,18 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(true); - expect(executeCommandSpy).toHaveBeenCalledTimes(3); + expect(executeCommandSpy).toHaveBeenCalledTimes(2); expect(executeCommandSpy).toHaveBeenNthCalledWith(1, 'command.first'); expect(executeCommandSpy).toHaveBeenNthCalledWith(2, 'command.second'); - expect(executeCommandSpy).toHaveBeenNthCalledWith(3, 'editor.action.clipboardPasteAction'); }); it('should return false when all focus commands fail', async () => { const mockAdapter = createMockVscodeAdapter(); - const insertFactory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new AIAssistantInsertFactory(mockAdapter, mockLogger); const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second'], @@ -260,7 +221,7 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(false); }); @@ -302,11 +263,11 @@ describe('ComposablePasteDestination Integration Tests', () => { const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await destination.pasteLink(formattedLink, 'both'); + const result = await destination.pasteLink(formattedLink); expect(result).toBe(true); expect(insertSpy).toHaveBeenCalledTimes(1); - expect(insertSpy).toHaveBeenCalledWith(mockEditor, ' src/file.ts#L10 '); + expect(insertSpy).toHaveBeenCalledWith(mockEditor, 'src/file.ts#L10'); }); it('should handle showTextDocument failure gracefully', async () => { @@ -341,7 +302,7 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(false); }); @@ -379,7 +340,7 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(false); }); @@ -390,11 +351,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const mockAdapter = createMockVscodeAdapter(); const mockTerminal = createMockTerminal({ name: 'Test Terminal' }); - const insertFactory = new TerminalInsertFactory( - mockAdapter, - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new TerminalInsertFactory(mockAdapter, mockLogger); const focusCapability = new TerminalFocusCapability( mockAdapter, mockTerminal, @@ -415,59 +372,16 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(false); }); - it('should apply smart padding with "both" mode', async () => { + it('should call pasteIntoTerminal for terminal destinations', async () => { const mockAdapter = createMockVscodeAdapter(); const mockTerminal = createMockTerminal({ name: 'Test Terminal' }); - const insertFactory = new TerminalInsertFactory( - mockAdapter, - mockClipboardPreserver, - mockLogger, - ); - const focusCapability = new TerminalFocusCapability( - mockAdapter, - mockTerminal, - insertFactory, - mockLogger, - ); - const eligibilityChecker = new ContentEligibilityChecker(mockLogger); - - jest.spyOn(mockAdapter, 'showTerminal'); - const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') - .mockResolvedValue(undefined); - - const destination = ComposablePasteDestination.createForTesting({ - id: 'terminal', - displayName: 'Terminal', - resource: { kind: 'terminal', terminal: mockTerminal }, - focusCapability, - eligibilityChecker, - isAvailable: () => Promise.resolve(true), - jumpSuccessMessage: 'Focused terminal', - loggingDetails: {}, - logger: mockLogger, - }); - - await destination.pasteLink(createMockFormattedLink('test-link'), 'both'); - - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, ' test-link '); - }); - - it('should apply smart padding with "none" mode', async () => { - const mockAdapter = createMockVscodeAdapter(); - const mockTerminal = createMockTerminal({ name: 'Test Terminal' }); - - const insertFactory = new TerminalInsertFactory( - mockAdapter, - mockClipboardPreserver, - mockLogger, - ); + const insertFactory = new TerminalInsertFactory(mockAdapter, mockLogger); const focusCapability = new TerminalFocusCapability( mockAdapter, mockTerminal, @@ -478,7 +392,7 @@ describe('ComposablePasteDestination Integration Tests', () => { jest.spyOn(mockAdapter, 'showTerminal'); const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') + .spyOn(mockAdapter, 'pasteIntoTerminal') .mockResolvedValue(undefined); const destination = ComposablePasteDestination.createForTesting({ @@ -493,9 +407,9 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link'), 'none'); + await destination.pasteLink(createMockFormattedLink('test-link')); - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, 'test-link'); + expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal); }); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.test.ts index 72e269867..e253513df 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.test.ts @@ -4,7 +4,6 @@ import { Result } from 'rangelink-core-ts'; import { FocusErrorReason } from '../../destinations/capabilities/FocusCapability'; import { ComposablePasteDestination } from '../../destinations/ComposablePasteDestination'; import { AutoPasteResult, PasteContentType } from '../../types'; -import type { PaddingMode } from '../../utils/applySmartPadding'; import { createMockComposablePasteDestination, createMockEligibilityChecker, @@ -13,9 +12,6 @@ import { createMockTerminalComposablePasteDestination, } from '../helpers'; -const UNUSED_PADDING_MODE = 'parameter not used' as unknown as PaddingMode; -const ARBITRARY_PADDING_MODE: PaddingMode = 'none'; - describe('ComposablePasteDestination', () => { const mockLogger = createMockLogger(); @@ -25,12 +21,7 @@ describe('ComposablePasteDestination', () => { const destination = createMockComposablePasteDestination({ isAvailable, logger: mockLogger }); const context = { fn: 'test', mock: true }; - await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - ARBITRARY_PADDING_MODE, - ); + await destination['performPaste']('text', context, PasteContentType.Link); expect(isAvailable).toHaveBeenCalledTimes(1); }); @@ -45,18 +36,13 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - const result = await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - UNUSED_PADDING_MODE, - ); + const result = await destination['performPaste']('text', context, PasteContentType.Link); expect(result).toBe(false); expect(focusCapability.focus).not.toHaveBeenCalled(); }); - it('should not double-pad already padded text', async () => { + it('should pass text through as-is (padding applied upstream at call sites)', async () => { const mockInsert = jest.fn().mockResolvedValue(true); const focusCapability = createMockFocusCapability(); focusCapability.focus.mockResolvedValue(Result.ok({ inserter: mockInsert })); @@ -67,7 +53,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - await destination['performPaste'](' already-padded ', context, PasteContentType.Link, 'both'); + await destination['performPaste'](' already-padded ', context, PasteContentType.Link); expect(mockInsert).toHaveBeenCalledWith(' already-padded '); }); @@ -91,12 +77,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - ARBITRARY_PADDING_MODE, - ); + await destination['performPaste']('text', context, PasteContentType.Link); expect(focusCapability.focus).toHaveBeenCalledTimes(1); expect(focusCapability.focus).toHaveBeenCalledWith(context); @@ -114,12 +95,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - const result = await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - ARBITRARY_PADDING_MODE, - ); + const result = await destination['performPaste']('text', context, PasteContentType.Link); expect(result).toBe(true); }); @@ -135,12 +111,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - const result = await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - ARBITRARY_PADDING_MODE, - ); + const result = await destination['performPaste']('text', context, PasteContentType.Link); expect(result).toBe(false); }); @@ -157,12 +128,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - const result = await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - ARBITRARY_PADDING_MODE, - ); + const result = await destination['performPaste']('text', context, PasteContentType.Link); expect(result).toBe(false); }); @@ -175,12 +141,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - await destination['performPaste']( - 'text', - context, - PasteContentType.Link, - UNUSED_PADDING_MODE, - ); + await destination['performPaste']('text', context, PasteContentType.Link); expect(mockLogger.info).toHaveBeenCalledWith( context, @@ -196,12 +157,7 @@ describe('ComposablePasteDestination', () => { }); const context = { fn: 'test', mock: true }; - await destination['performPaste']( - 'text', - context, - PasteContentType.Text, - UNUSED_PADDING_MODE, - ); + await destination['performPaste']('text', context, PasteContentType.Text); expect(mockLogger.info).toHaveBeenCalledWith( context, @@ -211,7 +167,7 @@ describe('ComposablePasteDestination', () => { }); describe('pasteLink() delegation', () => { - it('should build context with formattedLink, linkLength, and paddingMode', async () => { + it('should build context with formattedLink and linkLength', async () => { const focusCapability = createMockFocusCapability(); const destination = createMockComposablePasteDestination({ focusCapability, @@ -219,18 +175,17 @@ describe('ComposablePasteDestination', () => { }); const formattedLink = createMockFormattedLink('test-link'); - await destination.pasteLink(formattedLink, 'both'); + await destination.pasteLink(formattedLink); expect(focusCapability.focus).toHaveBeenCalledWith({ fn: 'ComposablePasteDestination.pasteLink', formattedLink, linkLength: 9, - paddingMode: 'both', mock: true, }); }); - it('should pass link text to insert function with paddingMode applied', async () => { + it('should pass link text to insert function unchanged (padding applied upstream)', async () => { const mockInsert = jest.fn().mockResolvedValue(true); const focusCapability = createMockFocusCapability(); focusCapability.focus.mockResolvedValue(Result.ok({ inserter: mockInsert })); @@ -241,31 +196,30 @@ describe('ComposablePasteDestination', () => { }); const formattedLink = createMockFormattedLink('my-link'); - await destination.pasteLink(formattedLink, 'both'); + await destination.pasteLink(formattedLink); - expect(mockInsert).toHaveBeenCalledWith(' my-link '); + expect(mockInsert).toHaveBeenCalledWith('my-link'); }); }); describe('pasteContent() delegation', () => { - it('should build context with contentLength and paddingMode', async () => { + it('should build context with contentLength', async () => { const focusCapability = createMockFocusCapability(); const destination = createMockComposablePasteDestination({ focusCapability, logger: mockLogger, }); - await destination.pasteContent('test content', 'none'); + await destination.pasteContent('test content'); expect(focusCapability.focus).toHaveBeenCalledWith({ fn: 'ComposablePasteDestination.pasteContent', contentLength: 12, - paddingMode: 'none', mock: true, }); }); - it('should pass content text to insert function with paddingMode applied', async () => { + it('should pass content text to insert function unchanged (padding applied upstream)', async () => { const mockInsert = jest.fn().mockResolvedValue(true); const focusCapability = createMockFocusCapability(); focusCapability.focus.mockResolvedValue(Result.ok({ inserter: mockInsert })); @@ -275,9 +229,9 @@ describe('ComposablePasteDestination', () => { logger: mockLogger, }); - await destination.pasteContent('my content', 'both'); + await destination.pasteContent('my content'); - expect(mockInsert).toHaveBeenCalledWith(' my content '); + expect(mockInsert).toHaveBeenCalledWith('my content'); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts index c9ad53626..66e15e291 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts @@ -228,20 +228,16 @@ describe('DestinationRegistry', () => { let capturedCapability: unknown; const builder: DestinationBuilder = (_options, context) => { - capturedCapability = context.factories.focusCapability.createAIAssistantCapability( - ['focus'], - ['paste'], - ); + capturedCapability = context.factories.focusCapability.createAIAssistantCapability([ + 'focus', + ]); return createBaseMockPasteDestination({ id: 'terminal' }); }; registry.register('terminal', builder); registry.create({ kind: 'terminal', terminal: {} as never }); expect(capturedCapability).toBe(mockCapability); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( - ['focus'], - ['paste'], - ); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith(['focus']); }); it('should allow mocking EligibilityCheckerFactory methods in builder', () => { @@ -319,10 +315,9 @@ describe('DestinationRegistry', () => { const registry = createRegistry(factories); const builder: DestinationBuilder = (_options, context) => { - const capability = context.factories.focusCapability.createAIAssistantCapability( - ['focus.cmd'], - ['paste'], - ); + const capability = context.factories.focusCapability.createAIAssistantCapability([ + 'focus.cmd', + ]); const checker = context.factories.eligibilityChecker.createContentEligibilityChecker(); expect(capability).toBe(mockCapability); @@ -335,10 +330,9 @@ describe('DestinationRegistry', () => { const destination = registry.create({ kind: 'cursor-ai' }); expect(destination).toBeDefined(); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( - ['focus.cmd'], - ['paste'], - ); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith([ + 'focus.cmd', + ]); expect(factories.eligibilityChecker.createContentEligibilityChecker).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts index e2289b805..a72333d7b 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts @@ -1261,15 +1261,11 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'terminal', terminal: mockTerminal }); const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await manager.sendLinkToDestination( - formattedLink, - TEST_STATUS_MESSAGE, - 'both', - ); + const result = await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE); expect(result).toBe(true); expect(mockTerminalDest.pasteLink).toHaveBeenCalledTimes(1); - expect(mockTerminalDest.pasteLink).toHaveBeenCalledWith(formattedLink, 'both'); + expect(mockTerminalDest.pasteLink).toHaveBeenCalledWith(formattedLink); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenNthCalledWith( @@ -1288,7 +1284,6 @@ describe('PasteDestinationManager', () => { destinationKind: 'terminal', displayName: 'Terminal ("bash")', formattedLink, - paddingMode: 'both', terminalName: 'bash', }, 'Sending link to Terminal ("bash")', @@ -1317,15 +1312,11 @@ describe('PasteDestinationManager', () => { ); const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await cursorManager.sendLinkToDestination( - formattedLink, - TEST_STATUS_MESSAGE, - 'both', - ); + const result = await cursorManager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE); expect(result).toBe(true); expect(boundDest.pasteLink).toHaveBeenCalledTimes(1); - expect(boundDest.pasteLink).toHaveBeenCalledWith(formattedLink, 'both'); + expect(boundDest.pasteLink).toHaveBeenCalledWith(formattedLink); expect(mockSetStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockSetStatusBarMessage).toHaveBeenNthCalledWith( @@ -1346,15 +1337,11 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'github-copilot-chat' }); const formattedLink = createMockFormattedLink('src/file.ts#L10'); - const result = await manager.sendLinkToDestination( - formattedLink, - TEST_STATUS_MESSAGE, - 'both', - ); + const result = await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE); expect(result).toBe(true); expect(mockDestination.pasteLink).toHaveBeenCalledTimes(1); - expect(mockDestination.pasteLink).toHaveBeenCalledWith(formattedLink, 'both'); + expect(mockDestination.pasteLink).toHaveBeenCalledWith(formattedLink); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenNthCalledWith( 1, @@ -1372,7 +1359,6 @@ describe('PasteDestinationManager', () => { const result = await manager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ); expect(result).toBe(false); @@ -1397,7 +1383,6 @@ describe('PasteDestinationManager', () => { const result = await localManager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ); expect(result).toBe(false); @@ -1445,7 +1430,6 @@ describe('PasteDestinationManager', () => { const result = await cursorManager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ); expect(result).toBe(false); @@ -1470,7 +1454,7 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'terminal', terminal: mockTerminal }); const formattedLink = createMockFormattedLink('src/file.ts#L10'); - await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE, 'both'); + await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockAdapter.__getVscodeInstance().window.setStatusBarMessage).toHaveBeenNthCalledWith( @@ -1489,7 +1473,6 @@ describe('PasteDestinationManager', () => { destinationKind: 'terminal', displayName: 'Terminal ("bash")', formattedLink, - paddingMode: 'both', terminalName: 'bash', }, 'Sending link to Terminal ("bash")', @@ -1515,7 +1498,6 @@ describe('PasteDestinationManager', () => { const result = await localManager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ); expect(result).toBe(false); @@ -1545,7 +1527,6 @@ describe('PasteDestinationManager', () => { manager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ), ).toThrowRangeLinkExtensionErrorAsync('UNEXPECTED_CODE_PATH', { message: @@ -1568,7 +1549,6 @@ describe('PasteDestinationManager', () => { manager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ), ).toThrowRangeLinkExtensionErrorAsync('UNEXPECTED_CODE_PATH', { message: @@ -1596,7 +1576,6 @@ describe('PasteDestinationManager', () => { manager.sendLinkToDestination( createMockFormattedLink('src/file.ts#L10'), TEST_STATUS_MESSAGE, - 'both', ), ).toThrowRangeLinkExtensionErrorAsync('DESTINATION_NOT_IMPLEMENTED', { message: @@ -1617,7 +1596,7 @@ describe('PasteDestinationManager', () => { mockTerminalDest.pasteLink.mockResolvedValueOnce(false); const formattedLink = createMockFormattedLink('src/file.ts#L10'); - await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE, 'both'); + await manager.sendLinkToDestination(formattedLink, TEST_STATUS_MESSAGE); expect(mockLogger.error).toHaveBeenCalledWith( { @@ -1625,7 +1604,6 @@ describe('PasteDestinationManager', () => { destinationKind: 'terminal', displayName: 'Terminal ("bash")', formattedLink, - paddingMode: 'both', terminalName: 'bash', }, 'Paste link failed to Terminal ("bash")', @@ -2085,28 +2063,67 @@ describe('PasteDestinationManager', () => { }); describe('isClipboardRestorationApplicable()', () => { - it('returns true when no destination is bound', () => { - expect(manager.isClipboardRestorationApplicable()).toBe(true); + it('returns true when no destination is bound (success path)', () => { + expect(manager.isClipboardRestorationApplicable(true)).toBe(true); }); - it('returns true when terminal is bound', async () => { + it('returns true when no destination is bound (failure path)', () => { + expect(manager.isClipboardRestorationApplicable(false)).toBe(true); + }); + + it('returns true when terminal is bound and paste succeeded', async () => { const mockTerminal = createMockTerminal(); mockAdapter.__getVscodeInstance().window.activeTerminal = mockTerminal; await manager.bind({ kind: 'terminal', terminal: mockTerminal }); - expect(manager.isClipboardRestorationApplicable()).toBe(true); + expect(manager.isClipboardRestorationApplicable(true)).toBe(true); }); - it('returns true when built-in AI assistant is bound', async () => { + it('returns true when built-in AI assistant is bound and paste succeeded', async () => { const { manager: localManager } = createManager({ envOptions: { appName: 'Cursor' }, }); await localManager.bind({ kind: 'cursor-ai' }); - expect(localManager.isClipboardRestorationApplicable()).toBe(true); + expect(localManager.isClipboardRestorationApplicable(true)).toBe(true); localManager.dispose(); }); + + it('returns false when shouldPreserveClipboard() returns false regardless of paste result', () => { + const mockDest = createMockTerminalPasteDestination({ + shouldPreserveClipboard: jest.fn().mockReturnValue(false), + }); + (manager as any).boundDestination = mockDest; + + expect(manager.isClipboardRestorationApplicable(false)).toBe(false); + expect(manager.isClipboardRestorationApplicable(true)).toBe(false); + }); + + it('returns false when paste failed and destination provides a failure instruction', () => { + const mockDest = createMockTerminalPasteDestination({ + shouldPreserveClipboard: jest.fn().mockReturnValue(true), + getUserInstruction: jest + .fn() + .mockImplementation((result: AutoPasteResult) => + result === AutoPasteResult.Failure ? 'Paste (Cmd+V) to use.' : undefined, + ), + }); + (manager as any).boundDestination = mockDest; + + expect(manager.isClipboardRestorationApplicable(false)).toBe(false); + expect(mockDest.getUserInstruction).toHaveBeenCalledWith(AutoPasteResult.Failure); + }); + + it('returns true when paste failed and destination provides no failure instruction', () => { + const mockDest = createMockTerminalPasteDestination({ + shouldPreserveClipboard: jest.fn().mockReturnValue(true), + getUserInstruction: jest.fn().mockReturnValue(undefined), + }); + (manager as any).boundDestination = mockDest; + + expect(manager.isClipboardRestorationApplicable(false)).toBe(true); + }); }); describe('dispose()', () => { @@ -2539,10 +2556,10 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'terminal', terminal: mockTerminal }); mockTerminalDest.pasteContent.mockResolvedValueOnce(true); - const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS, 'none'); + const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS); expect(result).toBe(true); - expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(TEST_CONTENT, 'none'); + expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(TEST_CONTENT); expect(mockVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith( 1, @@ -2566,10 +2583,10 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'github-copilot-chat' }); mockDestination.pasteContent.mockResolvedValueOnce(true); - const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS, 'none'); + const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS); expect(result).toBe(true); - expect(mockDestination.pasteContent).toHaveBeenCalledWith(TEST_CONTENT, 'none'); + expect(mockDestination.pasteContent).toHaveBeenCalledWith(TEST_CONTENT); expect(mockVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith( 1, @@ -2589,10 +2606,10 @@ describe('PasteDestinationManager', () => { mockTerminalDest.pasteContent.mockResolvedValueOnce(false); - const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS, 'none'); + const result = await manager.sendTextToDestination(TEST_CONTENT, TEST_STATUS); expect(result).toBe(false); - expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(TEST_CONTENT, 'none'); + expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(TEST_CONTENT); expect(mockLogger.error).toHaveBeenCalledWith( { @@ -2600,7 +2617,6 @@ describe('PasteDestinationManager', () => { contentLength: TEST_CONTENT.length, displayName: 'Terminal ("bash")', destinationKind: 'terminal', - paddingMode: 'none', terminalName: 'bash', }, 'Paste content failed to Terminal ("bash")', @@ -2622,10 +2638,10 @@ describe('PasteDestinationManager', () => { await manager.bind({ kind: 'terminal', terminal: mockTerminal }); mockTerminalDest.pasteContent.mockResolvedValueOnce(true); - const result = await manager.sendTextToDestination(largeContent, TEST_STATUS, 'none'); + const result = await manager.sendTextToDestination(largeContent, TEST_STATUS); expect(result).toBe(true); - expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(largeContent, 'none'); + expect(mockTerminalDest.pasteContent).toHaveBeenCalledWith(largeContent); expect(mockVscode.window.setStatusBarMessage).toHaveBeenCalledTimes(2); expect(mockVscode.window.setStatusBarMessage).toHaveBeenNthCalledWith( diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts index 95ae39a53..2cf2d6f66 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts @@ -6,12 +6,7 @@ import { EditorFocusCapability } from '../../../destinations/capabilities/Editor import { FocusCapabilityFactory } from '../../../destinations/capabilities/FocusCapabilityFactory'; import { LazyResolvedFocusCapability } from '../../../destinations/capabilities/LazyResolvedFocusCapability'; import { TerminalFocusCapability } from '../../../destinations/capabilities/TerminalFocusCapability'; -import { - createMockClipboardPreserver, - createMockTerminal, - createMockUri, - createMockVscodeAdapter, -} from '../../helpers'; +import { createMockTerminal, createMockUri, createMockVscodeAdapter } from '../../helpers'; describe('FocusCapabilityFactory', () => { let factory: FocusCapabilityFactory; @@ -19,8 +14,7 @@ describe('FocusCapabilityFactory', () => { beforeEach(() => { const mockLogger = createMockLogger(); const mockAdapter = createMockVscodeAdapter(); - const mockClipboardPreserver = createMockClipboardPreserver(); - factory = new FocusCapabilityFactory(mockAdapter, mockClipboardPreserver, mockLogger); + factory = new FocusCapabilityFactory(mockAdapter, mockLogger); }); it('creates EditorFocusCapability', () => { @@ -38,10 +32,7 @@ describe('FocusCapabilityFactory', () => { }); it('creates AIAssistantFocusCapability', () => { - const capability = factory.createAIAssistantCapability( - ['workbench.action.chat.open'], - ['editor.action.clipboardPasteAction'], - ); + const capability = factory.createAIAssistantCapability(['workbench.action.chat.open']); expect(capability).toBeInstanceOf(AIAssistantFocusCapability); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts index 3e63147ed..3da2eae8e 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts @@ -1,177 +1,41 @@ import { createMockLogger } from 'barebone-logger-testing'; -import type { ClipboardPreserver } from '../../../../clipboard/ClipboardPreserver'; -import { FOCUS_TO_PASTE_DELAY_MS } from '../../../../constants/chatPasteConstants'; import { AIAssistantInsertFactory } from '../../../../destinations/capabilities/insertFactories/aiAssistantInsertFactory'; -import { createMockClipboardPreserver, createMockVscodeAdapter } from '../../../helpers'; +import { createMockVscodeAdapter } from '../../../helpers'; describe('AIAssistantInsertFactory', () => { let mockLogger: ReturnType; - let mockClipboardPreserver: jest.Mocked; beforeEach(() => { mockLogger = createMockLogger(); - mockClipboardPreserver = createMockClipboardPreserver(); - jest.useFakeTimers(); }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('creates an insert function that copies to clipboard and executes paste command', async () => { + it('delegates to ideAdapter.pasteTextFromClipboard and returns true on success', async () => { const mockAdapter = createMockVscodeAdapter(); - const clipboardSpy = jest - .spyOn(mockAdapter, 'writeTextToClipboard') - .mockResolvedValue(undefined); - const executeCommandSpy = jest - .spyOn(mockAdapter, 'executeCommand') - .mockResolvedValue(undefined); + const pasteSpy = jest.spyOn(mockAdapter, 'pasteTextFromClipboard').mockResolvedValue(true); - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); + const factory = new AIAssistantInsertFactory(mockAdapter, mockLogger); const insertFn = factory.forTarget(); - const resultPromise = insertFn('test content'); - await jest.advanceTimersByTimeAsync(FOCUS_TO_PASTE_DELAY_MS); - const result = await resultPromise; + const result = await insertFn('test content'); expect(result).toBe(true); - expect(clipboardSpy).toHaveBeenCalledWith('test content'); - expect(executeCommandSpy).toHaveBeenCalledWith('editor.action.clipboardPasteAction'); - expect(mockLogger.debug).toHaveBeenCalledWith( - { fn: 'AIAssistantInsertFactory.insert', textLength: 12 }, - 'Copied text to clipboard', - ); - expect(mockLogger.info).toHaveBeenCalledWith( - { fn: 'AIAssistantInsertFactory.insert', command: 'editor.action.clipboardPasteAction' }, - 'Clipboard paste succeeded', - ); + expect(pasteSpy).toHaveBeenCalled(); }); - it('waits for focus-to-paste delay before executing paste command', async () => { + it('returns false when pasteTextFromClipboard returns false', async () => { const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); - const executeCommandSpy = jest - .spyOn(mockAdapter, 'executeCommand') - .mockResolvedValue(undefined); + jest.spyOn(mockAdapter, 'pasteTextFromClipboard').mockResolvedValue(false); - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); - const insertFn = factory.forTarget(); - - const resultPromise = insertFn('content'); - - expect(executeCommandSpy).not.toHaveBeenCalled(); - - await jest.advanceTimersByTimeAsync(FOCUS_TO_PASTE_DELAY_MS); - await resultPromise; - - expect(executeCommandSpy).toHaveBeenCalled(); - }); - - it('tries multiple paste commands until one succeeds', async () => { - const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); - const executeCommandSpy = jest - .spyOn(mockAdapter, 'executeCommand') - .mockRejectedValueOnce(new Error('First failed')) - .mockResolvedValueOnce(undefined); - - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['command.first', 'command.second'], - mockClipboardPreserver, - mockLogger, - ); - const insertFn = factory.forTarget(); - - const resultPromise = insertFn('content'); - await jest.advanceTimersByTimeAsync(FOCUS_TO_PASTE_DELAY_MS); - const result = await resultPromise; - - expect(result).toBe(true); - expect(executeCommandSpy).toHaveBeenCalledTimes(2); - expect(executeCommandSpy).toHaveBeenNthCalledWith(1, 'command.first'); - expect(executeCommandSpy).toHaveBeenNthCalledWith(2, 'command.second'); - }); - - it('returns false when all paste commands fail', async () => { - const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); - jest - .spyOn(mockAdapter, 'executeCommand') - .mockRejectedValueOnce(new Error('First failed')) - .mockRejectedValueOnce(new Error('Second failed')); - - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['command.first', 'command.second'], - mockClipboardPreserver, - mockLogger, - ); - const insertFn = factory.forTarget(); - - const resultPromise = insertFn('content'); - await jest.advanceTimersByTimeAsync(FOCUS_TO_PASTE_DELAY_MS); - const result = await resultPromise; - - expect(result).toBe(false); - expect(mockLogger.info).toHaveBeenCalledWith( - { fn: 'AIAssistantInsertFactory.insert', allCommandsFailed: true }, - 'All clipboard paste commands failed', - ); - }); - - it('returns false when clipboard write fails', async () => { - const mockAdapter = createMockVscodeAdapter(); - const clipboardError = new Error('Clipboard access denied'); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockRejectedValue(clipboardError); - const executeCommandSpy = jest.spyOn(mockAdapter, 'executeCommand'); - - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, - ); + const factory = new AIAssistantInsertFactory(mockAdapter, mockLogger); const insertFn = factory.forTarget(); const result = await insertFn('content'); expect(result).toBe(false); - expect(executeCommandSpy).not.toHaveBeenCalled(); expect(mockLogger.warn).toHaveBeenCalledWith( - { fn: 'AIAssistantInsertFactory.insert', error: clipboardError }, - 'Failed to write to clipboard', - ); - }); - - it('delegates wrapping to the injected ClipboardPreserver', async () => { - const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); - jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); - - const factory = new AIAssistantInsertFactory( - mockAdapter, - ['editor.action.clipboardPasteAction'], - mockClipboardPreserver, - mockLogger, + { fn: 'AIAssistantInsertFactory.insert', allCommandsFailed: true }, + 'Clipboard paste command failed', ); - const insertFn = factory.forTarget(); - - const resultPromise = insertFn('content'); - await jest.advanceTimersByTimeAsync(FOCUS_TO_PASTE_DELAY_MS); - await resultPromise; - - expect(mockClipboardPreserver.preserve).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts index c0595a83e..a11d6c80f 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts @@ -13,56 +13,22 @@ describe('ManualPasteInsertFactory', () => { mockLogger = createMockLogger(); }); - it('writes text to clipboard and returns true', async () => { + it('returns true without touching the clipboard or executing commands', async () => { const mockAdapter = createMockVscodeAdapter(); - const clipboardSpy = jest - .spyOn(mockAdapter, 'writeTextToClipboard') - .mockResolvedValue(undefined); + const clipboardSpy = jest.spyOn(mockAdapter, 'writeTextToClipboard'); + const executeCommandSpy = jest.spyOn(mockAdapter, 'executeCommand'); - const factory = new ManualPasteInsertFactory(mockAdapter, mockLogger); + const factory = new ManualPasteInsertFactory(mockLogger); const insertFn = factory.forTarget(); const result = await insertFn(LINK_TEXT); expect(result).toBe(true); - expect(clipboardSpy).toHaveBeenCalledWith('src/app.ts#L10-L20'); - expect(mockLogger.info).toHaveBeenCalledWith( - { fn: 'ManualPasteInsertFactory.insert', textLength: LINK_TEXT_LENGTH }, - 'Link copied to clipboard for manual paste', - ); - }); - - it('does not execute any paste commands', async () => { - const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockResolvedValue(undefined); - const executeCommandSpy = jest.spyOn(mockAdapter, 'executeCommand'); - - const factory = new ManualPasteInsertFactory(mockAdapter, mockLogger); - const insertFn = factory.forTarget(); - - await insertFn(LINK_TEXT); - + expect(clipboardSpy).not.toHaveBeenCalled(); expect(executeCommandSpy).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'ManualPasteInsertFactory.insert', textLength: LINK_TEXT_LENGTH }, - 'Link copied to clipboard for manual paste', - ); - }); - - it('returns false when clipboard write fails', async () => { - const mockAdapter = createMockVscodeAdapter(); - const clipboardError = new Error('Clipboard access denied'); - jest.spyOn(mockAdapter, 'writeTextToClipboard').mockRejectedValue(clipboardError); - - const factory = new ManualPasteInsertFactory(mockAdapter, mockLogger); - const insertFn = factory.forTarget(); - - const result = await insertFn(LINK_TEXT); - - expect(result).toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( - { fn: 'ManualPasteInsertFactory.insert', error: clipboardError }, - 'Failed to write to clipboard', + 'Link ready for manual paste', ); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts index ddde90a44..d67991d24 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts @@ -1,56 +1,49 @@ import { createMockLogger } from 'barebone-logger-testing'; -import type { ClipboardPreserver } from '../../../../clipboard/ClipboardPreserver'; import { TerminalInsertFactory } from '../../../../destinations/capabilities/insertFactories/terminalInsertFactory'; -import { - createMockClipboardPreserver, - createMockTerminal, - createMockVscodeAdapter, -} from '../../../helpers'; +import { createMockTerminal, createMockVscodeAdapter } from '../../../helpers'; describe('TerminalInsertFactory', () => { let mockLogger: ReturnType; - let mockClipboardPreserver: jest.Mocked; beforeEach(() => { mockLogger = createMockLogger(); - mockClipboardPreserver = createMockClipboardPreserver(); }); - it('creates an insert function that pastes text to terminal via clipboard', async () => { + it('delegates to ideAdapter.pasteIntoTerminal and returns true on success', async () => { const mockAdapter = createMockVscodeAdapter(); const mockTerminal = createMockTerminal({ name: 'My Terminal' }); - const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') + const pasteIntoTerminalSpy = jest + .spyOn(mockAdapter, 'pasteIntoTerminal') .mockResolvedValue(undefined); - const factory = new TerminalInsertFactory(mockAdapter, mockClipboardPreserver, mockLogger); + const factory = new TerminalInsertFactory(mockAdapter, mockLogger); const insertFn = factory.forTarget(mockTerminal); const result = await insertFn('test content'); expect(result).toBe(true); - expect(pasteTextSpy).toHaveBeenCalledTimes(1); - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, 'test content'); + expect(pasteIntoTerminalSpy).toHaveBeenCalledTimes(1); + expect(pasteIntoTerminalSpy).toHaveBeenCalledWith(mockTerminal); expect(mockLogger.info).toHaveBeenCalledWith( - { fn: 'TerminalInsertFactory.insert', terminalName: 'My Terminal', textLength: 12 }, + { fn: 'TerminalInsertFactory.insert', terminalName: 'My Terminal' }, 'Terminal paste succeeded', ); }); - it('returns false when paste throws an error', async () => { + it('returns false when pasteIntoTerminal throws', async () => { const mockAdapter = createMockVscodeAdapter(); const mockTerminal = createMockTerminal({ name: 'My Terminal' }); const testError = new Error('Paste failed'); - jest.spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard').mockRejectedValue(testError); + jest.spyOn(mockAdapter, 'pasteIntoTerminal').mockRejectedValue(testError); - const factory = new TerminalInsertFactory(mockAdapter, mockClipboardPreserver, mockLogger); + const factory = new TerminalInsertFactory(mockAdapter, mockLogger); const insertFn = factory.forTarget(mockTerminal); const result = await insertFn('content'); expect(result).toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( + expect(mockLogger.error).toHaveBeenCalledWith( { fn: 'TerminalInsertFactory.insert', terminalName: 'My Terminal', error: testError }, 'Terminal paste failed', ); @@ -60,35 +53,18 @@ describe('TerminalInsertFactory', () => { const mockAdapter = createMockVscodeAdapter(); const terminal1 = createMockTerminal({ name: 'Terminal 1' }); const terminal2 = createMockTerminal({ name: 'Terminal 2' }); - const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') + const pasteIntoTerminalSpy = jest + .spyOn(mockAdapter, 'pasteIntoTerminal') .mockResolvedValue(undefined); - const factory = new TerminalInsertFactory(mockAdapter, mockClipboardPreserver, mockLogger); + const factory = new TerminalInsertFactory(mockAdapter, mockLogger); const insertFn1 = factory.forTarget(terminal1); const insertFn2 = factory.forTarget(terminal2); await insertFn1('content for terminal 1'); await insertFn2('content for terminal 2'); - expect(pasteTextSpy).toHaveBeenNthCalledWith(1, terminal1, 'content for terminal 1'); - expect(pasteTextSpy).toHaveBeenNthCalledWith(2, terminal2, 'content for terminal 2'); - }); - - it('delegates wrapping to the injected ClipboardPreserver', async () => { - const mockAdapter = createMockVscodeAdapter(); - jest.spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard').mockResolvedValue(undefined); - const mockTerminal = createMockTerminal({ name: 'My Terminal' }); - - const factory = new TerminalInsertFactory(mockAdapter, mockClipboardPreserver, mockLogger); - const insertFn = factory.forTarget(mockTerminal); - - await insertFn('content'); - - expect(mockClipboardPreserver.preserve).toHaveBeenCalledTimes(1); - expect(mockLogger.info).toHaveBeenCalledWith( - { fn: 'TerminalInsertFactory.insert', terminalName: 'My Terminal', textLength: 7 }, - 'Terminal paste succeeded', - ); + expect(pasteIntoTerminalSpy).toHaveBeenNthCalledWith(1, terminal1); + expect(pasteIntoTerminalSpy).toHaveBeenNthCalledWith(2, terminal2); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/helpers/destinationTestHelpers.ts b/packages/rangelink-vscode-extension/src/__tests__/helpers/destinationTestHelpers.ts index 8d2f330f3..0ac25695d 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/helpers/destinationTestHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/helpers/destinationTestHelpers.ts @@ -8,12 +8,9 @@ import type { Logger } from 'barebone-logger'; import type { PasteDestination } from '../../destinations'; -import type { PaddingMode } from '../../utils/applySmartPadding'; import { createMockFormattedLink } from './createMockFormattedLink'; -const UNUSED_PADDING_MODE = 'parameter not used' as unknown as PaddingMode; - /** * Test logging behavior for a destination * @@ -61,19 +58,13 @@ export const testPasteReturnValues = ( describe('pasteLink() return values', () => { it('should return true on successful paste', async () => { await successScenario(); - const result = await destination.pasteLink( - createMockFormattedLink('test'), - UNUSED_PADDING_MODE, - ); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(true); }); it('should return false when destination not available', async () => { await failureScenario(); - const result = await destination.pasteLink( - createMockFormattedLink('test'), - UNUSED_PADDING_MODE, - ); + const result = await destination.pasteLink(createMockFormattedLink('test')); expect(result).toBe(false); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts index f9dece89a..2809c8a82 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts @@ -2,7 +2,6 @@ import type { Logger } from 'barebone-logger'; import { createMockLogger } from 'barebone-logger-testing'; import { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; -import { BehaviourAfterPaste } from '../../../types/BehaviourAfterPaste'; import { PathFormat } from '../../../types/PathFormat'; import { RelativePathFormat } from '../../../types/RelativePathFormat'; import { TerminalFocusType } from '../../../types/TerminalFocusType'; @@ -737,102 +736,39 @@ describe('VscodeAdapter', () => { }); }); - describe('pasteTextToTerminalViaClipboard', () => { - it('should write to clipboard, show terminal, and execute paste command with default behavior', async () => { + describe('pasteIntoTerminal', () => { + it('should show terminal and execute paste command', async () => { const mockTerminal = createMockTerminal(); - const text = 'test text for paste'; - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, text); + await adapter.pasteIntoTerminal(mockTerminal); - expect(writeToClipboardSpy).toHaveBeenCalledWith(text); - expect(writeToClipboardSpy).toHaveBeenCalledTimes(1); expect(mockTerminal.show).toHaveBeenCalledTimes(1); expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); expect(executeCommandSpy).toHaveBeenCalledTimes(1); expect(mockTerminal.sendText).not.toHaveBeenCalled(); }); - it('should use NOTHING behaviour when explicitly specified', async () => { - const mockTerminal = createMockTerminal(); - const text = 'test text'; - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); - - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, text, { - behaviour: BehaviourAfterPaste.NOTHING, - }); - - expect(writeToClipboardSpy).toHaveBeenCalledWith(text); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); - expect(mockTerminal.sendText).not.toHaveBeenCalled(); - }); - - it('should send Enter after paste when EXECUTE behaviour specified', async () => { - const mockTerminal = createMockTerminal(); - const text = 'command to execute'; - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); - - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, text, { - behaviour: BehaviourAfterPaste.EXECUTE, - }); - - expect(writeToClipboardSpy).toHaveBeenCalledWith(text); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); - expect(mockTerminal.sendText).toHaveBeenCalledWith('', true); - expect(mockTerminal.sendText).toHaveBeenCalledTimes(1); - }); - - it('should handle empty text', async () => { - const mockTerminal = createMockTerminal(); - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); + it('should log terminal name before executing command', async () => { + const mockTerminal = createMockTerminal({ name: 'my-terminal' }); + jest.spyOn(adapter, 'executeCommand'); - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, ''); + await adapter.pasteIntoTerminal(mockTerminal); - expect(writeToClipboardSpy).toHaveBeenCalledWith(''); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); - }); - - it('should handle long text (130+ characters)', async () => { - const mockTerminal = createMockTerminal(); - const longText = 'a'.repeat(150); - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); - - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, longText); - - expect(writeToClipboardSpy).toHaveBeenCalledWith(longText); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); - }); - - it('should handle multi-line text', async () => { - const mockTerminal = createMockTerminal(); - const multiLineText = 'line1\nline2\nline3'; - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); - - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, multiLineText); - - expect(writeToClipboardSpy).toHaveBeenCalledWith(multiLineText); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.pasteIntoTerminal', terminalName: 'my-terminal' }, + 'Executing terminal paste command', + ); }); it('should throw TERMINAL_NOT_DEFINED when terminal is undefined', async () => { const undefinedTerminal = undefined as any; await expect(async () => - adapter.pasteTextToTerminalViaClipboard(undefinedTerminal, 'text'), + adapter.pasteIntoTerminal(undefinedTerminal), ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { message: 'Terminal reference is not defined', - functionName: 'VscodeAdapter.pasteTextToTerminalViaClipboard', + functionName: 'VscodeAdapter.pasteIntoTerminal', }); }); @@ -840,10 +776,10 @@ describe('VscodeAdapter', () => { const nullTerminal = null as any; await expect(async () => - adapter.pasteTextToTerminalViaClipboard(nullTerminal, 'text'), + adapter.pasteIntoTerminal(nullTerminal), ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { message: 'Terminal reference is not defined', - functionName: 'VscodeAdapter.pasteTextToTerminalViaClipboard', + functionName: 'VscodeAdapter.pasteIntoTerminal', }); }); }); diff --git a/packages/rangelink-vscode-extension/src/bookmarks/BookmarkService.ts b/packages/rangelink-vscode-extension/src/bookmarks/BookmarkService.ts index 96db81e59..8c2c723ca 100644 --- a/packages/rangelink-vscode-extension/src/bookmarks/BookmarkService.ts +++ b/packages/rangelink-vscode-extension/src/bookmarks/BookmarkService.ts @@ -3,9 +3,7 @@ import type { Logger } from 'barebone-logger'; import type { ConfigReader } from '../config/ConfigReader'; import { DEFAULT_FEATURES_BOOKMARKS_ENABLED, - DEFAULT_SMART_PADDING_PASTE_BOOKMARK, SETTING_FEATURES_BOOKMARKS_ENABLED, - SETTING_SMART_PADDING_PASTE_BOOKMARK, } from '../constants'; import type { PasteDestinationManager } from '../destinations'; import { RangeLinkExtensionError } from '../errors/RangeLinkExtensionError'; @@ -108,14 +106,9 @@ export class BookmarkService { await this.bookmarksStore.recordAccess(bookmarkId); await this.ideAdapter.writeTextToClipboard(bookmark.link); - const paddingMode = this.configReader.getPaddingMode( - SETTING_SMART_PADDING_PASTE_BOOKMARK, - DEFAULT_SMART_PADDING_PASTE_BOOKMARK, - ); await this.destinationManager.sendTextToDestination( bookmark.link, `Bookmark pasted: ${bookmark.label}`, - paddingMode, ); this.logger.debug({ ...logCtx, bookmark }, `Sent bookmark to destination: ${bookmark.label}`); } diff --git a/packages/rangelink-vscode-extension/src/bookmarks/__tests__/BookmarkService.test.ts b/packages/rangelink-vscode-extension/src/bookmarks/__tests__/BookmarkService.test.ts index a2f631701..c9104dfc6 100644 --- a/packages/rangelink-vscode-extension/src/bookmarks/__tests__/BookmarkService.test.ts +++ b/packages/rangelink-vscode-extension/src/bookmarks/__tests__/BookmarkService.test.ts @@ -98,7 +98,6 @@ describe('BookmarkService', () => { expect(mockDestinationManager.sendTextToDestination).toHaveBeenCalledWith( 'src/features/my-feature.ts#L10-L20', 'Bookmark pasted: My Feature', - 'both', ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'BookmarkService.sendBookmark', bookmarkId: 'bookmark-1', bookmark: TEST_BOOKMARK }, diff --git a/packages/rangelink-vscode-extension/src/constants/chatPasteConstants.ts b/packages/rangelink-vscode-extension/src/constants/chatPasteConstants.ts deleted file mode 100644 index 1ac910cd7..000000000 --- a/packages/rangelink-vscode-extension/src/constants/chatPasteConstants.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Constants for automatic paste to AI chat destinations - */ - -/** - * System-level paste commands to attempt when auto-pasting to chat interfaces. - * - * Ordered by reliability (most reliable first): - * 1. editor.action.clipboardPasteAction - Standard VSCode editor paste - * 2. execPaste - System-wide paste (more aggressive) - * 3. paste - Generic fallback - */ -export const CHAT_PASTE_COMMANDS = [ - 'editor.action.clipboardPasteAction', - 'execPaste', - 'paste', -] as const; - -/** - * Delay in milliseconds between focusing chat interface and attempting paste. - * - * Allows focus operation to complete before executing paste command. - * Confirmed working at 200ms for both Claude Code and Cursor AI. - */ -export const FOCUS_TO_PASTE_DELAY_MS = 200; diff --git a/packages/rangelink-vscode-extension/src/constants/index.ts b/packages/rangelink-vscode-extension/src/constants/index.ts index 93f5f770d..d75ea6231 100644 --- a/packages/rangelink-vscode-extension/src/constants/index.ts +++ b/packages/rangelink-vscode-extension/src/constants/index.ts @@ -1,6 +1,6 @@ -export * from './chatPasteConstants'; export * from './commandIds'; export * from './contextKeys'; +export * from './pasteTimingConstants'; export * from './settingDefaults'; export * from './settingKeys'; export * from './vscodeCommandIds'; diff --git a/packages/rangelink-vscode-extension/src/constants/pasteTimingConstants.ts b/packages/rangelink-vscode-extension/src/constants/pasteTimingConstants.ts new file mode 100644 index 000000000..096615b5f --- /dev/null +++ b/packages/rangelink-vscode-extension/src/constants/pasteTimingConstants.ts @@ -0,0 +1,20 @@ +/** + * Constants for automatic paste to AI chat destinations. + */ + +/** + * Delay between focusing a chat interface and attempting paste, for a panel + * that is already visible (warm). The shorter delay is sufficient because + * the webview context is retained (retainContextWhenHidden) and the input + * just needs a brief moment to regain focus. + */ +export const FOCUS_TO_PASTE_DELAY_MS = 200; + +/** + * Delay after a successful clipboard paste command before allowing the + * outer ClipboardPreserver to restore the user's prior clipboard. Webview-based + * AI assistants (Claude Code) read from the system clipboard + * asynchronously across the Electron IPC boundary — this delay keeps the + * RangeLink on the clipboard until the webview's paste handler completes. + */ +export const CLIPBOARD_POST_PASTE_DELAY_MS = 200; diff --git a/packages/rangelink-vscode-extension/src/createWiringServices.ts b/packages/rangelink-vscode-extension/src/createWiringServices.ts index 6830531ac..8d689018a 100644 --- a/packages/rangelink-vscode-extension/src/createWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/createWiringServices.ts @@ -92,7 +92,7 @@ export const createWiringServices = ( logger.debug({ fn: 'createWiringServices' }, 'Bookmarks store initialized'); const clipboardPreserver = new DefaultClipboardPreserver(ideAdapter, configReader, logger); - const focusCapabilityFactory = new FocusCapabilityFactory(ideAdapter, clipboardPreserver, logger); + const focusCapabilityFactory = new FocusCapabilityFactory(ideAdapter, logger); const eligibilityCheckerFactory = new EligibilityCheckerFactory(logger); const registry = new DestinationRegistry( focusCapabilityFactory, diff --git a/packages/rangelink-vscode-extension/src/destinations/ComposablePasteDestination.ts b/packages/rangelink-vscode-extension/src/destinations/ComposablePasteDestination.ts index d619bfb0d..656cf00c6 100644 --- a/packages/rangelink-vscode-extension/src/destinations/ComposablePasteDestination.ts +++ b/packages/rangelink-vscode-extension/src/destinations/ComposablePasteDestination.ts @@ -9,7 +9,6 @@ import { type DestinationKind, PasteContentType, } from '../types'; -import { applySmartPadding, type PaddingMode } from '../utils'; import { ContentEligibilityChecker } from './capabilities/ContentEligibilityChecker'; import type { EligibilityChecker } from './capabilities/EligibilityChecker'; @@ -270,37 +269,35 @@ export class ComposablePasteDestination implements PasteDestination { * Paste a RangeLink to this destination with appropriate padding and focus. * * @param formattedLink - The formatted RangeLink with metadata - * @param paddingMode - How to apply smart padding (both, before, after, none) + * @returns Promise resolving to true if paste succeeded, false otherwise */ - async pasteLink(formattedLink: FormattedLink, paddingMode: PaddingMode): Promise { + async pasteLink(formattedLink: FormattedLink): Promise { const context: LoggingContext = { fn: `${this.constructor.name}.pasteLink`, formattedLink, linkLength: formattedLink.link.length, - paddingMode, ...this.loggingDetails, }; - return this.performPaste(formattedLink.link, context, PasteContentType.Link, paddingMode); + return this.performPaste(formattedLink.link, context, PasteContentType.Link); } /** * Paste text content to this destination with appropriate padding and focus. * * @param content - The text content to paste - * @param paddingMode - How to apply smart padding (both, before, after, none) + * @returns Promise resolving to true if paste succeeded, false otherwise */ - async pasteContent(content: string, paddingMode: PaddingMode): Promise { + async pasteContent(content: string): Promise { const context: LoggingContext = { fn: `${this.constructor.name}.pasteContent`, contentLength: content.length, - paddingMode, ...this.loggingDetails, }; - return this.performPaste(content, context, PasteContentType.Text, paddingMode); + return this.performPaste(content, context, PasteContentType.Text); } /** @@ -308,23 +305,24 @@ export class ComposablePasteDestination implements PasteDestination { * * Coordinates capabilities in order: * 1. Check availability - * 2. Apply smart padding based on provided mode - * 3. Focus destination - * 4. Insert text + * 2. Focus destination + * 3. Insert text + * + * Smart padding is applied by the caller before constructing + * CopyAndSendOptions.content.clipboard — the text arriving here is + * already padded and already on the clipboard. * * Eligibility is checked by RangeLinkService before calling this method. * - * @param text - The text to paste + * @param text - The text to paste (already padded by caller) * @param context - Logging context with operation details * @param contentType - Type of content being pasted (for log messages) - * @param paddingMode - How to apply smart padding (both, before, after, none) * @returns Promise resolving to true if paste succeeded, false otherwise */ private async performPaste( text: string, context: LoggingContext, contentType: PasteContentType, - paddingMode: PaddingMode, ): Promise { const contentLabel = contentType === PasteContentType.Link ? 'link' : 'content'; @@ -335,8 +333,6 @@ export class ComposablePasteDestination implements PasteDestination { return false; } - const paddedText = applySmartPadding(text, paddingMode); - const focusResult = await this.focusCapability.focus(context); if (!focusResult.success) { @@ -347,7 +343,7 @@ export class ComposablePasteDestination implements PasteDestination { return false; } - const success = await focusResult.value.inserter(paddedText); + const success = await focusResult.value.inserter(text); if (success) { this.logger.info(context, `Pasted ${contentLabel} to ${this.displayName}`); diff --git a/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts b/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts index fc9ac1211..d4b75fd00 100644 --- a/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts +++ b/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts @@ -2,7 +2,6 @@ import type { FormattedLink } from 'rangelink-core-ts'; import type * as vscode from 'vscode'; import type { AutoPasteResult, DestinationKind } from '../types'; -import type { PaddingMode } from '../utils/applySmartPadding'; /** * Interface for RangeLink paste destinations @@ -72,39 +71,40 @@ export interface PasteDestination { isEligibleForPasteContent(content: string): Promise; /** - * Paste a RangeLink to this destination with appropriate padding and focus + * Paste a RangeLink to this destination with focus and insert. + * + * Padding is applied upstream by call sites before the clipboard write. + * The text arriving here is already padded and already on the clipboard. * * Implementation requirements: * - Check eligibility internally (defensive programming) - * - Apply padding based on provided paddingMode * - Focus destination after paste (terminal.show(), chat.open(), etc.) * - Log success/failure for debugging * - Return false on failure or ineligibility (no throwing) * * @param formattedLink - The formatted RangeLink with metadata - * @param paddingMode - How to apply smart padding (both, before, after, none) * @returns Promise resolving to true if paste succeeded, false otherwise */ - pasteLink(formattedLink: FormattedLink, paddingMode: PaddingMode): Promise; + pasteLink(formattedLink: FormattedLink): Promise; /** - * Paste text content to this destination with appropriate padding and focus + * Paste text content to this destination with focus and insert. + * + * Padding is applied upstream by call sites before the clipboard write. + * The text arriving here is already padded and already on the clipboard. * - * Used for pasting selected text directly to bound destinations (issue #89). * Unlike pasteLink(), this accepts raw text content without link formatting. * * Implementation requirements: * - Check eligibility internally (defensive programming) - * - Apply padding based on provided paddingMode * - Focus destination after paste (terminal.show(), chat.open(), etc.) * - Log success/failure for debugging * - Return false on failure or ineligibility (no throwing) * * @param content - The text content to paste - * @param paddingMode - How to apply smart padding (both, before, after, none) * @returns Promise resolving to true if paste succeeded, false otherwise */ - pasteContent(content: string, paddingMode: PaddingMode): Promise; + pasteContent(content: string): Promise; /** * Get user instruction for manual paste action (clipboard-based destinations only) diff --git a/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts b/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts index 4402c18f3..250f71cac 100644 --- a/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts +++ b/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts @@ -25,7 +25,6 @@ import { isEditorDestination, isTerminalDestination, isWritableScheme, - type PaddingMode, } from '../utils'; import type { DestinationRegistry } from './DestinationRegistry'; @@ -188,19 +187,28 @@ export class PasteDestinationManager implements vscode.Disposable { /** * Whether clipboard content should be restored after a paste operation. * - * Delegates to the bound destination's shouldPreserveClipboard() method. - * Returns true (restore) when no destination is bound or when the - * destination signals that clipboard restoration is appropriate. + * Returns true (restore) when no destination is bound, when paste succeeded + * (the link is already in the AI chat — the user's original clipboard should + * come back), or when the destination has no manual-paste instruction. * - * Returns false when the bound destination resolved to a manual-paste - * tier (focusCommands) — the link must stay on the clipboard for the - * user to paste manually. + * Returns false when paste failed AND the destination provided a failure + * instruction (i.e. the user was told "Paste (Cmd/Ctrl+V) …") — the + * RangeLink must stay on the clipboard for the user to paste manually. + * Also returns false when the destination's shouldPreserveClipboard() signals + * false (Tier 3 custom AI assistants that resolve to focusCommands). */ - isClipboardRestorationApplicable(): boolean { + isClipboardRestorationApplicable(pasteSucceeded: boolean): boolean { if (!this.boundDestination) { return true; } - return this.boundDestination.shouldPreserveClipboard(); + if (!this.boundDestination.shouldPreserveClipboard()) { + return false; + } + if (pasteSucceeded) { + return true; + } + const failureInstruction = this.boundDestination.getUserInstruction?.(AutoPasteResult.Failure); + return failureInstruction === undefined; } /** @@ -269,24 +277,22 @@ export class PasteDestinationManager implements vscode.Disposable { * * @param formattedLink - The formatted RangeLink with metadata * @param basicStatusMessage - Base message for status bar (e.g., "RangeLink copied to clipboard") - * @param paddingMode - How to apply smart padding (both, before, after, none) + * @returns true if sent successfully, false otherwise */ async sendLinkToDestination( formattedLink: FormattedLink, basicStatusMessage: string, - paddingMode: PaddingMode, ): Promise { return this.sendWithFeedback({ basicStatusMessage, logContext: { fn: 'PasteDestinationManager.sendLinkToDestination', formattedLink, - paddingMode, }, debugMessage: (displayName) => `Sending link to ${displayName}`, errorMessage: (displayName) => `Paste link failed to ${displayName}`, - execute: (destination) => destination.pasteLink(formattedLink, paddingMode), + execute: (destination) => destination.pasteLink(formattedLink), }); } @@ -298,24 +304,19 @@ export class PasteDestinationManager implements vscode.Disposable { * * @param content - The text content to send * @param basicStatusMessage - Base message for status bar (e.g., "Text copied to clipboard") - * @param paddingMode - How to apply smart padding (both, before, after, none) + * @returns true if sent successfully, false otherwise */ - async sendTextToDestination( - content: string, - basicStatusMessage: string, - paddingMode: PaddingMode, - ): Promise { + async sendTextToDestination(content: string, basicStatusMessage: string): Promise { return this.sendWithFeedback({ basicStatusMessage, logContext: { fn: 'PasteDestinationManager.sendTextToDestination', contentLength: content.length, - paddingMode, }, debugMessage: (displayName) => `Sending content to ${displayName} (${content.length} chars)`, errorMessage: (displayName) => `Paste content failed to ${displayName}`, - execute: (destination) => destination.pasteContent(content, paddingMode), + execute: (destination) => destination.pasteContent(content), }); } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts index c3f3b4c08..dd5d258d5 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts @@ -1,9 +1,7 @@ import type { Logger } from 'barebone-logger'; import type * as vscode from 'vscode'; -import type { ClipboardPreserver } from '../../clipboard/ClipboardPreserver'; import type { CustomAiAssistantConfig } from '../../config/parseCustomAiAssistants'; -import { CHAT_PASTE_COMMANDS } from '../../constants'; import type { VscodeAdapter } from '../../ide/vscode/VscodeAdapter'; import type { FocusTier } from '../types'; @@ -29,7 +27,6 @@ import { TerminalFocusCapability } from './TerminalFocusCapability'; export class FocusCapabilityFactory { constructor( private readonly ideAdapter: VscodeAdapter, - private readonly clipboardPreserver: ClipboardPreserver, private readonly logger: Logger, ) {} @@ -47,21 +44,16 @@ export class FocusCapabilityFactory { return new TerminalFocusCapability( this.ideAdapter, terminal, - new TerminalInsertFactory(this.ideAdapter, this.clipboardPreserver, this.logger), + new TerminalInsertFactory(this.ideAdapter, this.logger), this.logger, ); } - createAIAssistantCapability(focusCommands: string[], pasteCommands: string[]): FocusCapability { + createAIAssistantCapability(focusCommands: string[]): FocusCapability { return new AIAssistantFocusCapability( this.ideAdapter, focusCommands, - new AIAssistantInsertFactory( - this.ideAdapter, - pasteCommands, - this.clipboardPreserver, - this.logger, - ), + new AIAssistantInsertFactory(this.ideAdapter, this.logger), this.logger, ); } @@ -87,12 +79,7 @@ export class FocusCapabilityFactory { if (config.focusAndPasteCommands && config.focusAndPasteCommands.length > 0) { tiers.push({ commands: config.focusAndPasteCommands, - insertFactory: new AIAssistantInsertFactory( - this.ideAdapter, - [...CHAT_PASTE_COMMANDS], - this.clipboardPreserver, - this.logger, - ), + insertFactory: this.createStandardAIAssistantInsertFactory(), label: 'focusAndPasteCommands', probeMode: 'execute', }); @@ -101,7 +88,7 @@ export class FocusCapabilityFactory { if (config.focusCommands && config.focusCommands.length > 0) { tiers.push({ commands: config.focusCommands, - insertFactory: new ManualPasteInsertFactory(this.ideAdapter, this.logger), + insertFactory: new ManualPasteInsertFactory(this.logger), label: 'focusCommands', probeMode: 'execute', }); @@ -116,17 +103,16 @@ export class FocusCapabilityFactory { buildBuiltinFallbackTier(focusCommands: readonly string[]): FocusTier { return { commands: focusCommands, - insertFactory: new AIAssistantInsertFactory( - this.ideAdapter, - [...CHAT_PASTE_COMMANDS], - this.clipboardPreserver, - this.logger, - ), + insertFactory: this.createStandardAIAssistantInsertFactory(), label: 'builtinFallback', probeMode: 'execute', }; } + private createStandardAIAssistantInsertFactory(): AIAssistantInsertFactory { + return new AIAssistantInsertFactory(this.ideAdapter, this.logger); + } + /** * Create a LazyResolvedFocusCapability from pre-built tiers. * diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/aiAssistantInsertFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/aiAssistantInsertFactory.ts index 9040bfa66..49b6e10b8 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/aiAssistantInsertFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/aiAssistantInsertFactory.ts @@ -1,7 +1,5 @@ import type { Logger } from 'barebone-logger'; -import type { ClipboardPreserver } from '../../../clipboard/ClipboardPreserver'; -import { FOCUS_TO_PASTE_DELAY_MS } from '../../../constants/chatPasteConstants'; import type { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; import type { InsertFactory } from './InsertFactory'; @@ -9,49 +7,29 @@ import type { InsertFactory } from './InsertFactory'; /** * InsertFactory for AI assistant destinations. * - * AI assistants (Claude Code, Cursor AI, GitHub Copilot Chat) use clipboard-based - * paste via VSCode commands. Unlike terminal/editor destinations, there's no - * runtime target - the paste commands are fixed at factory creation time. + * AI assistants (Claude Code, Cursor AI, GitHub Copilot Chat) + * use clipboard-based paste via the VS Code paste command. The clipboard is + * populated once by ClipboardRouter.executeCopyAndSend() before this factory + * is invoked — this factory only executes the paste command and waits for + * webview-based assistants to complete their async clipboard read. */ export class AIAssistantInsertFactory implements InsertFactory { constructor( private readonly ideAdapter: VscodeAdapter, - private readonly pasteCommands: readonly string[], - private readonly clipboardPreserver: ClipboardPreserver, private readonly logger: Logger, ) {} forTarget(): (text: string) => Promise { - return async (text: string): Promise => { + return async (_text: string): Promise => { const fn = 'AIAssistantInsertFactory.insert'; - return this.clipboardPreserver.preserve(async () => { - // Must overwrite clipboard with padded text — RangeLinkService wrote the - // unpadded original, but smart padding was applied after that clipboard write. - // AI assistant paste commands read from clipboard, so it must contain the final text. - try { - await this.ideAdapter.writeTextToClipboard(text); - } catch (error) { - this.logger.warn({ fn, error }, 'Failed to write to clipboard'); - return false; - } - this.logger.debug({ fn, textLength: text.length }, 'Copied text to clipboard'); + const success = await this.ideAdapter.pasteTextFromClipboard(); + if (success) { + return true; + } - await new Promise((resolve) => setTimeout(resolve, FOCUS_TO_PASTE_DELAY_MS)); - - for (const command of this.pasteCommands) { - try { - await this.ideAdapter.executeCommand(command); - this.logger.info({ fn, command }, 'Clipboard paste succeeded'); - return true; - } catch (error) { - this.logger.debug({ fn, command, error }, 'Paste command failed, trying next'); - } - } - - this.logger.info({ fn, allCommandsFailed: true }, 'All clipboard paste commands failed'); - return false; - }); + this.logger.warn({ fn, allCommandsFailed: true }, 'Clipboard paste command failed'); + return false; }; } } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/manualPasteInsertFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/manualPasteInsertFactory.ts index 219afcd24..226f317bb 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/manualPasteInsertFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/manualPasteInsertFactory.ts @@ -1,37 +1,22 @@ import type { Logger } from 'barebone-logger'; -import type { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; - import type { InsertFactory } from './InsertFactory'; /** * InsertFactory for Tier 3 manual-paste destinations. * - * Writes the link text to clipboard and returns true. No paste commands - * are executed — the user pastes manually (Cmd+V) after the toast. - * Clipboard preservation is intentionally NOT used: the link must remain - * on the clipboard until the user pastes. + * Returns true without touching the clipboard — ClipboardRouter already wrote + * the text, and shouldPreserveClipboard() = false ensures it stays for manual paste. + * No paste commands are executed; the user pastes manually (Cmd+V) after the toast. */ export class ManualPasteInsertFactory implements InsertFactory { - constructor( - private readonly ideAdapter: VscodeAdapter, - private readonly logger: Logger, - ) {} + constructor(private readonly logger: Logger) {} forTarget(): (text: string) => Promise { return async (text: string): Promise => { - const fn = 'ManualPasteInsertFactory.insert'; - - try { - await this.ideAdapter.writeTextToClipboard(text); - } catch (error) { - this.logger.warn({ fn, error }, 'Failed to write to clipboard'); - return false; - } - this.logger.info( - { fn, textLength: text.length }, - 'Link copied to clipboard for manual paste', + { fn: 'ManualPasteInsertFactory.insert', textLength: text.length }, + 'Link ready for manual paste', ); return true; }; diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts index 70a20745c..bbce7e2c3 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts @@ -1,41 +1,35 @@ -import type { Logger } from 'barebone-logger'; import type * as vscode from 'vscode'; -import type { ClipboardPreserver } from '../../../clipboard/ClipboardPreserver'; -import type { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; - import type { InsertFactory } from './InsertFactory'; +import type { Logger } from 'barebone-logger'; +import type { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; /** * InsertFactory for terminal destinations. * - * Uses clipboard-based paste via pasteTextToTerminalViaClipboard. + * Shows the terminal and executes the terminal paste command. The clipboard is + * populated once by ClipboardRouter.executeCopyAndSend() before this factory + * is invoked. */ export class TerminalInsertFactory implements InsertFactory { constructor( private readonly ideAdapter: VscodeAdapter, - private readonly clipboardPreserver: ClipboardPreserver, private readonly logger: Logger, ) {} forTarget(terminal: vscode.Terminal): (text: string) => Promise { const terminalName = terminal.name; - return async (text: string): Promise => { - const fn = 'TerminalInsertFactory.insert'; - return this.clipboardPreserver.preserve(async () => { - try { - await this.ideAdapter.pasteTextToTerminalViaClipboard(terminal, text); - this.logger.info( - { fn, terminalName, textLength: text.length }, - 'Terminal paste succeeded', - ); - return true; - } catch (error) { - this.logger.warn({ fn, terminalName, error }, 'Terminal paste failed'); - return false; - } - }); + return async (_text: string): Promise => { + const logCtx = { fn: 'TerminalInsertFactory.insert', terminalName }; + try { + await this.ideAdapter.pasteIntoTerminal(terminal); + this.logger.info(logCtx, 'Terminal paste succeeded'); + return true; + } catch (error) { + this.logger.error({ ...logCtx, error }, 'Terminal paste failed'); + return false; + } }; } } diff --git a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts index 1decc0867..cb87b0af7 100644 --- a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts +++ b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts @@ -8,7 +8,6 @@ import type * as vscode from 'vscode'; import type { CustomAiAssistantConfig } from '../config/parseCustomAiAssistants'; -import { CHAT_PASTE_COMMANDS } from '../constants'; import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; import { AutoPasteResult, @@ -42,7 +41,7 @@ import type { PasteDestination } from './PasteDestination'; interface BuiltinAiAssistantDef { readonly kind: AIAssistantDestinationKind; - readonly focusAndPasteCommands: readonly string[]; + readonly focusCommands: readonly string[]; readonly displayName: string; readonly jumpMessageCode: MessageCode; readonly userInstructionMessageCode: MessageCode; @@ -52,7 +51,7 @@ interface BuiltinAiAssistantDef { const BUILTIN_AI_ASSISTANTS: Record = { 'cursor.cursor': { kind: 'cursor-ai', - focusAndPasteCommands: CURSOR_AI_FOCUS_COMMANDS, + focusCommands: CURSOR_AI_FOCUS_COMMANDS, displayName: 'Cursor AI Assistant', jumpMessageCode: MessageCode.STATUS_BAR_JUMP_SUCCESS_CURSOR_AI, userInstructionMessageCode: MessageCode.INFO_CURSOR_AI_USER_INSTRUCTIONS, @@ -60,7 +59,7 @@ const BUILTIN_AI_ASSISTANTS: Record = { }, 'anthropic.claude-code': { kind: 'claude-code', - focusAndPasteCommands: CLAUDE_CODE_FOCUS_COMMANDS, + focusCommands: CLAUDE_CODE_FOCUS_COMMANDS, displayName: 'Claude Code Chat', jumpMessageCode: MessageCode.STATUS_BAR_JUMP_SUCCESS_CLAUDE_CODE, userInstructionMessageCode: MessageCode.INFO_CLAUDE_CODE_USER_INSTRUCTIONS, @@ -68,7 +67,7 @@ const BUILTIN_AI_ASSISTANTS: Record = { }, 'github.copilot-chat': { kind: 'github-copilot-chat', - focusAndPasteCommands: GITHUB_COPILOT_CHAT_FOCUS_COMMANDS, + focusCommands: GITHUB_COPILOT_CHAT_FOCUS_COMMANDS, displayName: 'GitHub Copilot Chat', jumpMessageCode: MessageCode.STATUS_BAR_JUMP_SUCCESS_GITHUB_COPILOT_CHAT, userInstructionMessageCode: MessageCode.INFO_GITHUB_COPILOT_CHAT_USER_INSTRUCTIONS, @@ -181,10 +180,9 @@ const buildBuiltinAiAssistantDestination = ( ComposablePasteDestination.createAiAssistant({ id: def.kind, displayName: def.displayName, - focusCapability: context.factories.focusCapability.createAIAssistantCapability( - [...def.focusAndPasteCommands], - [...CHAT_PASTE_COMMANDS], - ), + focusCapability: context.factories.focusCapability.createAIAssistantCapability([ + ...def.focusCommands, + ]), isAvailable: async () => def.isAvailable(context), jumpSuccessMessage: formatMessage(def.jumpMessageCode), loggingDetails: {}, @@ -296,7 +294,7 @@ const createOverriddenBuiltinBuilder = (_options, context) => { const userTiers = context.factories.focusCapability.buildCustomAIAssistantTiers(config); const fallbackTier = context.factories.focusCapability.buildBuiltinFallbackTier( - builtin.focusAndPasteCommands, + builtin.focusCommands, ); const allTiers = [...userTiers, fallbackTier]; diff --git a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts index df31af7e0..35c6d6042 100644 --- a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts +++ b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts @@ -1,7 +1,7 @@ import type { Logger } from 'barebone-logger'; import * as vscode from 'vscode'; -import { VSCODE_CMD_TERMINAL_PASTE } from '../../constants'; +import { CLIPBOARD_POST_PASTE_DELAY_MS, VSCODE_CMD_TERMINAL_PASTE } from '../../constants'; import { RangeLinkExtensionError } from '../../errors/RangeLinkExtensionError'; import { RangeLinkExtensionErrorCodes } from '../../errors/RangeLinkExtensionErrorCodes'; import { @@ -80,6 +80,33 @@ export class VscodeAdapter return this.ideInstance.env.clipboard.writeText(text); } + /** + * Paste text from clipboard into the currently focused editor element. + * + * Executes the built-in VS Code clipboard paste command. The clipboard + * must already contain the desired text — this method does NOT write to + * clipboard. After a successful paste, waits for postPasteDelayMs so that + * webview-based AI assistant panels can complete their async clipboard read + * across the Electron IPC boundary before the outer ClipboardPreserver + * restores the user's prior clipboard. + * + * @param postPasteDelayMs - Optional delay after paste (defaults to CLIPBOARD_POST_PASTE_DELAY_MS) + * @returns true if paste command succeeded, false otherwise + */ + async pasteTextFromClipboard(postPasteDelayMs?: number): Promise { + const delay = postPasteDelayMs ?? CLIPBOARD_POST_PASTE_DELAY_MS; + const logCtx = { fn: 'VscodeAdapter.pasteTextFromClipboard', delay }; + try { + await this.ideInstance.commands.executeCommand('editor.action.clipboardPasteAction'); + this.logger.info(logCtx, 'Clipboard paste succeeded'); + await new Promise((resolve) => setTimeout(resolve, delay)); + return true; + } catch (error) { + this.logger.debug({ ...logCtx, error }, 'Paste command failed'); + return false; + } + } + /** * Show temporary status bar message using VSCode API */ @@ -268,49 +295,34 @@ export class VscodeAdapter } /** - * Paste text into terminal using clipboard + paste command. + * Paste clipboard content into a terminal. * - * Writes text to clipboard, then executes VSCode's paste command to insert into - * the terminal. This approach works around terminal line wrapping limitations with - * link detection: when using terminal.sendText(), long lines wrap visually and - * VSCode's link provider only scans the wrapped portion. Clipboard paste allows - * the link provider to scan the full logical line, enabling detection of long - * links (130+ chars). + * Shows the terminal to ensure it has focus, then executes the terminal paste + * command. The clipboard must already contain the desired text — this method + * does NOT write to clipboard. The single clipboard write happens earlier in + * ClipboardRouter.executeCopyAndSend(). * - * @param terminal - Terminal to paste text into - * @param text - Text content to paste (will be written to clipboard) - * @param options - Optional paste behavior configuration + * @param terminal - Terminal to paste into * @throws RangeLinkError with TERMINAL_NOT_DEFINED if terminal is undefined/null */ - async pasteTextToTerminalViaClipboard( + async pasteIntoTerminal( terminal: vscode.Terminal, - text: string, options?: SendTextToTerminalOptions, ): Promise { - this.enforceTerminalExists(terminal, 'VscodeAdapter.pasteTextToTerminalViaClipboard'); + this.enforceTerminalExists(terminal, 'VscodeAdapter.pasteIntoTerminal'); const behaviour = options?.behaviour ?? BehaviourAfterPaste.NOTHING; this.logger.debug( - { - fn: 'VscodeAdapter.pasteTextToTerminalViaClipboard', - textLength: text.length, - terminalName: terminal.name, - behaviour, - }, - 'Pasting to terminal via clipboard', + { fn: 'VscodeAdapter.pasteIntoTerminal', terminalName: terminal.name }, + 'Pasting into terminal', ); - - // Write text to clipboard - await this.writeTextToClipboard(text); - - // Show terminal to ensure it's active for paste command terminal.show(); - - // Execute paste command (simulates Cmd+V / Ctrl+V) + this.logger.debug( + { fn: 'VscodeAdapter.pasteIntoTerminal', terminalName: terminal.name }, + 'Executing terminal paste command', + ); await this.executeCommand(VSCODE_CMD_TERMINAL_PASTE); - // Handle execution behavior if requested if (behaviour === BehaviourAfterPaste.EXECUTE) { - // Send Enter key separately after paste completes terminal.sendText('', true); } } diff --git a/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts b/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts index 41649da7f..3bc92f7ab 100644 --- a/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts +++ b/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts @@ -40,7 +40,7 @@ export class ClipboardRouter { if (shouldPreserve) { await this.clipboardPreserver.preserve( () => this.executeCopyAndSend(options), - () => this.destinationManager.isClipboardRestorationApplicable(), + () => this.destinationManager.isClipboardRestorationApplicable(true), ); } else { await this.executeCopyAndSend(options); diff --git a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts index 40e2945b4..98b8a19fd 100644 --- a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts +++ b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts @@ -16,7 +16,7 @@ import { PathFormat, RelativePathFormat, } from '../types'; -import { formatMessage } from '../utils'; +import { applySmartPadding, formatMessage } from '../utils'; import type { ClipboardRouter } from './ClipboardRouter'; import { handleDirtyBufferWarning } from './handleDirtyBufferWarning'; @@ -116,6 +116,8 @@ export class FilePathPaster { const destinationFilePath = quotePath(filePath); + const paddedPath = applySmartPadding(destinationFilePath, paddingMode); + if (destinationFilePath !== filePath) { this.logger.debug( { ...logCtx, before: filePath, after: destinationFilePath }, @@ -129,13 +131,13 @@ export class FilePathPaster { destinationBehavior, }, content: { - clipboard: filePath, - send: destinationFilePath, + clipboard: paddedPath, + send: paddedPath, sourceUri: uri, }, strategies: { sendFn: (text, basicStatusMessage) => - this.destinationManager.sendTextToDestination(text, basicStatusMessage, paddingMode), + this.destinationManager.sendTextToDestination(text, basicStatusMessage), isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), }, contentName: formatMessage(MessageCode.CONTENT_NAME_FILE_PATH), diff --git a/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts b/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts index 0a81653ea..267ba1866 100644 --- a/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts +++ b/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts @@ -13,7 +13,7 @@ import { PasteContentType, PathFormat, } from '../types'; -import { formatMessage, generateLinkFromSelections } from '../utils'; +import { applySmartPadding, formatMessage, generateLinkFromSelections } from '../utils'; import type { ClipboardRouter } from './ClipboardRouter'; import { getReferencePath } from './FilePathPaster'; @@ -173,6 +173,8 @@ export class LinkGenerator { DEFAULT_SMART_PADDING_PASTE_LINK, ); + const paddedLink = applySmartPadding(formattedLink.link, paddingMode); + this.logger.debug( { ...logCtx, link: formattedLink.link, rawLink: formattedLink.rawLink }, 'Sending link to destination', @@ -184,13 +186,13 @@ export class LinkGenerator { destinationBehavior, }, content: { - clipboard: formattedLink.link, - send: formattedLink, + clipboard: paddedLink, + send: { ...formattedLink, link: paddedLink }, sourceUri, }, strategies: { sendFn: (link, basicStatusMessage) => - this.destinationManager.sendLinkToDestination(link, basicStatusMessage, paddingMode), + this.destinationManager.sendLinkToDestination(link, basicStatusMessage), isEligibleFn: (destination, link) => destination.isEligibleForPasteLink(link), }, contentName: linkTypeName, diff --git a/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts b/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts index 189c3096c..d174aedf4 100644 --- a/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts +++ b/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts @@ -10,7 +10,7 @@ import { import type { PasteDestinationManager } from '../destinations/PasteDestinationManager'; import type { VscodeAdapter } from '../ide/vscode/VscodeAdapter'; import { MessageCode, PasteContentType, type TerminalPasteResult } from '../types'; -import { formatMessage } from '../utils'; +import { applySmartPadding, formatMessage } from '../utils'; import type { ClipboardRouter } from './ClipboardRouter'; @@ -92,18 +92,20 @@ export class TerminalSelectionService { DEFAULT_SMART_PADDING_PASTE_CONTENT, ); + const paddedText = applySmartPadding(terminalText, paddingMode); + await this.clipboardRouter.copyAndSendToDestination({ control: { contentType: PasteContentType.Text, destinationBehavior, }, content: { - clipboard: terminalText, - send: terminalText, + clipboard: paddedText, + send: paddedText, }, strategies: { sendFn: (text, basicStatusMessage) => - this.destinationManager.sendTextToDestination(text, basicStatusMessage, paddingMode), + this.destinationManager.sendTextToDestination(text, basicStatusMessage), isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), }, contentName: formatMessage(MessageCode.CONTENT_NAME_SELECTED_TEXT), diff --git a/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts b/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts index d99652f7e..ac9de98e3 100644 --- a/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts +++ b/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts @@ -7,7 +7,7 @@ import { } from '../constants'; import type { PasteDestinationManager } from '../destinations/PasteDestinationManager'; import { MessageCode, PasteContentType } from '../types'; -import { formatMessage } from '../utils'; +import { applySmartPadding, formatMessage } from '../utils'; import type { ClipboardRouter } from './ClipboardRouter'; import type { SelectionValidator } from './SelectionValidator'; @@ -53,19 +53,21 @@ export class TextSelectionPaster { const destinationBehavior = await this.clipboardRouter.resolveDestinationBehavior(logCtx); if (destinationBehavior === undefined) return; + const paddedContent = applySmartPadding(content, paddingMode); + await this.clipboardRouter.copyAndSendToDestination({ control: { contentType: PasteContentType.Text, destinationBehavior, }, content: { - clipboard: content, - send: content, + clipboard: paddedContent, + send: paddedContent, sourceUri: editor.document.uri, }, strategies: { sendFn: (text, basicStatusMessage) => - this.destinationManager.sendTextToDestination(text, basicStatusMessage, paddingMode), + this.destinationManager.sendTextToDestination(text, basicStatusMessage), isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), }, contentName: formatMessage(MessageCode.CONTENT_NAME_SELECTED_TEXT), From 37851ad78fe07f183744a2ed7f7a272d722b4e86 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 12 May 2026 22:38:00 -0400 Subject: [PATCH 2/6] Ran `pnpm fix` --- .../capabilities/insertFactories/terminalInsertFactory.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts index bbce7e2c3..35788e4e8 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/insertFactories/terminalInsertFactory.ts @@ -1,9 +1,10 @@ +import type { Logger } from 'barebone-logger'; import type * as vscode from 'vscode'; -import type { InsertFactory } from './InsertFactory'; -import type { Logger } from 'barebone-logger'; import type { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; +import type { InsertFactory } from './InsertFactory'; + /** * InsertFactory for terminal destinations. * From 22beb71e3a2d8b4f0c252b00b3d2c6f9506386a6 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 12 May 2026 23:33:44 -0400 Subject: [PATCH 3/6] [PR feedback] Fix ClipboardRouter pasteSucceeded tracking, update tests, correct docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the clipboard preservation guard so it receives the actual paste outcome instead of hardcoded `true`, preventing stale clipboard restoration on failed pastes. Added logger assertions to integration tests, updated enum references to string literals per T003, and fixed a minor docs issue. Benefits: - Clipboard is only restored when paste actually succeeded — failed pastes no longer inadvertently overwrite user clipboard - Integration test logger assertions provide regression protection for diagnostic logging - ADR code fence now has correct language tag for proper rendering at docs.rangelink.io - QA YAML uses renamed `focusCommands` field, matching current code Ignored Feedback: - Adding `settingsHelpers.resetRangelinkSettings` to send-file-path integration test: the `standardSuite.setup()` wrapper already calls `resetRangelinkSettings()` before every test in the suite, so the explicit call is redundant Ref: https://github.com/couimet/rangeLink/pull/556#pullrequestreview-4277737760 --- docs/ADR/0003-single-clipboard-write.md | 2 +- .../qa/qa-test-cases-v1.1.0.yaml | 2 +- ...osablePasteDestination.integration.test.ts | 112 ++++++++++++++++-- .../PasteDestinationManager.test.ts | 2 +- .../aiAssistantInsertFactory.test.ts | 1 + .../manualPasteInsertFactory.test.ts | 9 +- .../terminalInsertFactory.test.ts | 1 + .../ide/vscode/VscodeAdapter.test.ts | 10 +- .../src/destinations/PasteDestination.ts | 4 +- .../src/services/ClipboardRouter.ts | 17 +-- 10 files changed, 124 insertions(+), 36 deletions(-) diff --git a/docs/ADR/0003-single-clipboard-write.md b/docs/ADR/0003-single-clipboard-write.md index 6ce89a64f..aac1595df 100644 --- a/docs/ADR/0003-single-clipboard-write.md +++ b/docs/ADR/0003-single-clipboard-write.md @@ -34,7 +34,7 @@ The refactoring gives `ClipboardRouter` sole ownership of the clipboard write. ` ### 3. Two-delay model -``` +```text ┌────────────┐ ┌──────────────┐ ┌─────────────────┐ │ Focus cmd │────▶│ FOCUS_DELAY │────▶│ Clipboard Paste │ │ executed │ │ (pre-paste) │ │ Command │ diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index 96a2a89a4..4f26218fd 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -705,7 +705,7 @@ test_cases: feature: 'Clipboard Preservation' scenario: 'AI assistant auto-paste fails (focus command throws): RangeLink stays in clipboard for manual paste' preconditions: - - 'Custom AI assistant "Dummy AI (Focus-Fail)" configured in workspace settings with focusPasteCommands: [dummyAi.focusFail] (registered command that throws intentionally)' + - 'Custom AI assistant "Dummy AI (Focus-Fail)" configured in workspace settings with focusCommands: [dummyAi.focusFail] (registered command that throws intentionally)' - 'Dummy AI extension installed and active' - 'rangelink.clipboard.preserve = "always" (default)' - 'A file is open in the editor' diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts index 855b955a2..820283fb4 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts @@ -69,6 +69,15 @@ describe('ComposablePasteDestination Integration Tests', () => { expect(showTerminalSpy).toHaveBeenCalledWith(mockTerminal, 'steal-focus'); expect(pasteTextSpy).toHaveBeenCalledTimes(1); expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + terminalName: 'Test Terminal', + }, + 'Pasted link to Terminal', + ); }); it('should verify focus happens before text insertion', async () => { @@ -106,9 +115,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link')); + const formattedLink = createMockFormattedLink('test-link'); + + await destination.pasteLink(formattedLink); expect(callOrder).toStrictEqual(['focus', 'insert']); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Pasted link to Terminal', + ); }); }); @@ -152,6 +171,14 @@ describe('ComposablePasteDestination Integration Tests', () => { expect(executeCommandSpy).toHaveBeenCalledTimes(1); expect(executeCommandSpy).toHaveBeenCalledWith('ai.assistant.focus'); expect(pasteClipboardSpy).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Pasted link to Claude Code Chat', + ); }); it('should try focus commands in order until success', async () => { @@ -184,12 +211,22 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test')); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); expect(result).toBe(true); expect(executeCommandSpy).toHaveBeenCalledTimes(2); expect(executeCommandSpy).toHaveBeenNthCalledWith(1, 'command.first'); expect(executeCommandSpy).toHaveBeenNthCalledWith(2, 'command.second'); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Pasted link to Claude Code Chat', + ); }); it('should return false when all focus commands fail', async () => { @@ -221,9 +258,20 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test')); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + reason: 'COMMAND_FOCUS_FAILED', + }, + 'Focus failed, cannot paste link', + ); }); }); @@ -268,6 +316,15 @@ describe('ComposablePasteDestination Integration Tests', () => { expect(result).toBe(true); expect(insertSpy).toHaveBeenCalledTimes(1); expect(insertSpy).toHaveBeenCalledWith(mockEditor, 'src/file.ts#L10'); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + editorPath: '/path/to/file.ts', + }, + 'Pasted link to Text Editor', + ); }); it('should handle showTextDocument failure gracefully', async () => { @@ -302,9 +359,20 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test')); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + reason: 'SHOW_DOCUMENT_FAILED', + }, + 'Focus failed, cannot paste link', + ); }); it('should handle insertTextAtCursor returning false', async () => { @@ -340,9 +408,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test')); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); expect(result).toBe(false); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Failed to paste link to Text Editor', + ); }); }); @@ -372,9 +450,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test')); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); expect(result).toBe(false); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Cannot paste link: Terminal not available', + ); }); it('should call pasteIntoTerminal for terminal destinations', async () => { @@ -407,9 +495,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link')); + const formattedLink = createMockFormattedLink('test-link'); + + await destination.pasteLink(formattedLink); expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Pasted link to Terminal', + ); }); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts index a72333d7b..13d4f7de3 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts @@ -2112,7 +2112,7 @@ describe('PasteDestinationManager', () => { (manager as any).boundDestination = mockDest; expect(manager.isClipboardRestorationApplicable(false)).toBe(false); - expect(mockDest.getUserInstruction).toHaveBeenCalledWith(AutoPasteResult.Failure); + expect(mockDest.getUserInstruction).toHaveBeenCalledWith('Failure'); }); it('returns true when paste failed and destination provides no failure instruction', () => { diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts index 3da2eae8e..d7b6e783d 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts @@ -21,6 +21,7 @@ describe('AIAssistantInsertFactory', () => { expect(result).toBe(true); expect(pasteSpy).toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); }); it('returns false when pasteTextFromClipboard returns false', async () => { diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts index a11d6c80f..ad0890b84 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/manualPasteInsertFactory.test.ts @@ -1,7 +1,6 @@ import { createMockLogger } from 'barebone-logger-testing'; import { ManualPasteInsertFactory } from '../../../../destinations/capabilities/insertFactories/manualPasteInsertFactory'; -import { createMockVscodeAdapter } from '../../../helpers'; const LINK_TEXT = 'src/app.ts#L10-L20'; const LINK_TEXT_LENGTH = LINK_TEXT.length; @@ -13,19 +12,13 @@ describe('ManualPasteInsertFactory', () => { mockLogger = createMockLogger(); }); - it('returns true without touching the clipboard or executing commands', async () => { - const mockAdapter = createMockVscodeAdapter(); - const clipboardSpy = jest.spyOn(mockAdapter, 'writeTextToClipboard'); - const executeCommandSpy = jest.spyOn(mockAdapter, 'executeCommand'); - + it('returns true and logs the manual paste instruction', async () => { const factory = new ManualPasteInsertFactory(mockLogger); const insertFn = factory.forTarget(); const result = await insertFn(LINK_TEXT); expect(result).toBe(true); - expect(clipboardSpy).not.toHaveBeenCalled(); - expect(executeCommandSpy).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'ManualPasteInsertFactory.insert', textLength: LINK_TEXT_LENGTH }, 'Link ready for manual paste', diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts index d67991d24..9948ed9ad 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/terminalInsertFactory.test.ts @@ -66,5 +66,6 @@ describe('TerminalInsertFactory', () => { expect(pasteIntoTerminalSpy).toHaveBeenNthCalledWith(1, terminal1); expect(pasteIntoTerminalSpy).toHaveBeenNthCalledWith(2, terminal2); + expect(mockLogger.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts index 2809c8a82..16ff6ea5d 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts @@ -738,7 +738,7 @@ describe('VscodeAdapter', () => { describe('pasteIntoTerminal', () => { it('should show terminal and execute paste command', async () => { - const mockTerminal = createMockTerminal(); + const mockTerminal = createMockTerminal({ name: 'my-terminal' }); const executeCommandSpy = jest.spyOn(adapter, 'executeCommand'); await adapter.pasteIntoTerminal(mockTerminal); @@ -747,14 +747,6 @@ describe('VscodeAdapter', () => { expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); expect(executeCommandSpy).toHaveBeenCalledTimes(1); expect(mockTerminal.sendText).not.toHaveBeenCalled(); - }); - - it('should log terminal name before executing command', async () => { - const mockTerminal = createMockTerminal({ name: 'my-terminal' }); - jest.spyOn(adapter, 'executeCommand'); - - await adapter.pasteIntoTerminal(mockTerminal); - expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'VscodeAdapter.pasteIntoTerminal', terminalName: 'my-terminal' }, 'Executing terminal paste command', diff --git a/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts b/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts index d4b75fd00..1b379dd47 100644 --- a/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts +++ b/packages/rangelink-vscode-extension/src/destinations/PasteDestination.ts @@ -78,7 +78,7 @@ export interface PasteDestination { * * Implementation requirements: * - Check eligibility internally (defensive programming) - * - Focus destination after paste (terminal.show(), chat.open(), etc.) + * - Focus destination before paste (terminal.show(), chat.open(), etc.) * - Log success/failure for debugging * - Return false on failure or ineligibility (no throwing) * @@ -97,7 +97,7 @@ export interface PasteDestination { * * Implementation requirements: * - Check eligibility internally (defensive programming) - * - Focus destination after paste (terminal.show(), chat.open(), etc.) + * - Focus destination before paste (terminal.show(), chat.open(), etc.) * - Log success/failure for debugging * - Return false on failure or ineligibility (no throwing) * diff --git a/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts b/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts index 3bc92f7ab..25ca51c5a 100644 --- a/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts +++ b/packages/rangelink-vscode-extension/src/services/ClipboardRouter.ts @@ -38,9 +38,12 @@ export class ClipboardRouter { options.control.destinationBehavior !== DestinationBehavior.ClipboardOnly && this.destinationManager.isBound(); if (shouldPreserve) { + let pasteSucceeded = false; await this.clipboardPreserver.preserve( - () => this.executeCopyAndSend(options), - () => this.destinationManager.isClipboardRestorationApplicable(true), + async () => { + pasteSucceeded = await this.executeCopyAndSend(options); + }, + () => this.destinationManager.isClipboardRestorationApplicable(pasteSucceeded), ); } else { await this.executeCopyAndSend(options); @@ -72,7 +75,7 @@ export class ClipboardRouter { return DestinationBehavior.BoundDestination; } - private async executeCopyAndSend(options: CopyAndSendOptions): Promise { + private async executeCopyAndSend(options: CopyAndSendOptions): Promise { const { control, content, strategies, contentName, fnName } = options; await this.ideAdapter.writeTextToClipboard(content.clipboard); @@ -92,7 +95,7 @@ export class ClipboardRouter { : 'No destination bound - copied to clipboard only'; this.logger.info({ fn: fnName }, reason); this.ideAdapter.setStatusBarMessage(basicStatusMessage); - return; + return false; } const destination = this.destinationManager.getBoundDestination()!; @@ -105,7 +108,7 @@ export class ClipboardRouter { 'Content not eligible for paste - skipping auto-paste', ); this.ideAdapter.setStatusBarMessage(basicStatusMessage); - return; + return false; } if (content.sourceUri && isSameFileDestination(content.sourceUri, destination)) { @@ -117,7 +120,7 @@ export class ClipboardRouter { const selfPasteMessage = formatMessage(selfPasteMessageCodes[control.contentType]); this.ideAdapter.showInformationMessage(selfPasteMessage); this.ideAdapter.setStatusBarMessage(basicStatusMessage); - return; + return false; } this.logger.debug( @@ -125,7 +128,7 @@ export class ClipboardRouter { `Attempting to send content to bound destination: ${displayName}`, ); - await strategies.sendFn(content.send, basicStatusMessage); + return strategies.sendFn(content.send, basicStatusMessage); } private async showPickerAndBind(): Promise { From f8320f7afc28ade4ffd7fb665a8786ee65a6d455 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 13 May 2026 10:49:12 -0400 Subject: [PATCH 4/6] [fix] Restore pre-paste delay in pasteTextFromClipboard to fix AI assistant paste regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-clipboard-write refactoring accidentally dropped FOCUS_TO_PASTE_DELAY_MS from the AI assistant paste path. The old aiAssistantInsertFactory had this 200ms delay between clipboard write and paste command execution — it gave the Electron clipboard write time to propagate from the extension host process through the main process to the webview's renderer. Without it, editor.action.clipboardPasteAction fires before the webview can read the clipboard content, and the paste lands empty even though all code-side signals report success. Extracted a delay(ms) method in VscodeAdapter that wraps setTimeout. pasteTextFromClipboard calls this.delay(FOCUS_TO_PASTE_DELAY_MS) before executing the paste command, then this.delay(postPasteDelayMs) after a successful paste. Tests spy on delay() via jest.spyOn to verify the delays are enforced in the correct order without fighting fake timers. Benefits: - Claude Code, Cursor AI, GitHub Copilot Chat, and custom AI assistants now reliably receive pasted content - Pre-paste delay (FOCUS_TO_PASTE_DELAY_MS, 200ms) ensures clipboard propagation across Electron IPC boundary - Post-paste delay (CLIPBOARD_POST_PASTE_DELAY_MS, 200ms) remains intact for clipboard restoration safety - delay() method keeps production code clean (no testing parameters); tests spy on it for deterministic verification --- .../ide/vscode/VscodeAdapter.test.ts | 93 +++++++++++++++++++ .../src/ide/vscode/VscodeAdapter.ts | 27 ++++-- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts index 16ff6ea5d..4e1c6071c 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts @@ -1,6 +1,7 @@ import type { Logger } from 'barebone-logger'; import { createMockLogger } from 'barebone-logger-testing'; + import { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; import { PathFormat } from '../../../types/PathFormat'; import { RelativePathFormat } from '../../../types/RelativePathFormat'; @@ -776,6 +777,98 @@ describe('VscodeAdapter', () => { }); }); + describe('pasteTextFromClipboard', () => { + it('should enforce 200 pre-paste delay before executing the paste command', async () => { + const callOrder: string[] = []; + jest.spyOn(adapter as any, 'delay').mockImplementation((...args: unknown[]) => { + callOrder.push(`delay-${args[0]}`); + return Promise.resolve(); + }); + + await adapter.pasteTextFromClipboard(); + + expect(callOrder).toStrictEqual([ + `delay-${200}`, + `delay-${200}`, + ]); + + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'VscodeAdapter.pasteTextFromClipboard', + delay: 200, + prePasteDelay: 200, + }, + 'Pre-paste delay complete, executing paste', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.pasteTextFromClipboard', delay: 200 }, + 'Clipboard paste succeeded', + ); + }); + + it('should not execute paste command before the pre-paste delay resolves', async () => { + const callOrder: string[] = []; + jest.spyOn(adapter as any, 'delay').mockImplementation((...args: unknown[]) => { + callOrder.push(`delay-${args[0]}`); + return Promise.resolve(); + }); + mockVSCode.commands.executeCommand.mockImplementation(() => { + callOrder.push('paste'); + return Promise.resolve(undefined); + }); + + await adapter.pasteTextFromClipboard(); + + expect(callOrder).toStrictEqual([ + `delay-${200}`, + 'paste', + `delay-${200}`, + ]); + }); + + it('should use 200 as default post-paste delay', async () => { + const delaySpy = jest.spyOn(adapter as any, 'delay').mockResolvedValue(undefined); + + await adapter.pasteTextFromClipboard(); + + expect(delaySpy).toHaveBeenCalledTimes(2); + expect(delaySpy).toHaveBeenNthCalledWith(1, 200); + expect(delaySpy).toHaveBeenNthCalledWith(2, 200); + }); + + it('should use custom postPasteDelayMs when provided', async () => { + const CUSTOM_POST_DELAY = 500; + const delaySpy = jest.spyOn(adapter as any, 'delay').mockResolvedValue(undefined); + + await adapter.pasteTextFromClipboard(CUSTOM_POST_DELAY); + + expect(delaySpy).toHaveBeenNthCalledWith(2, CUSTOM_POST_DELAY); + expect(mockLogger.info).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.pasteTextFromClipboard', delay: CUSTOM_POST_DELAY }, + 'Clipboard paste succeeded', + ); + }); + + it('should skip post-paste delay when paste command fails', async () => { + const pasteError = new Error('Paste failed'); + mockVSCode.commands.executeCommand.mockRejectedValueOnce(pasteError); + const delaySpy = jest.spyOn(adapter as any, 'delay').mockResolvedValue(undefined); + + await adapter.pasteTextFromClipboard(); + + expect(delaySpy).toHaveBeenCalledTimes(1); + expect(delaySpy).toHaveBeenCalledWith(200); + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'VscodeAdapter.pasteTextFromClipboard', + delay: 200, + error: pasteError, + }, + 'Paste command failed', + ); + }); + }); + describe('insertTextAtCursor', () => { it('should call editor.edit and insert text at cursor position', async () => { // Capture the callback passed to edit() diff --git a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts index 35c6d6042..5234e956b 100644 --- a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts +++ b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts @@ -1,7 +1,7 @@ import type { Logger } from 'barebone-logger'; import * as vscode from 'vscode'; -import { CLIPBOARD_POST_PASTE_DELAY_MS, VSCODE_CMD_TERMINAL_PASTE } from '../../constants'; +import { CLIPBOARD_POST_PASTE_DELAY_MS, FOCUS_TO_PASTE_DELAY_MS, VSCODE_CMD_TERMINAL_PASTE } from '../../constants'; import { RangeLinkExtensionError } from '../../errors/RangeLinkExtensionError'; import { RangeLinkExtensionErrorCodes } from '../../errors/RangeLinkExtensionErrorCodes'; import { @@ -83,23 +83,26 @@ export class VscodeAdapter /** * Paste text from clipboard into the currently focused editor element. * - * Executes the built-in VS Code clipboard paste command. The clipboard - * must already contain the desired text — this method does NOT write to - * clipboard. After a successful paste, waits for postPasteDelayMs so that - * webview-based AI assistant panels can complete their async clipboard read - * across the Electron IPC boundary before the outer ClipboardPreserver - * restores the user's prior clipboard. + * Waits FOCUS_TO_PASTE_DELAY_MS before executing the paste command so that + * the clipboard write (performed earlier by ClipboardRouter) has time to + * propagate across the Electron IPC boundary to the webview's renderer process. + * Without this delay, webview-based AI assistants read stale clipboard data + * and the paste lands empty. After a successful paste, waits for + * postPasteDelayMs so that the webview can complete its async clipboard read + * before the outer ClipboardPreserver restores the user's prior clipboard. * * @param postPasteDelayMs - Optional delay after paste (defaults to CLIPBOARD_POST_PASTE_DELAY_MS) * @returns true if paste command succeeded, false otherwise */ async pasteTextFromClipboard(postPasteDelayMs?: number): Promise { - const delay = postPasteDelayMs ?? CLIPBOARD_POST_PASTE_DELAY_MS; - const logCtx = { fn: 'VscodeAdapter.pasteTextFromClipboard', delay }; + const postDelay = postPasteDelayMs ?? CLIPBOARD_POST_PASTE_DELAY_MS; + const logCtx = { fn: 'VscodeAdapter.pasteTextFromClipboard', delay: postDelay }; try { + await this.delay(FOCUS_TO_PASTE_DELAY_MS); + this.logger.debug({ ...logCtx, prePasteDelay: FOCUS_TO_PASTE_DELAY_MS }, 'Pre-paste delay complete, executing paste'); await this.ideInstance.commands.executeCommand('editor.action.clipboardPasteAction'); this.logger.info(logCtx, 'Clipboard paste succeeded'); - await new Promise((resolve) => setTimeout(resolve, delay)); + await this.delay(postDelay); return true; } catch (error) { this.logger.debug({ ...logCtx, error }, 'Paste command failed'); @@ -107,6 +110,10 @@ export class VscodeAdapter } } + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + /** * Show temporary status bar message using VSCode API */ From f732b061fe47ba245c92c0acf8f06c028562fa09 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 13 May 2026 12:52:01 -0400 Subject: [PATCH 5/6] [PR feedback] Use .toHaveBeenCalledWith() for explicit no-args assertion in AIAssistantInsertFactory test .toHaveBeenCalled() only asserts the spy was invoked. .toHaveBeenCalledWith() asserts it was called with an empty argument list, which is stricter and catches regressions where the signature changes to pass unintended arguments. Benefits: - Explicit no-arguments contract for pasteTextFromClipboard call site - Stricter assertion catches unintended signature drift Ref: https://github.com/couimet/rangeLink/pull/556#pullrequestreview-4278048369 --- .../insertFactories/aiAssistantInsertFactory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts index d7b6e783d..176ef7d63 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/insertFactories/aiAssistantInsertFactory.test.ts @@ -20,7 +20,7 @@ describe('AIAssistantInsertFactory', () => { const result = await insertFn('test content'); expect(result).toBe(true); - expect(pasteSpy).toHaveBeenCalled(); + expect(pasteSpy).toHaveBeenCalledWith(); expect(mockLogger.warn).not.toHaveBeenCalled(); }); From d8f8d34ef7ad64dbfc75425bc541cdadba4d45fa Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 13 May 2026 12:54:52 -0400 Subject: [PATCH 6/6] Ran `pnpm fix` --- .../src/__tests__/ide/vscode/VscodeAdapter.test.ts | 12 ++---------- .../src/ide/vscode/VscodeAdapter.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts index 4e1c6071c..9892486f2 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts @@ -1,7 +1,6 @@ import type { Logger } from 'barebone-logger'; import { createMockLogger } from 'barebone-logger-testing'; - import { VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; import { PathFormat } from '../../../types/PathFormat'; import { RelativePathFormat } from '../../../types/RelativePathFormat'; @@ -787,10 +786,7 @@ describe('VscodeAdapter', () => { await adapter.pasteTextFromClipboard(); - expect(callOrder).toStrictEqual([ - `delay-${200}`, - `delay-${200}`, - ]); + expect(callOrder).toStrictEqual([`delay-${200}`, `delay-${200}`]); expect(mockLogger.debug).toHaveBeenCalledWith( { @@ -819,11 +815,7 @@ describe('VscodeAdapter', () => { await adapter.pasteTextFromClipboard(); - expect(callOrder).toStrictEqual([ - `delay-${200}`, - 'paste', - `delay-${200}`, - ]); + expect(callOrder).toStrictEqual([`delay-${200}`, 'paste', `delay-${200}`]); }); it('should use 200 as default post-paste delay', async () => { diff --git a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts index 5234e956b..28c57c939 100644 --- a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts +++ b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts @@ -1,7 +1,11 @@ import type { Logger } from 'barebone-logger'; import * as vscode from 'vscode'; -import { CLIPBOARD_POST_PASTE_DELAY_MS, FOCUS_TO_PASTE_DELAY_MS, VSCODE_CMD_TERMINAL_PASTE } from '../../constants'; +import { + CLIPBOARD_POST_PASTE_DELAY_MS, + FOCUS_TO_PASTE_DELAY_MS, + VSCODE_CMD_TERMINAL_PASTE, +} from '../../constants'; import { RangeLinkExtensionError } from '../../errors/RangeLinkExtensionError'; import { RangeLinkExtensionErrorCodes } from '../../errors/RangeLinkExtensionErrorCodes'; import { @@ -99,7 +103,10 @@ export class VscodeAdapter const logCtx = { fn: 'VscodeAdapter.pasteTextFromClipboard', delay: postDelay }; try { await this.delay(FOCUS_TO_PASTE_DELAY_MS); - this.logger.debug({ ...logCtx, prePasteDelay: FOCUS_TO_PASTE_DELAY_MS }, 'Pre-paste delay complete, executing paste'); + this.logger.debug( + { ...logCtx, prePasteDelay: FOCUS_TO_PASTE_DELAY_MS }, + 'Pre-paste delay complete, executing paste', + ); await this.ideInstance.commands.executeCommand('editor.action.clipboardPasteAction'); this.logger.info(logCtx, 'Clipboard paste succeeded'); await this.delay(postDelay);