diff --git a/packages/rangelink-vscode-extension/CHANGELOG.md b/packages/rangelink-vscode-extension/CHANGELOG.md index 9d0fc0b6..b08981b3 100644 --- a/packages/rangelink-vscode-extension/CHANGELOG.md +++ b/packages/rangelink-vscode-extension/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Single status bar message when binding and sending in one step via the destination picker**, instead of two back-to-back messages. The merged message reads "Bound to <destination> — <link> sent". (#621) + ### Fixed - **R-F (paste current file path) now works for any active tab that maps to a file** — image previews, notebooks, and diff views — not just text editors. (#643) diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml index 49ebf767..7ed5ccf6 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml @@ -759,6 +759,22 @@ test_cases: expected_result: "'RangeLink: Unbind' is not visible in the menu (or is disabled)." automated: assisted + - id: context-menus-explorer-006 + labels: + - explorer + feature: 'context-menus' + scenario: 'Explorer (unbound): "Send File Path" opens picker, binds selected terminal and sends absolute path in one step' + expected_result: 'A single merged status bar message appears: "Bound to Terminal (...) — File path sent". Terminal receives the absolute path.' + automated: assisted + + - id: context-menus-explorer-007 + labels: + - explorer + feature: 'context-menus' + scenario: 'Explorer (unbound): "Send Relative File Path" opens picker, binds selected terminal and sends relative path in one step' + expected_result: 'A single merged status bar message appears: "Bound to Terminal (...) — File path sent". Terminal receives the workspace-relative path.' + automated: assisted + - id: context-menus-editor-tab-001 labels: - editor-tab @@ -791,6 +807,22 @@ test_cases: expected_result: 'Destination is unbound. Confirmation notification.' automated: assisted + - id: context-menus-editor-tab-005 + labels: + - editor-tab + feature: 'context-menus' + scenario: 'Editor tab (unbound): "Send File Path" opens picker, binds selected terminal and sends absolute path in one step' + expected_result: 'A single merged status bar message appears: "Bound to Terminal (...) — File path sent". Terminal receives the absolute path.' + automated: assisted + + - id: context-menus-editor-tab-006 + labels: + - editor-tab + feature: 'context-menus' + scenario: 'Editor tab (unbound): "Send Relative File Path" opens picker, binds selected terminal and sends relative path in one step' + expected_result: 'A single merged status bar message appears: "Bound to Terminal (...) — File path sent". Terminal receives the workspace-relative path.' + automated: assisted + - id: context-menus-editor-content-001 labels: - clipboard diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts index d8ad4e13..1dea3db8 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts @@ -168,4 +168,99 @@ standardSuite('Context Menus — Editor Tab', (ss) => { ss.log('✓ Editor-tab "Unbind" fired the unbind path; context key flipped to false'); }); + + test('[assisted] context-menus-editor-tab-005: Editor tab "Send File Path" (unbound) opens picker and sends absolute path to selected terminal', async () => { + const uri = await ss.createAndOpenFile('ctxmenu-tab-005', FILE_CONTENT); + const fn = path.basename(uri.fsPath); + + const terminalName = 'rl-ctxmenu-tab-005'; + const capturing = await ss.createCapturingTerminal(terminalName); + + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("rl-ctxmenu-tab-005") — File path sent', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const logCapture = getLogCapture(); + logCapture.mark('before-ctxmenu-tab-005'); + capturing.clearCaptured(); + + await waitForHuman( + 'context-menus-editor-tab-005', + `Right-click tab "${fn}" → "RangeLink: Send File Path" → select "${terminalName}" from the destination picker`, + [ + `1. Locate the "${fn}" tab in the editor tab bar`, + '2. Right-click the tab', + '3. Select "RangeLink: Send File Path"', + `4. In the destination picker, select "${terminalName}"`, + ], + ); + + const lines = logCapture.getLinesSince('before-ctxmenu-tab-005'); + + assertFilePathLogged(lines, { + pathFormat: 'absolute', + uriSource: 'context-menu', + filePath: uri.fsPath, + }); + const expectedPath = ` ${uri.fsPath} `; + assertClipboardWriteLogged(lines, { textLength: expectedPath.length }); + assertTerminalBufferEquals(capturing.getCapturedText(), expectedPath); + + ss.log( + '✓ Unbound editor-tab absolute path → picker → bind+send (merged message, pty capture verified content)', + ); + }); + + test('[assisted] context-menus-editor-tab-006: Editor tab "Send Relative File Path" (unbound) opens picker and sends relative path to selected terminal', async () => { + const uri = await ss.createAndOpenFile('ctxmenu-tab-006', FILE_CONTENT); + const fn = path.basename(uri.fsPath); + const relativePath = vscode.workspace.asRelativePath(uri, false); + + const terminalName = 'rl-ctxmenu-tab-006'; + const capturing = await ss.createCapturingTerminal(terminalName); + + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("rl-ctxmenu-tab-006") — File path sent', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const logCapture = getLogCapture(); + logCapture.mark('before-ctxmenu-tab-006'); + capturing.clearCaptured(); + + await waitForHuman( + 'context-menus-editor-tab-006', + `Right-click tab "${fn}" → "RangeLink: Send Relative File Path" → select "${terminalName}" from the destination picker`, + [ + `1. Locate the "${fn}" tab in the editor tab bar`, + '2. Right-click the tab', + '3. Select "RangeLink: Send Relative File Path"', + `4. In the destination picker, select "${terminalName}"`, + ], + ); + + const lines = logCapture.getLinesSince('before-ctxmenu-tab-006'); + + assertFilePathLogged(lines, { + pathFormat: 'workspace-relative', + uriSource: 'context-menu', + filePath: relativePath, + }); + const expectedPath = ` ${relativePath} `; + assertClipboardWriteLogged(lines, { textLength: expectedPath.length }); + assertTerminalBufferEquals(capturing.getCapturedText(), expectedPath); + + ss.log( + '✓ Unbound editor-tab relative path → picker → bind+send (merged message, pty capture verified content)', + ); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts index 3570a965..e1a3fb1d 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts @@ -190,4 +190,99 @@ standardSuite('Context Menus — Explorer', (ss) => { '✓ Unbound state: "Unbind" absent from Explorer context menu (human verdict + state invariant)', ); }); + + test('[assisted] context-menus-explorer-006: Explorer "Send File Path" (unbound) opens picker and sends absolute path to selected terminal', async () => { + const uri = await ss.createAndOpenFile('ctxmenu-exp-006', FILE_CONTENT); + const fn = path.basename(uri.fsPath); + + const terminalName = 'rl-ctxmenu-exp-006'; + const capturing = await ss.createCapturingTerminal(terminalName); + + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("rl-ctxmenu-exp-006") — File path sent', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const logCapture = getLogCapture(); + logCapture.mark('before-ctxmenu-exp-006'); + capturing.clearCaptured(); + + await waitForHuman( + 'context-menus-explorer-006', + `Right-click "${fn}" in Explorer → "RangeLink: Send File Path" → select "${terminalName}" from the destination picker`, + [ + `1. Locate "${fn}" in the Explorer panel`, + '2. Right-click it', + '3. Select "RangeLink: Send File Path"', + `4. In the destination picker, select "${terminalName}"`, + ], + ); + + const lines = logCapture.getLinesSince('before-ctxmenu-exp-006'); + + assertFilePathLogged(lines, { + pathFormat: 'absolute', + uriSource: 'context-menu', + filePath: uri.fsPath, + }); + const expectedPath = ` ${uri.fsPath} `; + assertClipboardWriteLogged(lines, { textLength: expectedPath.length }); + assertTerminalBufferEquals(capturing.getCapturedText(), expectedPath); + + ss.log( + '✓ Unbound explorer absolute path → picker → bind+send (merged message, pty capture verified content)', + ); + }); + + test('[assisted] context-menus-explorer-007: Explorer "Send Relative File Path" (unbound) opens picker and sends relative path to selected terminal', async () => { + const uri = await ss.createAndOpenFile('ctxmenu-exp-007', FILE_CONTENT); + const fn = path.basename(uri.fsPath); + const relativePath = vscode.workspace.asRelativePath(uri, false); + + const terminalName = 'rl-ctxmenu-exp-007'; + const capturing = await ss.createCapturingTerminal(terminalName); + + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("rl-ctxmenu-exp-007") — File path sent', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const logCapture = getLogCapture(); + logCapture.mark('before-ctxmenu-exp-007'); + capturing.clearCaptured(); + + await waitForHuman( + 'context-menus-explorer-007', + `Right-click "${fn}" in Explorer → "RangeLink: Send Relative File Path" → select "${terminalName}" from the destination picker`, + [ + `1. Locate "${fn}" in the Explorer panel`, + '2. Right-click it', + '3. Select "RangeLink: Send Relative File Path"', + `4. In the destination picker, select "${terminalName}"`, + ], + ); + + const lines = logCapture.getLinesSince('before-ctxmenu-exp-007'); + + assertFilePathLogged(lines, { + pathFormat: 'workspace-relative', + uriSource: 'context-menu', + filePath: relativePath, + }); + const expectedPath = ` ${relativePath} `; + assertClipboardWriteLogged(lines, { textLength: expectedPath.length }); + assertTerminalBufferEquals(capturing.getCapturedText(), expectedPath); + + ss.log( + '✓ Unbound explorer relative path → picker → bind+send (merged message, pty capture verified content)', + ); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts index 469fb197..adef8e69 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts @@ -325,8 +325,7 @@ standardSuite('Context Menus — Terminal', (ss) => { await ss.settle(TERMINAL_READY_MS); ss.expectStatusBarMessages([ - `✓ RangeLink: Bound to Terminal ("${destName}")`, - `✓ RangeLink: Selected text sent to Terminal ("${destName}")`, + `✓ RangeLink: Bound to Terminal ("${destName}") — Selected text sent`, ]); ss.expectContextKeys({ 'rangelink.isActiveTerminalBindable': true, @@ -600,8 +599,7 @@ standardSuite('Context Menus — Terminal', (ss) => { await ss.settle(TERMINAL_READY_MS); ss.expectStatusBarMessages([ - '✓ RangeLink: Bound to Terminal ("rl-sts-011-DEST")', - '✓ RangeLink: Selected text sent to Terminal ("rl-sts-011-DEST")', + '✓ RangeLink: Bound to Terminal ("rl-sts-011-DEST") — Selected text sent', ]); ss.expectContextKeys({ 'rangelink.isActiveTerminalBindable': true, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts index e0c2cfb2..637dfb09 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts @@ -53,8 +53,7 @@ standardSuite('Dirty Buffer Warning', (ss) => { const capturing = await ss.createCapturingTerminal('dirty-buffer-test'); ss.expectStatusBarMessages([ - '✓ RangeLink: Bound to Terminal ("dirty-buffer-test")', - '✓ RangeLink: File path sent to Terminal ("dirty-buffer-test")', + '✓ RangeLink: Bound to Terminal ("dirty-buffer-test") — File path sent', ]); ss.expectContextKeys({ 'rangelink.isBound': true, @@ -122,8 +121,7 @@ standardSuite('Dirty Buffer Warning', (ss) => { const capturing = await ss.createCapturingTerminal('dirty-buffer-test'); ss.expectStatusBarMessages([ - '✓ RangeLink: Bound to Terminal ("dirty-buffer-test")', - '✓ RangeLink: File path sent to Terminal ("dirty-buffer-test")', + '✓ RangeLink: Bound to Terminal ("dirty-buffer-test") — File path sent', ]); ss.expectContextKeys({ 'rangelink.isBound': true, @@ -630,8 +628,7 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { test('[assisted] dirty-buffer-warning-013: R-F Save & Send saves file and sends path', async () => { ss.expectStatusBarMessages([ - '✓ RangeLink: Bound to Terminal ("dirty-buffer-test")', - '✓ RangeLink: File path sent to Terminal ("dirty-buffer-test")', + '✓ RangeLink: Bound to Terminal ("dirty-buffer-test") — File path sent', ]); ss.expectContextKeys({ 'rangelink.isBound': true, @@ -681,8 +678,7 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { test('[assisted] dirty-buffer-warning-014: R-F Send Anyway sends path without saving', async () => { ss.expectStatusBarMessages([ - '✓ RangeLink: Bound to Terminal ("dirty-buffer-test")', - '✓ RangeLink: File path sent to Terminal ("dirty-buffer-test")', + '✓ RangeLink: Bound to Terminal ("dirty-buffer-test") — File path sent', ]); ss.expectContextKeys({ 'rangelink.isBound': true, 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 0592ba4b..4969183a 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/PasteDestinationManager.test.ts @@ -196,7 +196,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -244,6 +244,33 @@ describe('PasteDestinationManager', () => { expect(mockFeedback.notifyAlreadyBound).toHaveBeenCalledWith('Terminal ("bash")'); }); + it('should suppress notifyBound when skipMessage is true', async () => { + mockAdapter.__getVscodeInstance().window.activeTerminal = mockTerminal; + + const result = await manager.bind( + { kind: 'terminal', terminal: mockTerminal }, + { skipMessage: true }, + ); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Terminal ("bash")', + destinationKind: 'terminal', + }); + }); + expect(mockSession.isSet()).toBe(true); + expect(mockFeedback.notifyBound).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + { + fn: 'PasteDestinationManager.commitBind', + kind: 'terminal', + displayName: 'Terminal ("bash")', + terminalName: 'bash', + }, + 'Successfully bound to "Terminal ("bash")"', + ); + }); + it('should handle terminal with custom name', async () => { const customTerminal = createMockTerminal({ name: 'zsh', @@ -259,7 +286,7 @@ describe('PasteDestinationManager', () => { }); }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("zsh")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("zsh")'); }); }); @@ -279,7 +306,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -343,7 +370,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Claude Code Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Claude Code Chat'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -354,6 +381,22 @@ describe('PasteDestinationManager', () => { ); }); + it('should suppress notifyBound for chat destination when skipMessage is true', async () => { + const mockDestination = createMockClaudeCodeDestination({ isAvailable: true }); + jest.spyOn(mockRegistry, 'create').mockReturnValue(mockDestination); + + const result = await manager.bind({ kind: 'claude-code' }, { skipMessage: true }); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Claude Code Chat', + destinationKind: 'claude-code', + }); + }); + expect(mockSession.isSet()).toBe(true); + expect(mockFeedback.notifyBound).not.toHaveBeenCalled(); + }); + it('should bind to gemini-code-assist when available', async () => { const mockDestination = createMockGeminiCodeAssistDestination({ isAvailable: true }); jest.spyOn(mockRegistry, 'create').mockReturnValue(mockDestination); @@ -368,7 +411,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Gemini Code Assist', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Gemini Code Assist'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -413,7 +456,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -461,7 +504,7 @@ describe('PasteDestinationManager', () => { }); expect(mockFeedback.notifyAlreadyBound).toHaveBeenCalledWith('Cursor AI Assistant'); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant'); }); it('should show info message when already bound to github-copilot-chat', async () => { @@ -484,7 +527,7 @@ describe('PasteDestinationManager', () => { }); expect(mockFeedback.notifyAlreadyBound).toHaveBeenCalledWith('GitHub Copilot Chat'); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat'); }); it('should bind to custom AI assistant kind successfully', async () => { @@ -505,7 +548,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Acme Spark AI', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Acme Spark AI'); }); it('should show ERROR_CUSTOM_AI_NOT_AVAILABLE when custom AI assistant is not available', async () => { @@ -558,11 +601,7 @@ describe('PasteDestinationManager', () => { }); expect(mockSession.isSet()).toBe(true); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Text Editor ("file.ts")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Text Editor ("file.ts")'); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'PasteDestinationManager.commitBind', @@ -575,6 +614,34 @@ describe('PasteDestinationManager', () => { ); }); + it('should suppress notifyBound for text-editor when skipMessage is true', async () => { + const mockUri = createMockUri('/workspace/src/file.ts'); + const mockDocument = createMockDocument({ uri: mockUri }); + const mockEditor = createMockEditor({ + document: mockDocument, + selection: { active: { line: 0, character: 0 } } as vscode.Selection, + }); + + mockAdapter.__getVscodeInstance().window.activeTextEditor = mockEditor; + configureEmptyTabGroups(mockAdapter.__getVscodeInstance().window, 2); + + mockAdapter.__getVscodeInstance().window.visibleTextEditors = [mockEditor]; + + const result = await manager.bind( + { kind: 'text-editor', uri: mockUri, viewColumn: 1 }, + { skipMessage: true }, + ); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Text Editor ("file.ts")', + destinationKind: 'text-editor', + }); + }); + expect(mockSession.isSet()).toBe(true); + expect(mockFeedback.notifyBound).not.toHaveBeenCalled(); + }); + it('should fail to bind text editor when file is closed', async () => { mockAdapter.__getVscodeInstance().window.activeTextEditor = undefined; mockAdapter.__getVscodeInstance().window.visibleTextEditors = []; @@ -657,11 +724,7 @@ describe('PasteDestinationManager', () => { ); expect(mockFeedback.notifyBackgroundTabOpened).toHaveBeenCalledWith('file.ts'); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Text Editor ("file.ts")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Text Editor ("file.ts")'); }); it('should fail when showTextDocument resolves but editor not at expected viewColumn', async () => { @@ -767,6 +830,47 @@ describe('PasteDestinationManager', () => { }); }); + describe('buildCommitBindOptions', () => { + const callPrivate = (manager: PasteDestinationManager) => + (manager as any).buildCommitBindOptions as ( + wasBackgroundTab: boolean, + statusBarOptions?: { skipMessage?: boolean }, + ) => { suppressAutoPaste?: true; statusBarOptions?: { skipMessage?: boolean } } | undefined; + + it('returns undefined when neither flag is set', () => { + const result = callPrivate(manager)(false, undefined); + + expect(result).toBeUndefined(); + }); + + it('returns suppressAutoPaste when only background tab flag is set', () => { + const result = callPrivate(manager)(true, undefined); + + expect(result).toStrictEqual({ suppressAutoPaste: true }); + }); + + it('returns statusBarOptions when only status bar options are provided', () => { + const result = callPrivate(manager)(false, { skipMessage: true }); + + expect(result).toStrictEqual({ statusBarOptions: { skipMessage: true } }); + }); + + it('returns both fields when both flags are set', () => { + const result = callPrivate(manager)(true, { skipMessage: true }); + + expect(result).toStrictEqual({ + suppressAutoPaste: true, + statusBarOptions: { skipMessage: true }, + }); + }); + + it('returns statusBarOptions when background tab is false and skipMessage is false', () => { + const result = callPrivate(manager)(false, { skipMessage: false }); + + expect(result).toStrictEqual({ statusBarOptions: { skipMessage: false } }); + }); + }); + describe('bind() - cross-destination conflicts', () => { it('should show confirmation when binding chat while terminal already bound', async () => { const { manager: localManager, adapter: localAdapter } = createManager({ @@ -794,7 +898,7 @@ describe('PasteDestinationManager', () => { newDestination: 'Cursor AI Assistant', }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); }); it('should show confirmation when binding terminal while chat already bound', async () => { @@ -822,7 +926,7 @@ describe('PasteDestinationManager', () => { newDestination: 'Terminal ("bash")', }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant'); }); it('should show confirmation when binding github-copilot-chat while terminal already bound', async () => { @@ -852,7 +956,7 @@ describe('PasteDestinationManager', () => { newDestination: 'GitHub Copilot Chat', }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); }); it('should show confirmation when binding terminal while github-copilot-chat already bound', async () => { @@ -896,7 +1000,7 @@ describe('PasteDestinationManager', () => { newDestination: 'Terminal ("bash")', }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat'); }); it('should show confirmation when replacing cursor-ai with github-copilot-chat', async () => { @@ -931,7 +1035,7 @@ describe('PasteDestinationManager', () => { newDestination: 'GitHub Copilot Chat', }); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant'); }); it('should skip confirmation when re-binding the same AI assistant', async () => { @@ -1034,7 +1138,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('Terminal ("bash")'); }); @@ -1048,7 +1152,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Cursor AI Assistant'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('Cursor AI Assistant'); }); @@ -1063,7 +1167,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockSession.get()).toBeUndefined(); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'GitHub Copilot Chat'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('GitHub Copilot Chat'); }); @@ -1078,7 +1182,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockSession.get()).toBeUndefined(); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Claude Code Chat', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Claude Code Chat'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('Claude Code Chat'); }); @@ -1093,7 +1197,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockSession.get()).toBeUndefined(); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Gemini Code Assist', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Gemini Code Assist'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('Gemini Code Assist'); }); @@ -1116,11 +1220,7 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(false); expect(mockSession.get()).toBeUndefined(); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Text Editor ("file.ts")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Text Editor ("file.ts")'); expect(mockFeedback.notifyUnbound).toHaveBeenCalledWith('Text Editor ("file.ts")'); }); @@ -1480,15 +1580,11 @@ describe('PasteDestinationManager', () => { expect(mockSession.isSet()).toBe(true); expect(mockSession.get()?.id).toBe('text-editor'); - // Assert: Exactly 2 toasts across both binds — first bind + rebound - expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(2); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Terminal ("TestTerminal")', - undefined, - ); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 2, + // Assert: First bind toast + rebound toast + expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); + expect(mockFeedback.notifyBound).toHaveBeenCalledWith('Terminal ("TestTerminal")'); + expect(mockFeedback.notifyRebound).toHaveBeenCalledTimes(1); + expect(mockFeedback.notifyRebound).toHaveBeenCalledWith( 'Text Editor ("file.ts")', 'Terminal ("TestTerminal")', ); @@ -1559,11 +1655,7 @@ describe('PasteDestinationManager', () => { // Assert: Only first bind toast, no replacement toast expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Terminal ("TestTerminal")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("TestTerminal")'); }); }); @@ -1607,11 +1699,7 @@ describe('PasteDestinationManager', () => { // Assert: Only first bind toast, no second toast expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Terminal ("TestTerminal")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("TestTerminal")'); // Assert: Still bound to Terminal expect(mockSession.get()?.id).toBe('terminal'); @@ -1642,11 +1730,7 @@ describe('PasteDestinationManager', () => { // Assert: Standard toast shown (no "Unbound..." prefix) expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Terminal ("TestTerminal")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("TestTerminal")'); // Assert: QuickPick NOT shown (no existing binding) expect(mockVscode.window.showQuickPick).not.toHaveBeenCalled(); @@ -1707,11 +1791,7 @@ describe('PasteDestinationManager', () => { // Assert: Only first bind toast, no replacement toast expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith( - 1, - 'Terminal ("TestTerminal")', - undefined, - ); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("TestTerminal")'); }); }); }); @@ -1790,7 +1870,7 @@ describe('PasteDestinationManager', () => { ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); expect(mockVscode.window.showErrorMessage).not.toHaveBeenCalled(); expect(mockFeedback.notifyAlreadyBound).not.toHaveBeenCalled(); }); @@ -1854,7 +1934,7 @@ describe('PasteDestinationManager', () => { 'Already bound to Terminal ("bash"), no action taken', ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal ("bash")'); }); }); }); @@ -1911,7 +1991,7 @@ describe('PasteDestinationManager', () => { 'Successfully focused Terminal', ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal'); expect(mockFeedback.notifyJumpFocused).toHaveBeenCalledWith('Focused Terminal: "bash"'); }); @@ -1946,7 +2026,7 @@ describe('PasteDestinationManager', () => { 'Successfully focused Terminal', ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal'); expect(mockFeedback.notifyJumpFocused).toHaveBeenCalledWith('Focused Terminal: "bash"'); }); @@ -1980,7 +2060,7 @@ describe('PasteDestinationManager', () => { 'Successfully focused Terminal', ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal'); }); it('returns err with DESTINATION_FOCUS_FAILED when focus fails', async () => { @@ -2004,7 +2084,7 @@ describe('PasteDestinationManager', () => { 'Failed to focus Terminal', ); expect(mockFeedback.notifyBound).toHaveBeenCalledTimes(1); - expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal', undefined); + expect(mockFeedback.notifyBound).toHaveBeenNthCalledWith(1, 'Terminal'); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/feedback/OperationFeedbackProvider.test.ts b/packages/rangelink-vscode-extension/src/__tests__/feedback/OperationFeedbackProvider.test.ts index 1333d057..0ae392e7 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/feedback/OperationFeedbackProvider.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/feedback/OperationFeedbackProvider.test.ts @@ -167,6 +167,146 @@ describe('OperationFeedbackProvider', () => { expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); }); + describe('with bindContext', () => { + const bindContext = { destinationName: 'Terminal ("bash")' }; + + it('sent-automatic + bindContext uses merged message code', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { kind: 'sent-automatic' }, + bindContext, + ); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_BOUND_AND_SENT', { + destinationName: 'Terminal ("bash")', + linkTypeName: 'RangeLink', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — RangeLink sent', + ); + expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); + expect(mockAdapter.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('sent-automatic without bindContext uses existing message code', () => { + provider.provideSendFeedback(createPasteContext() as any, { kind: 'sent-automatic' }); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_LINK_SENT_TO_DESTINATION', { + linkTypeName: 'RangeLink', + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'RangeLink sent to Terminal ("bash")', + ); + }); + + it('sent-manual + bindContext prepends bound prefix to clipboard status bar message', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { + kind: 'sent-manual', + instruction: 'Press Cmd+V to paste', + }, + bindContext, + ); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_BOUND_PREFIX', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — RangeLink copied to clipboard', + ); + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith('Press Cmd+V to paste'); + }); + + it('failed-manual + bindContext prepends bound prefix to clipboard status bar message', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { + kind: 'failed-manual', + instruction: 'Manual paste required', + }, + bindContext, + ); + + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — RangeLink copied to clipboard', + ); + expect(mockAdapter.showWarningMessage).toHaveBeenCalledWith('Manual paste required'); + }); + + it('failed-automatic + bindContext prepends bound prefix to warning message', () => { + provider.provideSendFeedback( + createPasteContext({ + destination: { kind: 'text-editor', label: 'file.ts', displayName: 'Text Editor' }, + }) as any, + { kind: 'failed-automatic', destinationKind: 'text-editor' }, + bindContext, + ); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_BOUND_PREFIX', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.showWarningMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — Could not send to editor. Make sure the bound editor is visible and focused.', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + + it('self-paste-blocked + bindContext with clipboard written prepends bound prefix to status bar only', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { + kind: 'self-paste-blocked', + destinationKind: 'text-editor', + clipboardWritten: true, + toastMessage: 'Cannot auto-paste to same file.', + }, + bindContext, + ); + + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith( + 'Cannot auto-paste to same file.', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — RangeLink copied to clipboard', + ); + }); + + it('self-paste-blocked + bindContext without clipboard written does not show status bar', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { + kind: 'self-paste-blocked', + destinationKind: 'text-editor', + clipboardWritten: false, + toastMessage: 'Cannot paste when bound editor has an active selection.', + }, + bindContext, + ); + + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith( + 'Cannot paste when bound editor has an active selection.', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + + it('clipboard-preservation-failed + bindContext prepends bound prefix to warning', () => { + provider.provideSendFeedback( + createPasteContext() as any, + { + kind: 'clipboard-preservation-failed', + }, + bindContext, + ); + + expect(mockAdapter.showWarningMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash") — Clipboard preservation failed. Content was not sent.', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + it('throws RangeLinkExtensionError on unexpected outcome kind', () => { expect(() => provider.provideSendFeedback( @@ -282,4 +422,150 @@ describe('OperationFeedbackProvider', () => { }); }); }); + + describe('notifyBound', () => { + it('shows bound status bar message', () => { + provider.notifyBound('Terminal ("bash")'); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_BOUND', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Bound to Terminal ("bash")', + ); + expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyRebound', () => { + it('shows rebound status bar message with previous and new destination names', () => { + provider.notifyRebound('Text Editor ("file.ts")', 'Terminal ("bash")'); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_REBOUND', { + previousDestination: 'Terminal ("bash")', + newDestination: 'Text Editor ("file.ts")', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Unbound Terminal ("bash"), now bound to Text Editor ("file.ts")', + ); + expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyAlreadyBound', () => { + it('shows info message that destination is already bound', () => { + provider.notifyAlreadyBound('Terminal ("bash")'); + + expect(formatMessageSpy).toHaveBeenCalledWith('ALREADY_BOUND_TO_DESTINATION', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith( + 'Already bound to Terminal ("bash")', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyBindFailedEditor', () => { + it('shows error message with formatted code and params', () => { + provider.notifyBindFailedEditor(MessageCode.ERROR_TEXT_EDITOR_READ_ONLY, { + scheme: 'output', + }); + + expect(formatMessageSpy).toHaveBeenCalledWith('ERROR_TEXT_EDITOR_READ_ONLY', { + scheme: 'output', + }); + expect(mockAdapter.showErrorMessage).toHaveBeenCalledWith( + 'Cannot bind to read-only editor (output)', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyBindFailedNotAvailable', () => { + it('shows specific error for known AI assistant kind', () => { + provider.notifyBindFailedNotAvailable('Claude Code', 'claude-code'); + + expect(formatMessageSpy).toHaveBeenCalledWith('ERROR_CLAUDE_CODE_NOT_AVAILABLE'); + expect(mockAdapter.showErrorMessage).toHaveBeenCalledWith( + 'Cannot bind Claude Code - extension not installed or not active', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + + it('shows generic error for custom AI assistant kind', () => { + provider.notifyBindFailedNotAvailable('My Extension', 'custom-ai:my-extension'); + + expect(formatMessageSpy).toHaveBeenCalledWith('ERROR_CUSTOM_AI_NOT_AVAILABLE', { + extensionName: 'My Extension', + }); + expect(mockAdapter.showErrorMessage).toHaveBeenCalledWith( + 'Cannot bind My Extension - extension not installed or not active', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyBackgroundTabOpened', () => { + it('shows info message with file name', () => { + provider.notifyBackgroundTabOpened('file.ts'); + + expect(formatMessageSpy).toHaveBeenCalledWith('INFO_BACKGROUND_TAB_OPENED', { + fileName: 'file.ts', + }); + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith( + '"file.ts" opened at last cursor position. Adjust cursor before pasting.', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyUnbound', () => { + it('shows unbound status bar message', () => { + provider.notifyUnbound('Terminal ("bash")'); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_UNBOUND', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Unbound from Terminal ("bash")', + ); + expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyNothingToUnbind', () => { + it('shows not-bound status bar message', () => { + provider.notifyNothingToUnbind(); + + expect(formatMessageSpy).toHaveBeenCalledWith('STATUS_BAR_DESTINATION_NOT_BOUND'); + expect(mockAdapter.setStatusBarMessage).toHaveBeenCalledWith('No destination bound'); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyJumpFocused', () => { + it('shows provided message as successful status bar message', () => { + provider.notifyJumpFocused('Focused Terminal ("bash")'); + + expect(mockAdapter.setSuccessfulStatusBarMessage).toHaveBeenCalledWith( + 'Focused Terminal ("bash")', + ); + expect(mockAdapter.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe('notifyJumpFailed', () => { + it('shows info message that jump focus failed', () => { + provider.notifyJumpFailed('Terminal ("bash")'); + + expect(formatMessageSpy).toHaveBeenCalledWith('INFO_JUMP_FOCUS_FAILED', { + destinationName: 'Terminal ("bash")', + }); + expect(mockAdapter.showInformationMessage).toHaveBeenCalledWith( + 'Failed to focus Terminal ("bash")', + ); + expect(mockAdapter.setSuccessfulStatusBarMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockOperationFeedbackProvider.ts b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockOperationFeedbackProvider.ts index 73d3e9c6..eec0a7a5 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockOperationFeedbackProvider.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockOperationFeedbackProvider.ts @@ -12,6 +12,7 @@ export const createMockOperationFeedbackProvider = (): jest.Mocked< notifyAutoUnbind: jest.fn(), notifyDuplicateTabWarning: jest.fn(), notifyBound: jest.fn(), + notifyRebound: jest.fn(), notifyAlreadyBound: jest.fn(), notifyBindFailedEditor: jest.fn(), notifyBindFailedNotAvailable: jest.fn(), diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts index a3b7c114..4d1e45a5 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts @@ -108,7 +108,7 @@ describe('FilePathPaster', () => { it('delegates to pasteFilePath when active editor exists', async () => { const uri = createMockUri('/workspace/src/file.ts'); jest.spyOn(mockAdapter, 'getActiveTabUri').mockReturnValue(uri); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await paster.pasteCurrentFilePathToDestination(PathFormat.Absolute); @@ -124,7 +124,7 @@ describe('FilePathPaster', () => { jest .spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning') .mockResolvedValue(DirtyBufferWarningResult.ContinueAnyway); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); @@ -138,30 +138,36 @@ describe('FilePathPaster', () => { jest .spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning') .mockResolvedValue(DirtyBufferWarningResult.ContinueAnyway); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); - expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith({ - control: { - contentType: 'Text', - }, - content: { - clipboard: '/workspace/src/file.ts', - send: ' /workspace/src/file.ts ', - sourceUri: uri, - sourceViewColumn: undefined, - }, - strategies: { - sendFn: expect.any(Function) as unknown, - isEligibleFn: expect.any(Function) as unknown, + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: '/workspace/src/file.ts', + send: ' /workspace/src/file.ts ', + sourceUri: uri, + sourceViewColumn: undefined, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_FILE_PATH', + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, }, - contentNameCode: 'CONTENT_NAME_FILE_PATH', - fnName: 'pasteFilePath', - selfPastePolicy: 'block-on-editor-selection', - writeClipboardOnSelfPasteBlock: true, - }); + undefined, + ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'FilePathPaster.pasteFilePath', @@ -180,10 +186,36 @@ describe('FilePathPaster', () => { jest .spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning') .mockResolvedValue(DirtyBufferWarningResult.ContinueAnyway); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: "'/workspace/my project/file'\\''s.ts'", + send: " '/workspace/my project/file'\\''s.ts' ", + sourceUri: uri, + sourceViewColumn: undefined, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_FILE_PATH', + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'FilePathPaster.pasteFilePath', @@ -232,11 +264,36 @@ describe('FilePathPaster', () => { jest .spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning') .mockResolvedValue(DirtyBufferWarningResult.SaveAndContinue); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: '/workspace/src/file.ts', + send: ' /workspace/src/file.ts ', + sourceUri: uri, + sourceViewColumn: undefined, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_FILE_PATH', + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); }); it('proceeds when handleDirtyBufferWarning returns ContinueAnyway', async () => { @@ -246,11 +303,36 @@ describe('FilePathPaster', () => { jest .spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning') .mockResolvedValue(DirtyBufferWarningResult.ContinueAnyway); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: '/workspace/src/file.ts', + send: ' /workspace/src/file.ts ', + sourceUri: uri, + sourceViewColumn: undefined, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_FILE_PATH', + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); }); it('passes document, configReader, and R-F message codes to handleDirtyBufferWarning', async () => { @@ -277,12 +359,37 @@ describe('FilePathPaster', () => { jest.spyOn(mockAdapter, 'findOpenDocument').mockReturnValue(undefined); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); const warningSpy = jest.spyOn(handleDirtyBufferWarningModule, 'handleDirtyBufferWarning'); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await paster.pasteFilePathToDestination(uri, PathFormat.Absolute); expect(warningSpy).not.toHaveBeenCalled(); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: '/workspace/src/file.ts', + send: ' /workspace/src/file.ts ', + sourceUri: uri, + sourceViewColumn: undefined, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_FILE_PATH', + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); }); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/LinkGenerator.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/LinkGenerator.test.ts index afb3c9ea..c1feaf88 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/LinkGenerator.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/LinkGenerator.test.ts @@ -106,12 +106,39 @@ describe('LinkGenerator', () => { mockSelectionValidator.validateSelectionsAndShowError.mockReturnValue(validated); jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); - mockGenLink.mockReturnValue(Result.ok(createMockFormattedLink('src/file.ts#L1'))); - mockSendRouter.resolveDestination.mockResolvedValue(true); + const link = createMockFormattedLink('src/file.ts#L1'); + mockGenLink.mockReturnValue(Result.ok(link)); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await generator.createLink(); + const expectedViewColumn = mockAdapter.getActiveEditorViewColumn(); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Link', + }, + content: { + clipboard: 'src/file.ts#L1', + send: { ...link, link: ' src/file.ts#L1 ' }, + sourceUri: mockDoc.uri, + sourceViewColumn: expectedViewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_RANGELINK', + fnName: 'copyToClipboardAndDestination', + selfPastePolicy: 'block-on-uri', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); }); it('aborts when generateLinkFromSelection returns undefined', async () => { @@ -153,7 +180,7 @@ describe('LinkGenerator', () => { jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); mockGenLink.mockReturnValue(Result.ok(createMockFormattedLink('src/file.ts#L1'))); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await generator.createLink(); @@ -213,12 +240,39 @@ describe('LinkGenerator', () => { mockSelectionValidator.validateSelectionsAndShowError.mockReturnValue(validated); jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); - mockGenLink.mockReturnValue(Result.ok(createMockFormattedLink('src/file.ts#L1'))); - mockSendRouter.resolveDestination.mockResolvedValue(true); + const link = createMockFormattedLink('src/file.ts#L1'); + mockGenLink.mockReturnValue(Result.ok(link)); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await generator.createPortableLink(); + const expectedViewColumn = mockAdapter.getActiveEditorViewColumn(); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Link', + }, + content: { + clipboard: 'src/file.ts#L1', + send: { ...link, link: ' src/file.ts#L1 ' }, + sourceUri: mockDoc.uri, + sourceViewColumn: expectedViewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_PORTABLE_RANGELINK', + fnName: 'copyToClipboardAndDestination', + selfPastePolicy: 'block-on-uri', + writeClipboardOnSelfPasteBlock: true, + }, + undefined, + ); }); }); @@ -308,7 +362,7 @@ describe('LinkGenerator', () => { mockGenLink.mockReturnValue(Result.ok(link)); jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await generator.createLink(); @@ -343,7 +397,7 @@ describe('LinkGenerator', () => { jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); mockGenLink.mockReturnValue(Result.ok(createMockFormattedLink('src/file.ts#L4C1-L4C11'))); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await generator.createLink(); @@ -416,7 +470,7 @@ describe('LinkGenerator', () => { jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(mockDoc.uri); jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); mockGenLink.mockReturnValue(Result.ok(createMockFormattedLink('src/file.ts#L1'))); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await generator.createLink(); @@ -433,7 +487,10 @@ describe('LinkGenerator', () => { jest.spyOn(mockAdapter, 'getWorkspaceFolder').mockReturnValue(undefined); const link = createMockFormattedLink('src/file.ts#L1'); mockGenLink.mockReturnValue(Result.ok(link)); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await generator.createLink(); @@ -447,25 +504,28 @@ describe('LinkGenerator', () => { ); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); const expectedViewColumn = mockAdapter.getActiveEditorViewColumn(); - expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith({ - control: { - contentType: 'Link', - }, - content: { - clipboard: 'src/file.ts#L1', - send: { ...link, link: ' src/file.ts#L1 ' }, - sourceUri: mockDoc.uri, - sourceViewColumn: expectedViewColumn, - }, - strategies: { - sendFn: expect.any(Function) as unknown, - isEligibleFn: expect.any(Function) as unknown, + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Link', + }, + content: { + clipboard: 'src/file.ts#L1', + send: { ...link, link: ' src/file.ts#L1 ' }, + sourceUri: mockDoc.uri, + sourceViewColumn: expectedViewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_RANGELINK', + fnName: 'copyToClipboardAndDestination', + selfPastePolicy: 'block-on-uri', + writeClipboardOnSelfPasteBlock: true, }, - contentNameCode: 'CONTENT_NAME_RANGELINK', - fnName: 'copyToClipboardAndDestination', - selfPastePolicy: 'block-on-uri', - writeClipboardOnSelfPasteBlock: true, - }); + undefined, + ); }); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/SendRouter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/SendRouter.test.ts index af5a400f..af8d3363 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/SendRouter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/SendRouter.test.ts @@ -76,7 +76,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(true); + expect(result).toStrictEqual({ canProceed: true, bindPerformed: false }); expect(mockDestinationPicker.pick).not.toHaveBeenCalled(); }); @@ -95,7 +95,11 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(true); + expect(result).toStrictEqual({ + canProceed: true, + bindPerformed: true, + destinationName: 'Terminal', + }); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'test' }, 'No destination bound, showing quick pick', @@ -108,7 +112,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'test', outcome: 'no-resource' }, 'Picker did not bind, aborting', @@ -121,7 +125,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); }); it('returns false when bind fails', async () => { @@ -139,7 +143,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); expect(mockFeedbackProvider.showError).toHaveBeenCalled(); }); }); @@ -197,6 +201,7 @@ describe('SendRouter', () => { }, }, { kind: 'clipboard-preservation-failed' }, + undefined, ); }); @@ -224,6 +229,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-automatic' }, + undefined, ); }); @@ -311,6 +317,7 @@ describe('SendRouter', () => { toastMessage: 'Cannot auto-paste to same file. Link copied to clipboard. Tip: Use R-C for clipboard-only links.', }, + undefined, ); }); @@ -369,6 +376,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-automatic' }, + undefined, ); }); @@ -397,6 +405,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-manual', instruction: 'Press Cmd+V to paste' }, + undefined, ); }); @@ -437,6 +446,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'failed-manual', instruction: 'Manual paste required' }, + undefined, ); }); @@ -471,6 +481,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'failed-automatic', destinationKind: 'terminal' }, + undefined, ); }); @@ -501,6 +512,7 @@ describe('SendRouter', () => { destination: { kind: 'terminal', label: 'bash', displayName: '' }, }, { kind: 'sent-automatic' }, + undefined, ); }); }); @@ -536,6 +548,7 @@ describe('SendRouter', () => { destination: { kind: 'text-editor', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-automatic' }, + undefined, ); }); @@ -592,6 +605,7 @@ describe('SendRouter', () => { toastMessage: 'Cannot auto-paste to same file. Link copied to clipboard. Tip: Use R-C for clipboard-only links.', }, + undefined, ); }); @@ -632,6 +646,7 @@ describe('SendRouter', () => { destination: { kind: 'text-editor', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-automatic' }, + undefined, ); }); @@ -680,6 +695,7 @@ describe('SendRouter', () => { toastMessage: 'Cannot paste when bound editor has an active selection. File path copied to clipboard.', }, + undefined, ); expect( jest @@ -733,6 +749,7 @@ describe('SendRouter', () => { clipboardWritten: false, toastMessage: 'Cannot paste when bound editor has an active selection.', }, + undefined, ); expect( jest @@ -788,6 +805,7 @@ describe('SendRouter', () => { destination: { kind: 'text-editor', label: 'bash', displayName: 'Terminal ("bash")' }, }, { kind: 'sent-automatic' }, + undefined, ); }); }); @@ -899,7 +917,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'SendRouter.showPickerAndBind' }, 'No destinations available - no action taken', @@ -912,7 +930,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); expect(mockLogger.info).toHaveBeenCalledWith( { fn: 'SendRouter.showPickerAndBind' }, 'User cancelled quick pick - no action taken', @@ -934,7 +952,15 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(true); + expect(result).toStrictEqual({ + canProceed: true, + bindPerformed: true, + destinationName: 'Terminal', + }); + expect(mockDestinationManager.bind).toHaveBeenCalledWith( + { kind: 'terminal', terminal: { name: 'bash' } }, + { skipMessage: true }, + ); }); it('returns bind-failed when bind returns error', async () => { @@ -952,7 +978,7 @@ describe('SendRouter', () => { const result = await router.resolveDestination({ fn: 'test' }); - expect(result).toBe(false); + expect(result).toStrictEqual({ canProceed: false }); expect(mockLogger.error).toHaveBeenCalled(); expect(mockFeedbackProvider.showError).toHaveBeenCalled(); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/TerminalSelectionService.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/TerminalSelectionService.test.ts index 113a608b..1c4dffe9 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/TerminalSelectionService.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/TerminalSelectionService.test.ts @@ -147,7 +147,7 @@ describe('TerminalSelectionService', () => { mockClipboardService.capture.mockResolvedValue( ExtensionResult.ok({ clipboard: 'selected text', produced: undefined }), ); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); const result = await service.pasteTerminalSelectionToDestination(); @@ -161,28 +161,34 @@ describe('TerminalSelectionService', () => { mockClipboardService.capture.mockResolvedValue( ExtensionResult.ok({ clipboard: 'selected text', produced: undefined }), ); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); mockConfigReader.getPaddingMode.mockReturnValue('both'); const result = await service.pasteTerminalSelectionToDestination(); expect(result).toStrictEqual({ outcome: 'success' }); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); - expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith({ - control: { - contentType: 'Text', - }, - content: { - clipboard: ' selected text ', - send: ' selected text ', - }, - strategies: { - sendFn: expect.any(Function) as unknown, - isEligibleFn: expect.any(Function) as unknown, + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: ' selected text ', + send: ' selected text ', + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', + fnName: 'pasteTerminalSelectionToDestination', }, - contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', - fnName: 'pasteTerminalSelectionToDestination', - }); + undefined, + ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'TerminalSelectionService.pasteTerminalSelectionToDestination', @@ -201,7 +207,10 @@ describe('TerminalSelectionService', () => { mockClipboardService.capture.mockResolvedValue( ExtensionResult.ok({ clipboard: 'text', produced: undefined }), ); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ + canProceed: true, + bindPerformed: false, + }); await service.terminalLinkBridge(); diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/TextSelectionPaster.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/TextSelectionPaster.test.ts index a02ddcba..73c01593 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/TextSelectionPaster.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/TextSelectionPaster.test.ts @@ -63,30 +63,33 @@ describe('TextSelectionPaster', () => { editor: mockEditor, selections: [sel], }); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: true, bindPerformed: false }); mockConfigReader.getPaddingMode.mockReturnValue('both'); await paster.pasteSelectedTextToDestination(); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); - expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith({ - control: { - contentType: 'Text', - }, - content: { - clipboard: ' selected text ', - send: ' selected text ', - sourceUri: mockDoc.uri, - sourceViewColumn: mockEditor.viewColumn, - }, - strategies: { - sendFn: expect.any(Function) as unknown, - isEligibleFn: expect.any(Function) as unknown, + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: ' selected text ', + send: ' selected text ', + sourceUri: mockDoc.uri, + sourceViewColumn: mockEditor.viewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', + fnName: 'pasteSelectedTextToDestination', + selfPastePolicy: 'block-on-editor-selection', }, - contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', - fnName: 'pasteSelectedTextToDestination', - selfPastePolicy: 'block-on-editor-selection', - }); + undefined, + ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'TextSelectionPaster.pasteSelectedTextToDestination', @@ -110,10 +113,32 @@ describe('TextSelectionPaster', () => { editor: mockEditor, selections: [sel1, sel2], }); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: true, bindPerformed: false }); await paster.pasteSelectedTextToDestination(); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: 'line1\nline2', + send: 'line1\nline2', + sourceUri: mockDoc.uri, + sourceViewColumn: mockEditor.viewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', + fnName: 'pasteSelectedTextToDestination', + selfPastePolicy: 'block-on-editor-selection', + }, + undefined, + ); expect(mockLogger.debug).toHaveBeenCalledWith( { fn: 'TextSelectionPaster.pasteSelectedTextToDestination', @@ -135,7 +160,7 @@ describe('TextSelectionPaster', () => { editor: mockEditor, selections: [sel], }); - mockSendRouter.resolveDestination.mockResolvedValue(false); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: false }); await paster.pasteSelectedTextToDestination(); @@ -153,10 +178,31 @@ describe('TextSelectionPaster', () => { editor: mockEditor, selections: [sel], }); - mockSendRouter.resolveDestination.mockResolvedValue(true); + mockSendRouter.resolveDestination.mockResolvedValue({ canProceed: true, bindPerformed: false }); await paster.pasteSelectedTextToDestination(); expect(mockSendRouter.sendToDestination).toHaveBeenCalledTimes(1); + expect(mockSendRouter.sendToDestination).toHaveBeenCalledWith( + { + control: { + contentType: 'Text', + }, + content: { + clipboard: 'text', + send: 'text', + sourceUri: mockDoc.uri, + sourceViewColumn: mockEditor.viewColumn, + }, + strategies: { + sendFn: expect.any(Function) as unknown, + isEligibleFn: expect.any(Function) as unknown, + }, + contentNameCode: 'CONTENT_NAME_SELECTED_TEXT', + fnName: 'pasteSelectedTextToDestination', + selfPastePolicy: 'block-on-editor-selection', + }, + undefined, + ); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/services/toBindContext.test.ts b/packages/rangelink-vscode-extension/src/__tests__/services/toBindContext.test.ts new file mode 100644 index 00000000..7eb87264 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__tests__/services/toBindContext.test.ts @@ -0,0 +1,31 @@ +import { toBindContext } from '../../services/toBindContext'; +import type { ResolveResult } from '../../services/types'; + +describe('toBindContext', () => { + it('returns BindContext when canProceed and bindPerformed are both true', () => { + const result: ResolveResult = { + canProceed: true, + bindPerformed: true, + destinationName: 'Terminal ("bash")', + }; + + expect(toBindContext(result)).toStrictEqual({ destinationName: 'Terminal ("bash")' }); + }); + + it('returns undefined when canProceed is true but bindPerformed is false', () => { + const result: ResolveResult = { + canProceed: true, + bindPerformed: false, + }; + + expect(toBindContext(result)).toBeUndefined(); + }); + + it('returns undefined when canProceed is false', () => { + const result: ResolveResult = { + canProceed: false, + }; + + expect(toBindContext(result)).toBeUndefined(); + }); +}); diff --git a/packages/rangelink-vscode-extension/src/destinations/DestinationBinder.ts b/packages/rangelink-vscode-extension/src/destinations/DestinationBinder.ts index 32a56517..97aa6ec8 100644 --- a/packages/rangelink-vscode-extension/src/destinations/DestinationBinder.ts +++ b/packages/rangelink-vscode-extension/src/destinations/DestinationBinder.ts @@ -1,7 +1,10 @@ -import type { BindOptions, ExtensionResult } from '../types'; +import type { BindOptions, ExtensionResult, StatusBarOptions } from '../types'; import type { BindSuccessInfo } from './PasteDestinationManager'; export interface DestinationBinder { - bind(options: BindOptions): Promise>; + bind( + options: BindOptions, + statusBarOptions?: StatusBarOptions, + ): Promise>; } diff --git a/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts b/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts index 0c5c6337..1ebd7170 100644 --- a/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts +++ b/packages/rangelink-vscode-extension/src/destinations/PasteDestinationManager.ts @@ -51,25 +51,28 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo private readonly logger: Logger, ) {} - async bind(options: BindOptions): Promise> { + async bind( + options: BindOptions, + statusBarOptions?: StatusBarOptions, + ): Promise> { switch (options.kind) { case 'terminal': { const newDestination = this.registry.create({ kind: 'terminal', terminal: options.terminal, }); - return this.commitBind(newDestination); + return this.commitBind(newDestination, statusBarOptions ? { statusBarOptions } : undefined); } case 'text-editor': - return this.bindTextEditor(options); + return this.bindTextEditor(options, statusBarOptions); case 'cursor-ai': case 'gemini-code-assist': case 'github-copilot-chat': case 'claude-code': - return this.bindGenericDestination(options.kind); + return this.bindGenericDestination(options.kind, statusBarOptions); default: if (isCustomAiAssistantKind(options.kind)) { - return this.bindGenericDestination(options.kind); + return this.bindGenericDestination(options.kind, statusBarOptions); } throw new RangeLinkExtensionError({ code: RangeLinkExtensionErrorCodes.UNEXPECTED_DESTINATION_KIND, @@ -211,6 +214,7 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo */ private async bindTextEditor( options: TextEditorBindOptions, + statusBarOptions?: StatusBarOptions, ): Promise> { const fnName = 'bindTextEditor'; @@ -279,10 +283,8 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo const newDestination = this.registry.create(options); - return this.commitBind( - newDestination, - wasBackgroundTab ? { suppressAutoPaste: true } : undefined, - ); + const bindOptions = this.buildCommitBindOptions(wasBackgroundTab, statusBarOptions); + return this.commitBind(newDestination, bindOptions); } /** @@ -293,6 +295,7 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo */ private async bindGenericDestination( kind: DestinationKind, + statusBarOptions?: StatusBarOptions, ): Promise> { const fnName = 'bindGenericDestination'; @@ -313,7 +316,7 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo ); } - return this.commitBind(newDestination); + return this.commitBind(newDestination, statusBarOptions ? { statusBarOptions } : undefined); } /** @@ -323,11 +326,11 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo * then delegate the common bind flow here. * * @param newDestination - The destination to bind - * @param options - Optional bind metadata (e.g., suppressAutoPaste for background-tab binds) + * @param bindOptions - Optional bind metadata (suppressAutoPaste for background-tab binds, statusBarOptions to suppress toast) */ private async commitBind( newDestination: PasteDestination, - options?: { suppressAutoPaste?: true }, + bindOptions?: { suppressAutoPaste?: true; statusBarOptions?: StatusBarOptions }, ): Promise> { const kind = newDestination.id; const logCtx = { fn: 'PasteDestinationManager.commitBind', kind }; @@ -379,15 +382,33 @@ export class PasteDestinationManager implements DestinationBinder, DestinationFo `Successfully bound to "${newDestination.displayName}"`, ); - this.feedback.notifyBound(newDestination.displayName, replacedName); + if (!bindOptions?.statusBarOptions?.skipMessage) { + if (replacedName) { + this.feedback.notifyRebound(newDestination.displayName, replacedName); + } else { + this.feedback.notifyBound(newDestination.displayName); + } + } return ExtensionResult.ok({ destinationName: newDestination.displayName, destinationKind: kind, - ...(options?.suppressAutoPaste && { suppressAutoPaste: true as const }), + ...(bindOptions?.suppressAutoPaste && { suppressAutoPaste: true as const }), }); } + private buildCommitBindOptions( + wasBackgroundTab: boolean, + statusBarOptions?: StatusBarOptions, + ): { suppressAutoPaste?: true; statusBarOptions?: StatusBarOptions } | undefined { + return wasBackgroundTab || statusBarOptions + ? { + ...(wasBackgroundTab && { suppressAutoPaste: true as const }), + ...(statusBarOptions && { statusBarOptions }), + } + : undefined; + } + private async executeSend(options: { logContext: LoggingContext; debugMessage: (displayName: string) => string; diff --git a/packages/rangelink-vscode-extension/src/feedback/BindingFeedback.ts b/packages/rangelink-vscode-extension/src/feedback/BindingFeedback.ts index 13e80d72..94940389 100644 --- a/packages/rangelink-vscode-extension/src/feedback/BindingFeedback.ts +++ b/packages/rangelink-vscode-extension/src/feedback/BindingFeedback.ts @@ -1,7 +1,8 @@ import type { DestinationKind, MessageCode } from '../types'; export interface BindingFeedback { - notifyBound(destinationName: string, replacedName?: string): void; + notifyBound(destinationName: string): void; + notifyRebound(newDestinationName: string, previousDestinationName: string): void; notifyAlreadyBound(destinationName: string): void; notifyBindFailedEditor(messageCode: MessageCode, params: Record): void; notifyBindFailedNotAvailable(displayName: string, kind: DestinationKind): void; diff --git a/packages/rangelink-vscode-extension/src/feedback/OperationFeedbackProvider.ts b/packages/rangelink-vscode-extension/src/feedback/OperationFeedbackProvider.ts index b45a7c8f..27a8e16e 100644 --- a/packages/rangelink-vscode-extension/src/feedback/OperationFeedbackProvider.ts +++ b/packages/rangelink-vscode-extension/src/feedback/OperationFeedbackProvider.ts @@ -3,6 +3,7 @@ import { RangeLinkExtensionErrorCodes } from '../errors/RangeLinkExtensionErrorC import type { VscodeAdapter } from '../ide/vscode/VscodeAdapter'; import { type AIAssistantDestinationKind, + type BindContext, type DestinationKind, isAnyAiAssistantKind, MessageCode, @@ -48,14 +49,19 @@ export class OperationFeedbackProvider implements LifecycleFeedbackProvider, Bin ); } - notifyBound(destinationName: string, replacedName?: string): void { - const message = replacedName - ? formatMessage(MessageCode.STATUS_BAR_DESTINATION_REBOUND, { - previousDestination: replacedName, - newDestination: destinationName, - }) - : formatMessage(MessageCode.STATUS_BAR_DESTINATION_BOUND, { destinationName }); - this.vscodeAdapter.setSuccessfulStatusBarMessage(message); + notifyBound(destinationName: string): void { + this.vscodeAdapter.setSuccessfulStatusBarMessage( + formatMessage(MessageCode.STATUS_BAR_DESTINATION_BOUND, { destinationName }), + ); + } + + notifyRebound(newDestinationName: string, previousDestinationName: string): void { + this.vscodeAdapter.setSuccessfulStatusBarMessage( + formatMessage(MessageCode.STATUS_BAR_DESTINATION_REBOUND, { + previousDestination: previousDestinationName, + newDestination: newDestinationName, + }), + ); } notifyAlreadyBound(destinationName: string): void { @@ -118,36 +124,42 @@ export class OperationFeedbackProvider implements LifecycleFeedbackProvider, Bin this.vscodeAdapter.setSuccessfulStatusBarMessage(message); } - provideSendFeedback(context: PasteContext, outcome: PasteSendOutcome): void { + provideSendFeedback( + context: PasteContext, + outcome: PasteSendOutcome, + bindContext?: BindContext, + ): void { const linkTypeName = formatMessage(context.contentType); switch (outcome.kind) { case 'sent-automatic': { - const destinationName = context.destination.displayName; - this.vscodeAdapter.setSuccessfulStatusBarMessage( - formatMessage(MessageCode.STATUS_BAR_LINK_SENT_TO_DESTINATION, { - linkTypeName, - destinationName, - }), - ); - break; - } - case 'sent-manual': { - this.vscodeAdapter.setSuccessfulStatusBarMessage( - formatMessage(MessageCode.STATUS_BAR_LINK_COPIED_TO_CLIPBOARD, { linkTypeName }), - ); - void this.vscodeAdapter.showInformationMessage(outcome.instruction); + const message = bindContext + ? formatMessage(MessageCode.STATUS_BAR_DESTINATION_BOUND_AND_SENT, { + destinationName: bindContext.destinationName, + linkTypeName, + }) + : formatMessage(MessageCode.STATUS_BAR_LINK_SENT_TO_DESTINATION, { + linkTypeName, + destinationName: context.destination.displayName, + }); + this.vscodeAdapter.setSuccessfulStatusBarMessage(message); break; } + case 'sent-manual': case 'failed-manual': { this.vscodeAdapter.setSuccessfulStatusBarMessage( - formatMessage(MessageCode.STATUS_BAR_LINK_COPIED_TO_CLIPBOARD, { linkTypeName }), + this.buildClipboardMessage(linkTypeName, bindContext), ); - void this.vscodeAdapter.showWarningMessage(outcome.instruction); + if (outcome.kind === 'sent-manual') { + void this.vscodeAdapter.showInformationMessage(outcome.instruction); + } else { + void this.vscodeAdapter.showWarningMessage(outcome.instruction); + } break; } case 'failed-automatic': { + const failureMessage = this.buildPasteFailureMessage(outcome.destinationKind); void this.vscodeAdapter.showWarningMessage( - this.buildPasteFailureMessage(outcome.destinationKind), + bindContext ? `${this.buildBoundPrefix(bindContext)}${failureMessage}` : failureMessage, ); break; } @@ -155,14 +167,15 @@ export class OperationFeedbackProvider implements LifecycleFeedbackProvider, Bin void this.vscodeAdapter.showInformationMessage(outcome.toastMessage); if (outcome.clipboardWritten) { this.vscodeAdapter.setSuccessfulStatusBarMessage( - formatMessage(MessageCode.STATUS_BAR_LINK_COPIED_TO_CLIPBOARD, { linkTypeName }), + this.buildClipboardMessage(linkTypeName, bindContext), ); } break; } case 'clipboard-preservation-failed': { + const warningMessage = formatMessage(MessageCode.WARN_CLIPBOARD_PRESERVATION_FAILED); void this.vscodeAdapter.showWarningMessage( - formatMessage(MessageCode.WARN_CLIPBOARD_PRESERVATION_FAILED), + bindContext ? `${this.buildBoundPrefix(bindContext)}${warningMessage}` : warningMessage, ); break; } @@ -175,6 +188,20 @@ export class OperationFeedbackProvider implements LifecycleFeedbackProvider, Bin } } + private buildBoundPrefix(bindContext: BindContext): string { + return formatMessage(MessageCode.STATUS_BAR_DESTINATION_BOUND_PREFIX, { + destinationName: bindContext.destinationName, + }); + } + + private buildClipboardMessage(linkTypeName: string, bindContext?: BindContext): string { + const base = formatMessage(MessageCode.STATUS_BAR_LINK_COPIED_TO_CLIPBOARD, { linkTypeName }); + if (bindContext) { + return `${this.buildBoundPrefix(bindContext)}${base}`; + } + return base; + } + private buildPasteFailureMessage(destinationKind: string): string { switch (destinationKind) { case 'text-editor': diff --git a/packages/rangelink-vscode-extension/src/i18n/messages.en.ts b/packages/rangelink-vscode-extension/src/i18n/messages.en.ts index cf6d329d..015337a1 100644 --- a/packages/rangelink-vscode-extension/src/i18n/messages.en.ts +++ b/packages/rangelink-vscode-extension/src/i18n/messages.en.ts @@ -179,6 +179,9 @@ export const messagesEn: Record = { [MessageCode.SMART_BIND_CONFIRM_YES_REPLACE]: 'Yes, replace', [MessageCode.STATUS_BAR_DESTINATION_BOUND]: 'Bound to {destinationName}', + [MessageCode.STATUS_BAR_DESTINATION_BOUND_AND_SENT]: + 'Bound to {destinationName} — {linkTypeName} sent', + [MessageCode.STATUS_BAR_DESTINATION_BOUND_PREFIX]: 'Bound to {destinationName} — ', [MessageCode.STATUS_BAR_DESTINATION_NOT_BOUND]: 'No destination bound', [MessageCode.STATUS_BAR_DESTINATION_REBOUND]: 'Unbound {previousDestination}, now bound to {newDestination}', diff --git a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts index 06760bff..aa978d24 100644 --- a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts +++ b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts @@ -20,6 +20,7 @@ import { applySmartPadding, formatMessage } from '../utils'; import { handleDirtyBufferWarning } from './handleDirtyBufferWarning'; import type { SendRouter } from './SendRouter'; +import { toBindContext } from './toBindContext'; import { FILE_PATH_DIRTY_BUFFER_CODES } from './types'; /** @@ -111,12 +112,13 @@ export class FilePathPaster { DEFAULT_SMART_PADDING_PASTE_FILE_PATH, ); - const resolved = await this.sendRouter.resolveDestination(logCtx); - if (!resolved) return; + const resolveResult = await this.sendRouter.resolveDestination(logCtx); + if (!resolveResult.canProceed) return; const destinationFilePath = quotePath(filePath); const paddedPath = applySmartPadding(destinationFilePath, paddingMode); + const bindContext = toBindContext(resolveResult); if (destinationFilePath !== filePath) { this.logger.debug( @@ -125,24 +127,27 @@ export class FilePathPaster { ); } - await this.sendRouter.sendToDestination({ - control: { - contentType: PasteContentType.Text, + await this.sendRouter.sendToDestination( + { + control: { + contentType: PasteContentType.Text, + }, + content: { + clipboard: destinationFilePath, + send: paddedPath, + sourceUri: uri, + sourceViewColumn: this.ideAdapter.getActiveTabViewColumn(), + }, + strategies: { + sendFn: (text) => this.destinationManager.sendTextToDestination(text), + isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), + }, + contentNameCode: MessageCode.CONTENT_NAME_FILE_PATH, + fnName: 'pasteFilePath', + selfPastePolicy: 'block-on-editor-selection', + writeClipboardOnSelfPasteBlock: true, }, - content: { - clipboard: destinationFilePath, - send: paddedPath, - sourceUri: uri, - sourceViewColumn: this.ideAdapter.getActiveTabViewColumn(), - }, - strategies: { - sendFn: (text) => this.destinationManager.sendTextToDestination(text), - isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), - }, - contentNameCode: MessageCode.CONTENT_NAME_FILE_PATH, - fnName: 'pasteFilePath', - selfPastePolicy: 'block-on-editor-selection', - writeClipboardOnSelfPasteBlock: true, - }); + bindContext, + ); } } diff --git a/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts b/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts index 017ecf35..35bc1db2 100644 --- a/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts +++ b/packages/rangelink-vscode-extension/src/services/LinkGenerator.ts @@ -7,13 +7,20 @@ import { DEFAULT_SMART_PADDING_PASTE_LINK, SETTING_SMART_PADDING_PASTE_LINK } fr import type { PasteDestinationManager } from '../destinations/PasteDestinationManager'; import type { OperationFeedbackProvider } from '../feedback'; import type { VscodeAdapter } from '../ide/vscode/VscodeAdapter'; -import { DirtyBufferWarningResult, MessageCode, PasteContentType, PathFormat } from '../types'; +import { + type BindContext, + DirtyBufferWarningResult, + MessageCode, + PasteContentType, + PathFormat, +} from '../types'; import { applySmartPadding, formatMessage, generateLinkFromSelections } from '../utils'; import { getReferencePath } from './FilePathPaster'; import { handleDirtyBufferWarning } from './handleDirtyBufferWarning'; import type { SelectionValidator } from './SelectionValidator'; import type { SendRouter } from './SendRouter'; +import { toBindContext } from './toBindContext'; import { LINK_DIRTY_BUFFER_CODES } from './types'; /** @@ -71,9 +78,15 @@ export class LinkGenerator { this.logger.debug(logCtx, 'Active editor URI unavailable, aborting'); return; } - const resolved = await this.sendRouter.resolveDestination(logCtx); - if (!resolved) return; - await this.copyToClipboardAndDestination(formattedLink, contentNameCode, sourceUri); + const resolveResult = await this.sendRouter.resolveDestination(logCtx); + if (!resolveResult.canProceed) return; + const bindContext = toBindContext(resolveResult); + await this.copyToClipboardAndDestination( + formattedLink, + contentNameCode, + sourceUri, + bindContext, + ); } else { this.logger.debug(logCtx, 'generateLinkFromSelection returned undefined, aborting'); } @@ -167,6 +180,7 @@ export class LinkGenerator { formattedLink: FormattedLink, contentNameCode: MessageCode, sourceUri: vscode.Uri, + bindContext?: BindContext, ): Promise { const logCtx = { fn: 'LinkGenerator.copyToClipboardAndDestination' }; const paddingMode = this.configReader.getPaddingMode( @@ -181,24 +195,27 @@ export class LinkGenerator { 'Sending link to destination', ); - await this.sendRouter.sendToDestination({ - control: { - contentType: PasteContentType.Link, - }, - content: { - clipboard: formattedLink.link, - send: { ...formattedLink, link: paddedLink }, - sourceUri, - sourceViewColumn: this.ideAdapter.getActiveEditorViewColumn(), - }, - strategies: { - sendFn: (link) => this.destinationManager.sendLinkToDestination(link), - isEligibleFn: (destination, link) => destination.isEligibleForPasteLink(link), + await this.sendRouter.sendToDestination( + { + control: { + contentType: PasteContentType.Link, + }, + content: { + clipboard: formattedLink.link, + send: { ...formattedLink, link: paddedLink }, + sourceUri, + sourceViewColumn: this.ideAdapter.getActiveEditorViewColumn(), + }, + strategies: { + sendFn: (link) => this.destinationManager.sendLinkToDestination(link), + isEligibleFn: (destination, link) => destination.isEligibleForPasteLink(link), + }, + contentNameCode, + fnName: 'copyToClipboardAndDestination', + selfPastePolicy: 'block-on-uri', + writeClipboardOnSelfPasteBlock: true, }, - contentNameCode, - fnName: 'copyToClipboardAndDestination', - selfPastePolicy: 'block-on-uri', - writeClipboardOnSelfPasteBlock: true, - }); + bindContext, + ); } } diff --git a/packages/rangelink-vscode-extension/src/services/SendRouter.ts b/packages/rangelink-vscode-extension/src/services/SendRouter.ts index 09697247..ef165d52 100644 --- a/packages/rangelink-vscode-extension/src/services/SendRouter.ts +++ b/packages/rangelink-vscode-extension/src/services/SendRouter.ts @@ -12,9 +12,17 @@ import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors import type { OperationFeedbackProvider } from '../feedback'; import type { PasteContext, PasteSendOutcome } from '../feedback'; import type { ClipboardWriter } from '../ide/ClipboardProvider'; -import { AutoPasteResult, MessageCode, type QuickPickBindResult, type SendOptions } from '../types'; +import { + AutoPasteResult, + type BindContext, + MessageCode, + type QuickPickBindResult, + type SendOptions, +} from '../types'; import { formatMessage, isEditorDestination, isSameFileDestination } from '../utils'; +import type { ResolveResult } from './types'; + /** * Routes content through clipboard and to a bound destination. * Handles clipboard preservation, eligibility checks, self-paste detection, @@ -33,28 +41,34 @@ export class SendRouter { /** * Ensure a destination is bound, showing the picker if needed. - * Returns true if a destination is available (already bound or user picked one). + * Returns a discriminated union describing what happened: already bound, + * newly bound via picker (with destination name), or no destination available. */ - async resolveDestination(logCtx: LoggingContext): Promise { + async resolveDestination(logCtx: LoggingContext): Promise { if (!this.session.isSet()) { this.logger.debug(logCtx, 'No destination bound, showing quick pick'); const pickerResult = await this.showPickerAndBind(); - if (pickerResult.outcome !== 'bound') { - this.logger.debug( - { ...logCtx, outcome: pickerResult.outcome }, - 'Picker did not bind, aborting', - ); - return false; + if (pickerResult.outcome === 'bound') { + return { + canProceed: true, + bindPerformed: true, + destinationName: pickerResult.bindInfo.destinationName, + }; } + this.logger.debug( + { ...logCtx, outcome: pickerResult.outcome }, + 'Picker did not bind, aborting', + ); + return { canProceed: false }; } - return true; + return { canProceed: true, bindPerformed: false }; } /** * Routes content through clipboard and to the bound destination. * Handles clipboard preservation, eligibility, self-paste detection, and feedback. */ - async sendToDestination(options: SendOptions): Promise { + async sendToDestination(options: SendOptions, bindContext?: BindContext): Promise { const destinationSnapshot = this.session.get(); const shouldRoute = destinationSnapshot !== undefined; if (shouldRoute) { @@ -74,6 +88,7 @@ export class SendRouter { this.feedbackProvider.provideSendFeedback( this.buildPasteContext(options, destinationSnapshot), { kind: 'clipboard-preservation-failed' }, + bindContext, ); } return; @@ -82,6 +97,7 @@ export class SendRouter { this.feedbackProvider.provideSendFeedback( this.buildPasteContext(options, destinationSnapshot), outcome, + bindContext, ); } } else { @@ -272,7 +288,7 @@ export class SendRouter { return { outcome: 'cancelled' }; case 'selected': { - const bindResult = await this.binder.bind(result.bindOptions); + const bindResult = await this.binder.bind(result.bindOptions, { skipMessage: true }); if (!bindResult.success) { this.logger.error( { ...logCtx, error: bindResult.error }, diff --git a/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts b/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts index a5f12c22..a537512b 100644 --- a/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts +++ b/packages/rangelink-vscode-extension/src/services/TerminalSelectionService.ts @@ -14,6 +14,7 @@ import { MessageCode, PasteContentType, type TerminalPasteResult } from '../type import { applySmartPadding, formatMessage } from '../utils'; import type { SendRouter } from './SendRouter'; +import { toBindContext } from './toBindContext'; interface CaptureErrorInfo { readonly logMessage: string; @@ -90,8 +91,8 @@ export class TerminalSelectionService { `Read ${terminalText.length} chars from terminal selection`, ); - const resolved = await this.sendRouter.resolveDestination(logCtx); - if (!resolved) return { outcome: 'picker-cancelled' }; + const resolveResult = await this.sendRouter.resolveDestination(logCtx); + if (!resolveResult.canProceed) return { outcome: 'picker-cancelled' }; const paddingMode = this.configReader.getPaddingMode( SETTING_SMART_PADDING_PASTE_CONTENT, @@ -99,22 +100,26 @@ export class TerminalSelectionService { ); const paddedText = applySmartPadding(terminalText, paddingMode); - - await this.sendRouter.sendToDestination({ - control: { - contentType: PasteContentType.Text, - }, - content: { - clipboard: paddedText, - send: paddedText, + const bindContext = toBindContext(resolveResult); + + await this.sendRouter.sendToDestination( + { + control: { + contentType: PasteContentType.Text, + }, + content: { + clipboard: paddedText, + send: paddedText, + }, + strategies: { + sendFn: (text) => this.destinationManager.sendTextToDestination(text), + isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), + }, + contentNameCode: MessageCode.CONTENT_NAME_SELECTED_TEXT, + fnName: 'pasteTerminalSelectionToDestination', }, - strategies: { - sendFn: (text) => this.destinationManager.sendTextToDestination(text), - isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), - }, - contentNameCode: MessageCode.CONTENT_NAME_SELECTED_TEXT, - fnName: 'pasteTerminalSelectionToDestination', - }); + bindContext, + ); return { outcome: 'success' }; } diff --git a/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts b/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts index d36390f0..30275011 100644 --- a/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts +++ b/packages/rangelink-vscode-extension/src/services/TextSelectionPaster.ts @@ -11,6 +11,7 @@ import { applySmartPadding } from '../utils'; import type { SelectionValidator } from './SelectionValidator'; import type { SendRouter } from './SendRouter'; +import { toBindContext } from './toBindContext'; /** * Pastes the current editor text selection to the bound destination. @@ -48,28 +49,32 @@ export class TextSelectionPaster { DEFAULT_SMART_PADDING_PASTE_CONTENT, ); - const resolved = await this.sendRouter.resolveDestination(logCtx); - if (!resolved) return; + const resolveResult = await this.sendRouter.resolveDestination(logCtx); + if (!resolveResult.canProceed) return; const paddedContent = applySmartPadding(content, paddingMode); + const bindContext = toBindContext(resolveResult); - await this.sendRouter.sendToDestination({ - control: { - contentType: PasteContentType.Text, + await this.sendRouter.sendToDestination( + { + control: { + contentType: PasteContentType.Text, + }, + content: { + clipboard: paddedContent, + send: paddedContent, + sourceUri: editor.document.uri, + sourceViewColumn: editor.viewColumn, + }, + strategies: { + sendFn: (text) => this.destinationManager.sendTextToDestination(text), + isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), + }, + contentNameCode: MessageCode.CONTENT_NAME_SELECTED_TEXT, + fnName: 'pasteSelectedTextToDestination', + selfPastePolicy: 'block-on-editor-selection', }, - content: { - clipboard: paddedContent, - send: paddedContent, - sourceUri: editor.document.uri, - sourceViewColumn: editor.viewColumn, - }, - strategies: { - sendFn: (text) => this.destinationManager.sendTextToDestination(text), - isEligibleFn: (destination, text) => destination.isEligibleForPasteContent(text), - }, - contentNameCode: MessageCode.CONTENT_NAME_SELECTED_TEXT, - fnName: 'pasteSelectedTextToDestination', - selfPastePolicy: 'block-on-editor-selection', - }); + bindContext, + ); } } diff --git a/packages/rangelink-vscode-extension/src/services/toBindContext.ts b/packages/rangelink-vscode-extension/src/services/toBindContext.ts new file mode 100644 index 00000000..3da45568 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/services/toBindContext.ts @@ -0,0 +1,8 @@ +import type { BindContext } from '../types'; + +import type { ResolveResult } from './types'; + +export const toBindContext = (result: ResolveResult): BindContext | undefined => + result.canProceed && result.bindPerformed + ? { destinationName: result.destinationName } + : undefined; diff --git a/packages/rangelink-vscode-extension/src/services/types/ResolveResult.ts b/packages/rangelink-vscode-extension/src/services/types/ResolveResult.ts new file mode 100644 index 00000000..cbf212b5 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/services/types/ResolveResult.ts @@ -0,0 +1,4 @@ +export type ResolveResult = + | { canProceed: true; bindPerformed: false } + | { canProceed: true; bindPerformed: true; destinationName: string } + | { canProceed: false }; diff --git a/packages/rangelink-vscode-extension/src/services/types/index.ts b/packages/rangelink-vscode-extension/src/services/types/index.ts index c219607d..23fd5acf 100644 --- a/packages/rangelink-vscode-extension/src/services/types/index.ts +++ b/packages/rangelink-vscode-extension/src/services/types/index.ts @@ -1 +1,2 @@ export * from './DirtyBufferMessageCodes'; +export * from './ResolveResult'; diff --git a/packages/rangelink-vscode-extension/src/types/BindContext.ts b/packages/rangelink-vscode-extension/src/types/BindContext.ts new file mode 100644 index 00000000..81f0ced5 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/types/BindContext.ts @@ -0,0 +1,3 @@ +export interface BindContext { + readonly destinationName: string; +} diff --git a/packages/rangelink-vscode-extension/src/types/MessageCode.ts b/packages/rangelink-vscode-extension/src/types/MessageCode.ts index 499b4a58..debd624d 100644 --- a/packages/rangelink-vscode-extension/src/types/MessageCode.ts +++ b/packages/rangelink-vscode-extension/src/types/MessageCode.ts @@ -148,6 +148,8 @@ export enum MessageCode { // Status bar messages (user-facing UI) STATUS_BAR_DESTINATION_BOUND = 'STATUS_BAR_DESTINATION_BOUND', + STATUS_BAR_DESTINATION_BOUND_AND_SENT = 'STATUS_BAR_DESTINATION_BOUND_AND_SENT', + STATUS_BAR_DESTINATION_BOUND_PREFIX = 'STATUS_BAR_DESTINATION_BOUND_PREFIX', STATUS_BAR_DESTINATION_NOT_BOUND = 'STATUS_BAR_DESTINATION_NOT_BOUND', STATUS_BAR_DESTINATION_REBOUND = 'STATUS_BAR_DESTINATION_REBOUND', STATUS_BAR_DESTINATION_UNBOUND = 'STATUS_BAR_DESTINATION_UNBOUND', diff --git a/packages/rangelink-vscode-extension/src/types/index.ts b/packages/rangelink-vscode-extension/src/types/index.ts index ef6338ae..8ff553b1 100644 --- a/packages/rangelink-vscode-extension/src/types/index.ts +++ b/packages/rangelink-vscode-extension/src/types/index.ts @@ -16,6 +16,7 @@ export * from './RelativePathFormat'; export * from './ResolvedPath'; export * from './TerminalFocusType'; +export type * from './BindContext'; export type * from './BindOptions'; export type * from './BoundDestinationInfo'; export type * from './BoundState';