diff --git a/packages/rangelink-vscode-extension/CHANGELOG.md b/packages/rangelink-vscode-extension/CHANGELOG.md index f572c0ea..9d0fc0b6 100644 --- a/packages/rangelink-vscode-extension/CHANGELOG.md +++ b/packages/rangelink-vscode-extension/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) + ## [2.0.0] ### Added diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml index 61dda855..49ebf767 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases.yaml @@ -699,6 +699,22 @@ test_cases: expected_result: 'File path inserted at cursor. Status bar "File path sent to Text Editor".' automated: true + - id: send-file-path-017 + labels: + - clipboard + feature: 'send-file-path' + scenario: 'R-F sends file path when active tab is an image preview' + expected_result: 'Terminal receives workspace-relative path to the image file. No "No active file" error toast.' + automated: true + + - id: send-file-path-018 + labels: + - clipboard + feature: 'send-file-path' + scenario: 'R-F sends the modified side path when active tab is a text diff view' + expected_result: 'Terminal receives workspace-relative path to the right (modified) file in the diff, not the left (original).' + automated: true + # --------------------------------------------------------------------------- # Section 7 — Context Menu Integrations # --------------------------------------------------------------------------- diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/fileHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/fileHelpers.ts index 7f37e12c..929e88ec 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/fileHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/fileHelpers.ts @@ -7,6 +7,24 @@ import { getWorkspaceRoot, settle } from './testEnv'; let fileCounter = 0; +let fileCleanupRegistry: vscode.Uri[] = []; + +const registerFileForCleanup = (uri: vscode.Uri): void => { + fileCleanupRegistry.push(uri); +}; + +export const cleanupTrackedFiles = (): void => { + cleanupFiles(fileCleanupRegistry); + fileCleanupRegistry = []; +}; + +const ensureParentDir = (filePath: string): void => { + const dir = path.dirname(filePath); + if (dir !== getWorkspaceRoot()) { + fs.mkdirSync(dir, { recursive: true }); + } +}; + export const createWorkspaceFile = (descriptor: string, content: string): vscode.Uri => { fileCounter++; const filePath = path.join( @@ -14,17 +32,17 @@ export const createWorkspaceFile = (descriptor: string, content: string): vscode `__rl-test-${descriptor}-${Date.now()}-${fileCounter}.txt`, ); fs.writeFileSync(filePath, content, 'utf8'); - return vscode.Uri.file(filePath); + const uri = vscode.Uri.file(filePath); + registerFileForCleanup(uri); + return uri; }; export const createAndOpenFile = async ( descriptor: string, content: string, viewColumn?: vscode.ViewColumn, - trackingArray?: vscode.Uri[], ): Promise => { const uri = createWorkspaceFile(descriptor, content); - trackingArray?.push(uri); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc, { viewColumn: viewColumn ?? vscode.ViewColumn.One, @@ -47,8 +65,40 @@ export const findTestItemsByPrefix = ( export const createFileAt = (filename: string, content: string): vscode.Uri => { const filePath = path.join(getWorkspaceRoot(), filename); + ensureParentDir(filePath); fs.writeFileSync(filePath, content, 'utf8'); - return vscode.Uri.file(filePath); + const uri = vscode.Uri.file(filePath); + registerFileForCleanup(uri); + return uri; +}; + +const PNG_MAGIC_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export type PngFixtureMode = 'real-image' | 'magic-only'; + +export const createPngFixture = ( + descriptor: string, + mode: PngFixtureMode = 'real-image', +): vscode.Uri => { + fileCounter++; + const pngPath = path.join( + getWorkspaceRoot(), + `__rl-test-${descriptor}-${Date.now()}-${fileCounter}.png`, + ); + if (mode === 'real-image') { + const extension = vscode.extensions.getExtension('couimet.rangelink-vscode-extension'); + if (!extension) { + throw new Error( + 'createPngFixture(real-image) requires the RangeLink extension to be registered', + ); + } + fs.copyFileSync(path.join(extension.extensionPath, 'icon.png'), pngPath); + } else { + fs.writeFileSync(pngPath, PNG_MAGIC_BYTES); + } + const uri = vscode.Uri.file(pngPath); + registerFileForCleanup(uri); + return uri; }; export const openEditor = async ( diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/ssContext.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/ssContext.ts index 9f67d8c7..8b0fa08e 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/ssContext.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/ssContext.ts @@ -6,7 +6,15 @@ import type { LogCapture } from '../../LogCapture'; import type { CapturingTerminal } from './capturingPtyHelpers'; import { createAndBindCapturingTerminal, createCapturingTerminal } from './capturingPtyHelpers'; -import { cleanupFiles, createAndOpenFile, createWorkspaceFile, openEditor } from './fileHelpers'; +import { + cleanupTrackedFiles, + createAndOpenFile, + createFileAt, + createPngFixture, + createWorkspaceFile, + openEditor, + type PngFixtureMode, +} from './fileHelpers'; import { getLogCapture } from './getLogCapture'; import { SETTLE_MS, TERMINAL_READY_MS, waitForExtensionActive } from './testEnv'; import { @@ -31,7 +39,25 @@ export interface SsContext { lineCount: number, lineFactory: (index: number) => string, ) => { uri: vscode.Uri; filename: string }; + /** + * Create a tracked workspace file with a generated filename + * (`__rl-test---.txt`). + */ createWorkspaceFile: (descriptor: string, content: string) => vscode.Uri; + /** + * Create a tracked workspace file with an exact filename. + * Prefer `createWorkspaceFile` when the descriptor-based naming is sufficient; + * use this when the test needs a specific filename (spaces, parentheses, etc.). + */ + createTrackedFile: (filename: string, content: string) => vscode.Uri; + /** + * Create a tracked `.png` fixture in the workspace root. Auto-tracked for + * teardown. `'real-image'` (default) copies the extension's `icon.png` so + * the file opens in VS Code's image preview as a real custom editor; + * `'magic-only'` writes just the 8-byte PNG signature for tests that only + * need a binary classification. + */ + createPngFixture: (descriptor: string, mode?: PngFixtureMode) => vscode.Uri; createAndOpenFile: ( descriptor: string, content: string, @@ -72,12 +98,10 @@ export interface SsContext { expectContextKeys: (keys: Record) => void; openEditor: (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) => Promise; waitForExtensionActive: (extensionId: string, timeoutMs?: number) => Promise; - trackFileUri: (uri: vscode.Uri) => void; clearDummyAi: () => Promise; } export class SsContextImpl implements SsContext { - private tmpFileUris: vscode.Uri[] = []; private tmpTerminals: vscode.Terminal[] = []; private suiteLog: (msg: string) => void; private expectedStatusBarMessages: string[] = []; @@ -92,11 +116,8 @@ export class SsContextImpl implements SsContext { // calls disposeAllTerminals() + CMD_UNBIND_DESTINATION before beginTest(). // Disposing here would fire onDidCloseTerminal during the observation window // and leak status bar messages into verify(). - // File deletion is safe here: fs.unlinkSync does not trigger editor-close events. - // Only editor close (closeAllEditors, in setup) can fire onDidCloseTextDocument. this.tmpTerminals.splice(0); - cleanupFiles(this.tmpFileUris); - this.tmpFileUris.splice(0); + cleanupTrackedFiles(); await this.settle(); }); } @@ -135,14 +156,19 @@ export class SsContextImpl implements SsContext { ): { uri: vscode.Uri; filename: string } { const lines = Array.from({ length: lineCount }, (_, i) => lineFactory(i)); const uri = createWorkspaceFile(descriptor, lines.join('\n') + '\n'); - this.tmpFileUris.push(uri); return { uri, filename: path.basename(uri.fsPath) }; } createWorkspaceFile(descriptor: string, content: string): vscode.Uri { - const uri = createWorkspaceFile(descriptor, content); - this.tmpFileUris.push(uri); - return uri; + return createWorkspaceFile(descriptor, content); + } + + createTrackedFile(filename: string, content: string): vscode.Uri { + return createFileAt(filename, content); + } + + createPngFixture(descriptor: string, mode: PngFixtureMode = 'real-image'): vscode.Uri { + return createPngFixture(descriptor, mode); } async createAndOpenFile( @@ -150,8 +176,7 @@ export class SsContextImpl implements SsContext { content: string, viewColumn?: vscode.ViewColumn, ): Promise { - const uri = await createAndOpenFile(descriptor, content, viewColumn, this.tmpFileUris); - return uri; + return createAndOpenFile(descriptor, content, viewColumn); } async settle(ms?: number): Promise { @@ -201,10 +226,6 @@ export class SsContextImpl implements SsContext { await waitForExtensionActive(extensionId, this.suiteLog, timeoutMs); } - trackFileUri(uri: vscode.Uri): void { - this.tmpFileUris.push(uri); - } - async clearDummyAi(): Promise { await vscode.commands.executeCommand('dummyAi.clearAll'); } diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts index a0250098..669aa470 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts @@ -564,7 +564,6 @@ standardSuite('Context Menus — Editor Content', (ss) => { const ANCHOR_END = 'ANCHOR_END'; const destUri = ss.createWorkspaceFile('rvec-014-dest', `${ANCHOR_START}\n${ANCHOR_END}\n`); const sourceUri = ss.createWorkspaceFile('rvec-014-source', `${ANCHOR_START}\n`); - ss.trackFileUri(sourceUri); const destBasename = path.basename(destUri.fsPath); const destDoc = await vscode.workspace.openTextDocument(destUri); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts index 5a3222c9..9c49a5db 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; @@ -9,7 +8,6 @@ import { extractQuickPickItemsLogged, findTestItemsByPrefix, getLogCapture, - getWorkspaceRoot, openAndDismiss, standardSuite, waitForHumanVerdict, @@ -80,12 +78,7 @@ standardSuite('Editor Binding Validation', (ss) => { }); test('editor-binding-validation-004: binary .png file is excluded from R-D destination picker', async () => { - // Minimal PNG magic bytes — enough for VSCode to detect as binary - const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const pngPath = path.join(getWorkspaceRoot(), `__rl-test-ebv-004-${Date.now()}.png`); - fs.writeFileSync(pngPath, PNG_MAGIC); - const pngUri = vscode.Uri.file(pngPath); - ss.trackFileUri(pngUri); + const pngUri = ss.createPngFixture('ebv-004', 'magic-only'); const txtUri = ss.createWorkspaceFile('ebv-004-txt', 'control file\n'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts index 5c727c98..58e43445 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; @@ -10,11 +9,11 @@ import { CMD_OPEN_STATUS_BAR_MENU, } from '../../constants/commandIds'; import { + createFileAt, extractQuickPickItemsLogged, findTestItemsByPrefix, getLogCapture, getQuickPickLines, - getWorkspaceRoot, openAndDismiss, parseQuickPickItemsFromLogLine, standardSuite, @@ -123,21 +122,8 @@ standardSuite('File Picker', (ss) => { }); test('file-picker-003: same base name shows path disambiguation', async () => { - const wsRoot = getWorkspaceRoot(); - const subDirA = path.join(wsRoot, 'src', 'dirA'); - const subDirB = path.join(wsRoot, 'src', 'dirB'); - fs.mkdirSync(subDirA, { recursive: true }); - fs.mkdirSync(subDirB, { recursive: true }); - - const fileA = path.join(subDirA, '__rl-test-fp-003-shared.ts'); - const fileB = path.join(subDirB, '__rl-test-fp-003-shared.ts'); - fs.writeFileSync(fileA, 'file A\n', 'utf8'); - fs.writeFileSync(fileB, 'file B\n', 'utf8'); - const uriA = vscode.Uri.file(fileA); - const uriB = vscode.Uri.file(fileB); - - ss.trackFileUri(uriA); - ss.trackFileUri(uriB); + const uriA = createFileAt('src/dirA/__rl-test-fp-003-shared.ts', 'file A\n'); + const uriB = createFileAt('src/dirB/__rl-test-fp-003-shared.ts', 'file B\n'); await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriA), { viewColumn: vscode.ViewColumn.One, @@ -441,22 +427,8 @@ standardSuite('File Picker', (ss) => { }); test('[assisted] file-picker-010: secondary picker shows path disambiguation for same-name files', async () => { - const wsRoot = getWorkspaceRoot(); - const subDirA = path.join(wsRoot, 'src', 'fp010A'); - const subDirB = path.join(wsRoot, 'src', 'fp010B'); - fs.mkdirSync(subDirA, { recursive: true }); - fs.mkdirSync(subDirB, { recursive: true }); - - const sharedName = '__rl-test-fp-010-shared.ts'; - const fileA = path.join(subDirA, sharedName); - const fileB = path.join(subDirB, sharedName); - fs.writeFileSync(fileA, 'file A\n', 'utf8'); - fs.writeFileSync(fileB, 'file B\n', 'utf8'); - const uriA = vscode.Uri.file(fileA); - const uriB = vscode.Uri.file(fileB); - - ss.trackFileUri(uriA); - ss.trackFileUri(uriB); + const uriA = createFileAt('src/fp010A/__rl-test-fp-010-shared.ts', 'file A\n'); + const uriB = createFileAt('src/fp010B/__rl-test-fp-010-shared.ts', 'file B\n'); await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriA), { viewColumn: vscode.ViewColumn.One, @@ -508,26 +480,11 @@ standardSuite('File Picker', (ss) => { }); test('file-picker-011: three files with same name show deeper disambiguation paths', async () => { - const wsRoot = getWorkspaceRoot(); - const dirA = path.join(wsRoot, 'src', 'a', 'nested'); - const dirB = path.join(wsRoot, 'src', 'b', 'nested'); - const dirC = path.join(wsRoot, 'src', 'c'); - fs.mkdirSync(dirA, { recursive: true }); - fs.mkdirSync(dirB, { recursive: true }); - fs.mkdirSync(dirC, { recursive: true }); - - const sharedName = '__rl-test-fp-011-shared.ts'; - const files = [ - path.join(dirA, sharedName), - path.join(dirB, sharedName), - path.join(dirC, sharedName), + const uris = [ + createFileAt('src/a/nested/__rl-test-fp-011-shared.ts', 'content\n'), + createFileAt('src/b/nested/__rl-test-fp-011-shared.ts', 'content\n'), + createFileAt('src/c/__rl-test-fp-011-shared.ts', 'content\n'), ]; - const uris = files.map((f) => { - fs.writeFileSync(f, 'content\n', 'utf8'); - const uri = vscode.Uri.file(f); - ss.trackFileUri(uri); - return uri; - }); await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uris[0]), { viewColumn: vscode.ViewColumn.One, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts index 7906bae9..523af98f 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts @@ -15,7 +15,6 @@ import { assertFilePathLogged, assertTerminalBufferContains, assertTerminalBufferEquals, - createFileAt, extractQuickPickItemsLogged, getLogCapture, openAndDismiss, @@ -106,8 +105,7 @@ standardSuite('Send File Path', (ss) => { 'rangelink.isBound': true, }); - const fileUri = createFileAt('__rl-test-my folder.ts', 'content\n'); - ss.trackFileUri(fileUri); + const fileUri = ss.createTrackedFile('__rl-test-my folder.ts', 'content\n'); await openEditor(fileUri); await ss.settle(); @@ -154,8 +152,7 @@ standardSuite('Send File Path', (ss) => { 'rangelink.isBound': true, }); - const fileUri = createFileAt('__rl-test-utils (v2).ts', 'content\n'); - ss.trackFileUri(fileUri); + const fileUri = ss.createTrackedFile('__rl-test-utils (v2).ts', 'content\n'); await openEditor(fileUri); await ss.settle(); @@ -200,8 +197,7 @@ standardSuite('Send File Path', (ss) => { `✓ RangeLink: File path sent to Text Editor ("${destBasename}")`, ]); ss.expectContextKeys({ 'rangelink.isBound': true }); - const sourceUri = createFileAt('__rl-test-source with spaces.ts', 'content\n'); - ss.trackFileUri(sourceUri); + const sourceUri = ss.createTrackedFile('__rl-test-source with spaces.ts', 'content\n'); const destEditor = await openEditor(destUri, vscode.ViewColumn.Two); destEditor.selection = new vscode.Selection( @@ -268,8 +264,7 @@ standardSuite('Send File Path', (ss) => { 'rangelink.isBound': true, }); - const fileUri = createFileAt('__rl-test-clipboard check.ts', 'content\n'); - ss.trackFileUri(fileUri); + const fileUri = ss.createTrackedFile('__rl-test-clipboard check.ts', 'content\n'); await openEditor(fileUri); await ss.settle(); @@ -620,7 +615,6 @@ standardSuite('Send File Path', (ss) => { const destUri = ss.createWorkspaceFile('sfp-015-dest', `${ANCHOR_START}\n${ANCHOR_END}\n`); const destBasename = path.basename(destUri.fsPath); const sourceUri = ss.createWorkspaceFile('sfp-015-source', 'source content\n'); - ss.trackFileUri(sourceUri); const destEditor = await openEditor(destUri, vscode.ViewColumn.Two); destEditor.selection = new vscode.Selection( @@ -691,4 +685,72 @@ standardSuite('Send File Path', (ss) => { '✓ Self-paste with no selection: path inserted at cursor, normal status bar feedback (no clipboard assertion)', ); }); + + test('send-file-path-017: R-F sends workspace-relative path when active tab is an image preview', async () => { + const capturing = await ss.createAndBindCapturingTerminal('sfp-test'); + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("sfp-test")', + '✓ RangeLink: File path sent to Terminal ("sfp-test")', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const pngUri = ss.createPngFixture('sfp-017'); + + await vscode.commands.executeCommand('vscode.open', pngUri); + await ss.settle(); + + assert.strictEqual( + vscode.window.activeTextEditor, + undefined, + 'Expected activeTextEditor to be undefined when active tab is an image preview', + ); + + capturing.clearCaptured(); + + await vscode.commands.executeCommand(CMD_PASTE_CURRENT_FILE_PATH_RELATIVE); + await ss.settle(); + + const relativePath = vscode.workspace.asRelativePath(pngUri, false); + assertTerminalBufferEquals(capturing.getCapturedText(), ` ${relativePath} `); + ss.log('✓ R-F sends workspace-relative path for image preview (TabInputCustom)'); + }); + + test('send-file-path-018: R-F sends modified-side path when active tab is a text diff view', async () => { + const capturing = await ss.createAndBindCapturingTerminal('sfp-test'); + ss.expectStatusBarMessages([ + '✓ RangeLink: Bound to Terminal ("sfp-test")', + '✓ RangeLink: File path sent to Terminal ("sfp-test")', + ]); + ss.expectContextKeys({ + 'rangelink.isActiveTerminalBindable': true, + 'rangelink.isActiveTerminalPasteDestination': true, + 'rangelink.isBound': true, + }); + + const leftUri = ss.createTrackedFile('__rl-test-sfp-018-left.txt', 'left\n'); + const rightUri = ss.createTrackedFile('__rl-test-sfp-018-right.txt', 'right\n'); + + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, 'sfp-018 Diff'); + await ss.settle(); + + capturing.clearCaptured(); + + await vscode.commands.executeCommand(CMD_PASTE_CURRENT_FILE_PATH_RELATIVE); + await ss.settle(); + + const rightRelativePath = vscode.workspace.asRelativePath(rightUri, false); + const leftRelativePath = vscode.workspace.asRelativePath(leftUri, false); + assertTerminalBufferEquals(capturing.getCapturedText(), ` ${rightRelativePath} `); + + // T009 — prove we picked the modified (right) side, not the original (left). + assert.ok( + !capturing.getCapturedText().includes(leftRelativePath), + `Terminal must not receive the left-side path "${leftRelativePath}" — diff resolution must pick the modified side`, + ); + ss.log('✓ R-F sends modified-side path for text diff (TabInputTextDiff)'); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts index 2e4c5738..b3d1c3ac 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts @@ -63,7 +63,6 @@ standardSuite('Text Editor Destination', (ss) => { const ANCHOR_END = 'ANCHOR_END'; const destUri = ss.createWorkspaceFile('ted-002-dest', `${ANCHOR_START}\n${ANCHOR_END}\n`); const sourceUri = ss.createWorkspaceFile('ted-002-source', 'source content\n'); - ss.trackFileUri(sourceUri); const destBasename = path.basename(destUri.fsPath); const destEditor = await vscode.window.showTextDocument( diff --git a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts index c8e5abb1..42415ef0 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/ide/vscode/VscodeAdapter.test.ts @@ -1,5 +1,6 @@ import type { Logger } from 'barebone-logger'; import { createMockLogger } from 'barebone-logger-testing'; +import type * as vscode from 'vscode'; import { projectTestStatusFields, VscodeAdapter } from '../../../ide/vscode/VscodeAdapter'; import { PathFormat } from '../../../types/PathFormat'; @@ -2715,6 +2716,116 @@ describe('VscodeAdapter', () => { }); }); + describe('getActiveTabUri', () => { + const setActiveTab = (tab: vscode.Tab): void => { + const tabGroup = createMockTabGroup([tab], { activeTab: tab }); + mockVSCode.window.tabGroups = createMockTabGroups({ + all: [tabGroup], + activeTabGroup: tabGroup, + }); + }; + + it('returns the URI when the tab input has a .uri property', () => { + const mockUri = createMockUri('/workspace/file.ts'); + const tab = createMockTab(mockUri); + setActiveTab(tab); + + const result = adapter.getActiveTabUri(); + + expect(result).toBe(mockUri); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'MockTabInputText' }, + 'Resolving active tab URI', + ); + }); + + it('returns .modified when the tab input has no .uri but has .modified', () => { + const modifiedUri = createMockUri('/workspace/right.txt'); + const tab = { + input: { modified: modifiedUri }, + } as unknown as vscode.Tab; + setActiveTab(tab); + + const result = adapter.getActiveTabUri(); + + expect(result).toBe(modifiedUri); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'Object' }, + 'Resolving active tab URI', + ); + }); + + it('returns undefined and logs unsupported when the tab input has no file URI', () => { + const tab = { input: { viewType: 'markdown.preview' } } as unknown as vscode.Tab; + setActiveTab(tab); + + const result = adapter.getActiveTabUri(); + + expect(result).toBeUndefined(); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'unsupported' }, + 'No file URI on active tab input', + ); + }); + + it('returns undefined and logs none when there is no active tab group', () => { + mockVSCode.window.tabGroups = createMockTabGroups({ + all: [], + activeTabGroup: undefined, + }); + + const result = adapter.getActiveTabUri(); + + expect(result).toBeUndefined(); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'none' }, + 'Resolving active tab URI', + ); + }); + + it('returns undefined when the active tab group has no active tab', () => { + const tabGroup = createMockTabGroup([], { activeTab: undefined }); + mockVSCode.window.tabGroups = createMockTabGroups({ + all: [tabGroup], + activeTabGroup: tabGroup, + }); + + const result = adapter.getActiveTabUri(); + + expect(result).toBeUndefined(); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'none' }, + 'Resolving active tab URI', + ); + }); + }); + + describe('getActiveTabViewColumn', () => { + it('returns the active tab group viewColumn', () => { + const tab = createMockTab(createMockUri('/workspace/file.ts')); + const tabGroup = createMockTabGroup([tab], { activeTab: tab, viewColumn: 2 }); + mockVSCode.window.tabGroups = createMockTabGroups({ + all: [tabGroup], + activeTabGroup: tabGroup, + }); + + const result = adapter.getActiveTabViewColumn(); + + expect(result).toBe(2); + }); + + it('returns undefined when there is no active tab group', () => { + mockVSCode.window.tabGroups = createMockTabGroups({ + all: [], + activeTabGroup: undefined, + }); + + const result = adapter.getActiveTabViewColumn(); + + expect(result).toBeUndefined(); + }); + }); + describe('visibleTextEditors', () => { it('should return array of visible editors', () => { const mockEditor1 = createMockEditor({ 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 08c78a9b..f5ffab0d 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/services/FilePathPaster.test.ts @@ -93,7 +93,7 @@ describe('FilePathPaster', () => { describe('pasteCurrentFilePathToDestination', () => { it('shows error when no active editor', async () => { - jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(undefined); + jest.spyOn(mockAdapter, 'getActiveTabUri').mockReturnValue(undefined); await paster.pasteCurrentFilePathToDestination(PathFormat.Absolute); @@ -107,7 +107,7 @@ describe('FilePathPaster', () => { it('delegates to pasteFilePath when active editor exists', async () => { const uri = createMockUri('/workspace/src/file.ts'); - jest.spyOn(mockAdapter, 'getActiveTextEditorUri').mockReturnValue(uri); + jest.spyOn(mockAdapter, 'getActiveTabUri').mockReturnValue(uri); mockSendRouter.resolveDestination.mockResolvedValue(false); await paster.pasteCurrentFilePathToDestination(PathFormat.Absolute); diff --git a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts index 94d005ff..ba2aca35 100644 --- a/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts +++ b/packages/rangelink-vscode-extension/src/ide/vscode/VscodeAdapter.ts @@ -903,6 +903,62 @@ export class VscodeAdapter return this.activeTextEditor?.viewColumn; } + /** + * Get the URI of the currently active tab, when that tab maps back to a file. + * + * Unlike getActiveTextEditorUri(), this works for any tab input kind that + * exposes a file URI — text editors, custom editors (e.g. image previews), + * notebooks, and diff views. For text and notebook diff tabs, the URI of the + * modified (right-hand) side is returned, since that is the file the user is + * editing. Returns undefined for webviews, terminals, and when no tab is + * active. + * + * @returns URI of the active tab's underlying file, or undefined when the + * active tab has no file URI (webview, terminal) or no tab is active + */ + getActiveTabUri(): vscode.Uri | undefined { + const activeTab = this.ideInstance.window.tabGroups.activeTabGroup?.activeTab; + if (!activeTab) { + this.logger.debug( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'none' }, + 'Resolving active tab URI', + ); + return undefined; + } + // Dynamic property check: TabInputText/Custom/Notebook expose `.uri`; + // TabInputTextDiff/NotebookDiff expose `.modified` (no `.uri`). + // Any future VS Code TabInput with a file URI works automatically. + const input = activeTab.input as Record; + const candidate = input.uri ?? input.modified; + if (candidate && typeof candidate === 'object' && 'fsPath' in candidate) { + const inputKind = + (input as { constructor?: { name?: string } }).constructor?.name ?? 'unknown'; + this.logger.debug( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind }, + 'Resolving active tab URI', + ); + return candidate as vscode.Uri; + } + this.logger.debug( + { fn: 'VscodeAdapter.getActiveTabUri', inputKind: 'unsupported' }, + 'No file URI on active tab input', + ); + return undefined; + } + + /** + * Get the view column of the currently active tab group. + * + * Mirrors getActiveEditorViewColumn() but reads from the active tab group + * rather than the active text editor, so it returns a column for non-text + * tabs (image previews, notebooks, diffs) too. + * + * @returns ViewColumn of active tab group or undefined when no group is active + */ + getActiveTabViewColumn(): vscode.ViewColumn | undefined { + return this.ideInstance.window.tabGroups.activeTabGroup?.viewColumn; + } + /** * Get all visible text editors. * diff --git a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts index e298cd4d..f8c367ab 100644 --- a/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts +++ b/packages/rangelink-vscode-extension/src/services/FilePathPaster.ts @@ -65,7 +65,7 @@ export class FilePathPaster { } private async pasteCurrentFilePath(pathFormat: PathFormat): Promise { - const uri = this.ideAdapter.getActiveTextEditorUri(); + const uri = this.ideAdapter.getActiveTabUri(); if (!uri) { this.logger.debug( { fn: 'FilePathPaster.pasteCurrentFilePath', pathFormat }, @@ -133,7 +133,7 @@ export class FilePathPaster { clipboard: destinationFilePath, send: paddedPath, sourceUri: uri, - sourceViewColumn: this.ideAdapter.getActiveEditorViewColumn(), + sourceViewColumn: this.ideAdapter.getActiveTabViewColumn(), }, strategies: { sendFn: (text) => this.destinationManager.sendTextToDestination(text),