diff --git a/docs/ADR/0003-single-clipboard-write.md b/docs/ADR/0003-single-clipboard-write.md new file mode 100644 index 000000000..aac1595df --- /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 + +```text +┌────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ 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..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 @@ -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 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' + 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..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 @@ -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,29 @@ 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); + 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 () => { 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 +99,7 @@ describe('ComposablePasteDestination Integration Tests', () => { callOrder.push('focus'); }); - jest.spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard').mockImplementation(async () => { + jest.spyOn(mockAdapter, 'pasteIntoTerminal').mockImplementation(async () => { callOrder.push('insert'); }); @@ -127,9 +115,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link'), 'both'); + 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', + ); }); }); @@ -137,12 +135,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 +147,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 +165,26 @@ 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); + 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 () => { 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 +196,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 +211,28 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + const formattedLink = createMockFormattedLink('test'); + + const result = await destination.pasteLink(formattedLink); 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'); + 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 () => { 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,9 +258,20 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + 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', + ); }); }); @@ -302,11 +311,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(insertSpy).toHaveBeenCalledTimes(1); - expect(insertSpy).toHaveBeenCalledWith(mockEditor, ' src/file.ts#L10 '); + 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 () => { @@ -341,9 +359,20 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + 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 () => { @@ -379,9 +408,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + 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', + ); }); }); @@ -390,11 +429,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,20 +450,26 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - const result = await destination.pasteLink(createMockFormattedLink('test'), 'both'); + 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 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 insertFactory = new TerminalInsertFactory(mockAdapter, mockLogger); const focusCapability = new TerminalFocusCapability( mockAdapter, mockTerminal, @@ -439,7 +480,7 @@ describe('ComposablePasteDestination Integration Tests', () => { jest.spyOn(mockAdapter, 'showTerminal'); const pasteTextSpy = jest - .spyOn(mockAdapter, 'pasteTextToTerminalViaClipboard') + .spyOn(mockAdapter, 'pasteIntoTerminal') .mockResolvedValue(undefined); const destination = ComposablePasteDestination.createForTesting({ @@ -454,48 +495,19 @@ describe('ComposablePasteDestination Integration Tests', () => { logger: mockLogger, }); - await destination.pasteLink(createMockFormattedLink('test-link'), 'both'); + const formattedLink = createMockFormattedLink('test-link'); - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, ' test-link '); - }); + await destination.pasteLink(formattedLink); - 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, + expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'ComposablePasteDestination.pasteLink', + formattedLink, + linkLength: formattedLink.link.length, + }, + 'Pasted link to Terminal', ); - 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'), 'none'); - - expect(pasteTextSpy).toHaveBeenCalledWith(mockTerminal, 'test-link'); }); }); }); 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..13d4f7de3 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('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..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 @@ -1,177 +1,42 @@ 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).toHaveBeenCalledWith(); + expect(mockLogger.warn).not.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..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,56 +12,16 @@ describe('ManualPasteInsertFactory', () => { mockLogger = createMockLogger(); }); - it('writes text to clipboard and returns true', async () => { - const mockAdapter = createMockVscodeAdapter(); - const clipboardSpy = jest - .spyOn(mockAdapter, 'writeTextToClipboard') - .mockResolvedValue(undefined); - - const factory = new ManualPasteInsertFactory(mockAdapter, mockLogger); + 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).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(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..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 @@ -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,19 @@ 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); + expect(mockLogger.error).not.toHaveBeenCalled(); }); }); 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..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 @@ -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,114 +736,128 @@ describe('VscodeAdapter', () => { }); }); - describe('pasteTextToTerminalViaClipboard', () => { - it('should write to clipboard, show terminal, and execute paste command with default behavior', async () => { - const mockTerminal = createMockTerminal(); - const text = 'test text for paste'; - const writeToClipboardSpy = jest.spyOn(adapter, 'writeTextToClipboard'); + describe('pasteIntoTerminal', () => { + it('should show terminal and execute paste command', async () => { + const mockTerminal = createMockTerminal({ name: 'my-terminal' }); 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(); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.pasteIntoTerminal', terminalName: 'my-terminal' }, + 'Executing terminal paste command', + ); }); - 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'); + it('should throw TERMINAL_NOT_DEFINED when terminal is undefined', async () => { + const undefinedTerminal = undefined as any; - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, text, { - behaviour: BehaviourAfterPaste.NOTHING, + await expect(async () => + adapter.pasteIntoTerminal(undefinedTerminal), + ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { + message: 'Terminal reference is not defined', + functionName: 'VscodeAdapter.pasteIntoTerminal', }); - - 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'); + it('should throw TERMINAL_NOT_DEFINED when terminal is null', async () => { + const nullTerminal = null as any; - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, text, { - behaviour: BehaviourAfterPaste.EXECUTE, + await expect(async () => + adapter.pasteIntoTerminal(nullTerminal), + ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { + message: 'Terminal reference is not defined', + functionName: 'VscodeAdapter.pasteIntoTerminal', }); - - 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'); + 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.pasteTextToTerminalViaClipboard(mockTerminal, ''); + await adapter.pasteTextFromClipboard(); - expect(writeToClipboardSpy).toHaveBeenCalledWith(''); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); + 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 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'); + 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.pasteTextToTerminalViaClipboard(mockTerminal, longText); + await adapter.pasteTextFromClipboard(); - expect(writeToClipboardSpy).toHaveBeenCalledWith(longText); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); + expect(callOrder).toStrictEqual([`delay-${200}`, 'paste', `delay-${200}`]); }); - 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'); + it('should use 200 as default post-paste delay', async () => { + const delaySpy = jest.spyOn(adapter as any, 'delay').mockResolvedValue(undefined); - await adapter.pasteTextToTerminalViaClipboard(mockTerminal, multiLineText); + await adapter.pasteTextFromClipboard(); - expect(writeToClipboardSpy).toHaveBeenCalledWith(multiLineText); - expect(mockTerminal.show).toHaveBeenCalledTimes(1); - expect(executeCommandSpy).toHaveBeenCalledWith('workbench.action.terminal.paste'); + expect(delaySpy).toHaveBeenCalledTimes(2); + expect(delaySpy).toHaveBeenNthCalledWith(1, 200); + expect(delaySpy).toHaveBeenNthCalledWith(2, 200); }); - it('should throw TERMINAL_NOT_DEFINED when terminal is undefined', async () => { - const undefinedTerminal = undefined as any; + it('should use custom postPasteDelayMs when provided', async () => { + const CUSTOM_POST_DELAY = 500; + const delaySpy = jest.spyOn(adapter as any, 'delay').mockResolvedValue(undefined); - await expect(async () => - adapter.pasteTextToTerminalViaClipboard(undefinedTerminal, 'text'), - ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { - message: 'Terminal reference is not defined', - functionName: 'VscodeAdapter.pasteTextToTerminalViaClipboard', - }); + 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 throw TERMINAL_NOT_DEFINED when terminal is null', async () => { - const nullTerminal = null as any; + 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 expect(async () => - adapter.pasteTextToTerminalViaClipboard(nullTerminal, 'text'), - ).toThrowRangeLinkExtensionErrorAsync('TERMINAL_NOT_DEFINED', { - message: 'Terminal reference is not defined', - functionName: 'VscodeAdapter.pasteTextToTerminalViaClipboard', - }); + 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', + ); }); }); 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..1b379dd47 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.) + * - Focus destination before 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.) + * - Focus destination before 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..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,7 +1,6 @@ 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'; @@ -9,33 +8,29 @@ import type { InsertFactory } from './InsertFactory'; /** * 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..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 { 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 { @@ -80,6 +84,43 @@ export class VscodeAdapter return this.ideInstance.env.clipboard.writeText(text); } + /** + * Paste text from clipboard into the currently focused editor element. + * + * 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 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 this.delay(postDelay); + return true; + } catch (error) { + this.logger.debug({ ...logCtx, error }, 'Paste command failed'); + return false; + } + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + /** * Show temporary status bar message using VSCode API */ @@ -268,49 +309,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..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(), + 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 { 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),