From 90c2260cfbfbbdbb703fd47f6747456ed5622f37 Mon Sep 17 00:00:00 2001 From: David Babel Date: Fri, 4 Sep 2026 01:54:59 +0200 Subject: [PATCH] vscode-remote-prefix --- eslint.config.js | 4 + src/server/db/settings.ts | 2 + web/src/components/plan/DiffViewer.test.tsx | 19 +++- web/src/components/plan/DiffViewer.tsx | 8 +- .../plan/WorkspaceBranchSection.tsx | 5 +- .../components/settings/tabs/AdvancedTab.tsx | 40 ++++++++ web/src/components/shared/ToolCallDisplay.tsx | 8 +- web/src/lib/editor-link.test.ts | 92 ++++++++++++++++--- web/src/lib/editor-link.ts | 33 +++++-- web/src/lib/resources.ts | 1 + 10 files changed, 186 insertions(+), 26 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 0c575d4c..ca12efd5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -84,6 +84,10 @@ export default tseslint.config( 'npm run dev', 'http://localhost:3000', 'http://proxy:8080', + 'vscode://', + '/path/to/file', + 'vscode-remote/ssh-remote+username@192.168.1.100', + 'vscode://vscode-remote/ssh-remote+username@192.168.1.100/path/to/file', 'http://localhost:4000', 'https://github.com/user/repo', 'my-plugin', diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index e36bbbb6..d60ce727 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -48,6 +48,7 @@ export const SETTINGS_KEYS = { PROXY_URL: 'network.proxyUrl', DEFAULT_AGENT: 'agent.defaultAgent', AGENT_MODEL_OVERRIDES: 'agent.modelOverrides', + VSCODE_REMOTE_PREFIX: 'editor.vscodeRemotePrefix', } as const export const SETTINGS_DEFAULTS: Record = { @@ -94,6 +95,7 @@ export const SETTINGS_DEFAULTS: Record = { [SETTINGS_KEYS.CONFIRM_ON_WORKSPACE_ACTIONS]: 'false', [SETTINGS_KEYS.FEATURES_PER_SESSION_MCP]: 'false', [SETTINGS_KEYS.MAINTENANCE_SNAPSHOT_STREAMS_MIGRATED]: 'false', + [SETTINGS_KEYS.VSCODE_REMOTE_PREFIX]: '', } export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS] diff --git a/web/src/components/plan/DiffViewer.test.tsx b/web/src/components/plan/DiffViewer.test.tsx index 8e31252c..d4ba7146 100644 --- a/web/src/components/plan/DiffViewer.test.tsx +++ b/web/src/components/plan/DiffViewer.test.tsx @@ -85,7 +85,7 @@ describe('DiffViewer', () => { settingResource.write('true', SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR) render() const link = screen.getByTitle('Open src/foo.ts in VSCode') - expect(link).toHaveAttribute('href', 'vscode://file//home/user/project/src/foo.ts') + expect(link).toHaveAttribute('href', 'vscode://file//home/user/project/src/foo.ts:1:1?windowId=_blank') }) it('renders WSL links when platform is WSL', () => { @@ -93,6 +93,21 @@ describe('DiffViewer', () => { settingResource.write('true', SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR) render() const link = screen.getByTitle('Open src/foo.ts in VSCode') - expect(link).toHaveAttribute('href', 'vscode://vscode-remote/wsl+Ubuntu/home/user/project/src/foo.ts:1') + expect(link).toHaveAttribute( + 'href', + 'vscode://vscode-remote/wsl+Ubuntu/home/user/project/src/foo.ts:1:1?windowId=_blank', + ) + }) + + it('inserts the remote prefix when the setting is set', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + settingResource.write('true', SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR) + settingResource.write('vscode-remote/ssh-remote+ia@192.168.1.35/', SETTINGS_KEYS.VSCODE_REMOTE_PREFIX) + render() + const link = screen.getByTitle('Open src/foo.ts in VSCode') + expect(link).toHaveAttribute( + 'href', + 'vscode://vscode-remote/ssh-remote+ia@192.168.1.35/home/user/project/src/foo.ts:1:1?windowId=_blank', + ) }) }) diff --git a/web/src/components/plan/DiffViewer.tsx b/web/src/components/plan/DiffViewer.tsx index f7c31cef..e624ff8d 100644 --- a/web/src/components/plan/DiffViewer.tsx +++ b/web/src/components/plan/DiffViewer.tsx @@ -18,9 +18,10 @@ interface DiffRowProps { file: GitDiffFile showEditorLink: boolean workdir: string | undefined + remotePrefix: string } -function DiffRow({ file, showEditorLink, workdir }: DiffRowProps) { +function DiffRow({ file, showEditorLink, workdir, remotePrefix }: DiffRowProps) { const t = useT() const displayPath = truncateMiddle(file.path, 28) @@ -40,7 +41,7 @@ function DiffRow({ file, showEditorLink, workdir }: DiffRowProps) { ? `+${file.additions}, -${file.deletions}` : '' - const href = showEditorLink && workdir ? buildEditorUrl(file.path, undefined, workdir) : undefined + const href = showEditorLink && workdir ? buildEditorUrl(file.path, undefined, workdir, remotePrefix) : undefined const content = ( <> @@ -76,6 +77,7 @@ export function DiffViewer() { const t = useT() const { diff } = useGitStatus() const showEditorLink = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR).value === 'true' + const remotePrefix = useSetting(SETTINGS_KEYS.VSCODE_REMOTE_PREFIX).value const { currentSession: session } = useScopedContext() const workdir = session?.workspace ?? session?.workdir @@ -102,7 +104,7 @@ export function DiffViewer() {
{diff.files.map((file, i) => ( - + ))}
diff --git a/web/src/components/plan/WorkspaceBranchSection.tsx b/web/src/components/plan/WorkspaceBranchSection.tsx index f035eaa0..0ad6a557 100644 --- a/web/src/components/plan/WorkspaceBranchSection.tsx +++ b/web/src/components/plan/WorkspaceBranchSection.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { FolderIcon, BranchIcon } from '../shared/icons' import { useT } from '../../hooks/useT' +import { useSetting } from '../../hooks/useSetting' +import { SETTINGS_KEYS } from '../../lib/resources' import { DiffViewer } from './DiffViewer' import { WorkspaceModal } from './WorkspaceModal' import { BranchModal } from './BranchModal' @@ -28,6 +30,7 @@ export function WorkspaceBranchSection({ onEditBranch, }: WorkspaceBranchSectionProps) { const t = useT() + const vscodeRemotePrefix = useSetting(SETTINGS_KEYS.VSCODE_REMOTE_PREFIX).value const [showWorkspaceModal, setShowWorkspaceModal] = useState(false) const [showBranchModal, setShowBranchModal] = useState(false) @@ -39,7 +42,7 @@ export function WorkspaceBranchSection({
{showEditorLink && workdir ? ( diff --git a/web/src/components/settings/tabs/AdvancedTab.tsx b/web/src/components/settings/tabs/AdvancedTab.tsx index 26a943e0..c8ad034a 100644 --- a/web/src/components/settings/tabs/AdvancedTab.tsx +++ b/web/src/components/settings/tabs/AdvancedTab.tsx @@ -23,6 +23,7 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) { const cacheWarming = useSetting(SETTINGS_KEYS.CACHE_WARMING).value === 'true' const retryPatternsSetting = useSetting(SETTINGS_KEYS.RETRY_PATTERNS).value const proxyUrlSetting = useSetting(SETTINGS_KEYS.PROXY_URL).value + const vscodeRemotePrefixSetting = useSetting(SETTINGS_KEYS.VSCODE_REMOTE_PREFIX).value const defaultAgentSetting = useSetting(SETTINGS_KEYS.DEFAULT_AGENT).value const showChangelogSetting = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_CHANGELOG_ON_UPDATE, 'true').value @@ -34,6 +35,7 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) { const [retryPatterns, setRetryPatterns] = useState({ patterns: [], maxRetriesPerTurn: 10 }) const [proxyUrl, setProxyUrl] = useState('') + const [vscodeRemotePrefix, setVscodeRemotePrefix] = useState('') const [defaultAgent, setDefaultAgent] = useState('') const [defaultAgentLoaded, setDefaultAgentLoaded] = useState(false) const [proxyTestText, proxyTestError, proxyTestSuccess, testProxy] = useTestButton() @@ -74,6 +76,10 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) { } }, [proxyUrlSetting]) + useEffect(() => { + setVscodeRemotePrefix(vscodeRemotePrefixSetting) + }, [vscodeRemotePrefixSetting]) + useEffect(() => { if (defaultAgentSetting !== '') { setDefaultAgent(defaultAgentSetting) @@ -91,6 +97,11 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) { void setSetting(SETTINGS_KEYS.PROXY_URL, value) } + const handleVscodeRemotePrefixChange = (value: string) => { + setVscodeRemotePrefix(value) + void setSetting(SETTINGS_KEYS.VSCODE_REMOTE_PREFIX, value) + } + function handleTestProxy() { testProxy(async () => { const res = await authFetch('/api/proxy/test', { method: 'POST' }) @@ -256,6 +267,35 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) { enabled={localToggles.openInEditor} onToggle={handleToggleOpenInEditor} /> + {localToggles.openInEditor && ( +
+

+ {t({ en: 'VSCode SSH Remote Prefix', fr: 'Préfixe VSCode SSH distant' })} +

+

+ {t({ + en: 'Insert a prefix in every "Open in VSCode" link to open files on a remote host over an SSH tunnel. Requires SSH credentials configured on the local machine running VS Code. Leave empty for local or WSL machines.', + fr: 'Insère un préfixe dans chaque lien « Ouvrir dans VSCode » pour ouvrir des fichiers sur un hôte distant via un tunnel SSH. Nécessite des identifiants SSH configurés sur la machine locale exécutant VS Code. Laissez vide pour une machine locale ou WSL.', + })} +

+
+ vscode:// + handleVscodeRemotePrefixChange(e.target.value)} + placeholder="vscode-remote/ssh-remote+username@192.168.1.100" + spellCheck={false} + className="flex-1 min-w-0 bg-transparent text-text-primary placeholder-text-muted focus:outline-none" + /> + /path/to/file +
+

+ {t({ en: 'Example:', fr: 'Exemple :' })}{' '} + vscode://vscode-remote/ssh-remote+username@192.168.1.100/path/to/file +

+
+ )}

{t({ en: 'Open in VSCode', fr: 'Ouvrir dans VSCode' })} diff --git a/web/src/lib/editor-link.test.ts b/web/src/lib/editor-link.test.ts index a79ae881..a1ef8aa6 100644 --- a/web/src/lib/editor-link.test.ts +++ b/web/src/lib/editor-link.test.ts @@ -26,29 +26,31 @@ beforeEach(() => { }) describe('buildEditorUrl — Linux native (no WSL)', () => { - it('returns vscode://file//path (double slash for backwards compat)', () => { + it('returns vscode://file//path with default line suffix', () => { seedConfig({ isWSL: false, wslDistro: '' }) - expect(buildEditorUrl('/home/user/file.ts')).toBe('vscode://file//home/user/file.ts') + expect(buildEditorUrl('/home/user/file.ts')).toBe('vscode://file//home/user/file.ts:1:1?windowId=_blank') }) - it('appends :line number', () => { + it('appends :line:1 suffix', () => { seedConfig({ isWSL: false, wslDistro: '' }) - expect(buildEditorUrl('/home/user/file.ts', 42)).toBe('vscode://file//home/user/file.ts:42') + expect(buildEditorUrl('/home/user/file.ts', 42)).toBe('vscode://file//home/user/file.ts:42:1?windowId=_blank') }) it('resolves relative path with workdir', () => { seedConfig({ isWSL: false, wslDistro: '' }) - expect(buildEditorUrl('src/foo.ts', undefined, '/home/user/proj')).toBe('vscode://file//home/user/proj/src/foo.ts') + expect(buildEditorUrl('src/foo.ts', undefined, '/home/user/proj')).toBe( + 'vscode://file//home/user/proj/src/foo.ts:1:1?windowId=_blank', + ) }) it('normalizes Windows backslashes', () => { seedConfig({ isWSL: false, wslDistro: '' }) - expect(buildEditorUrl('C:\\Users\\test\\file.ts')).toBe('vscode://file/C:/Users/test/file.ts') + expect(buildEditorUrl('C:\\Users\\test\\file.ts')).toBe('vscode://file/C:/Users/test/file.ts:1:1?windowId=_blank') }) it('encodes spaces', () => { seedConfig({ isWSL: false, wslDistro: '' }) - expect(buildEditorUrl('/home/user/my file.ts')).toBe('vscode://file//home/user/my%20file.ts') + expect(buildEditorUrl('/home/user/my file.ts')).toBe('vscode://file//home/user/my%20file.ts:1:1?windowId=_blank') }) it('encodes # and ? characters', () => { @@ -60,25 +62,75 @@ describe('buildEditorUrl — Linux native (no WSL)', () => { }) describe('buildEditorUrl — WSL', () => { - it('returns vscode://vscode-remote/wsl+Ubuntu/path:1', () => { + it('returns vscode://vscode-remote/wsl+Ubuntu/path:1:1', () => { seedConfig({ isWSL: true, wslDistro: 'Ubuntu' }) - expect(buildEditorUrl('/home/user/file.ts')).toBe('vscode://vscode-remote/wsl+Ubuntu/home/user/file.ts:1') + expect(buildEditorUrl('/home/user/file.ts')).toBe( + 'vscode://vscode-remote/wsl+Ubuntu/home/user/file.ts:1:1?windowId=_blank', + ) }) it('preserves the provided line number', () => { seedConfig({ isWSL: true, wslDistro: 'Ubuntu' }) - expect(buildEditorUrl('/home/user/file.ts', 10)).toBe('vscode://vscode-remote/wsl+Ubuntu/home/user/file.ts:10') + expect(buildEditorUrl('/home/user/file.ts', 10)).toBe( + 'vscode://vscode-remote/wsl+Ubuntu/home/user/file.ts:10:1?windowId=_blank', + ) }) it('handles custom distro names', () => { seedConfig({ isWSL: true, wslDistro: 'Debian' }) - expect(buildEditorUrl('/opt/project/main.go')).toBe('vscode://vscode-remote/wsl+Debian/opt/project/main.go:1') + expect(buildEditorUrl('/opt/project/main.go')).toBe( + 'vscode://vscode-remote/wsl+Debian/opt/project/main.go:1:1?windowId=_blank', + ) }) it('resolves relative path with workdir', () => { seedConfig({ isWSL: true, wslDistro: 'Ubuntu' }) expect(buildEditorUrl('src/foo.ts', undefined, '/home/user/proj')).toBe( - 'vscode://vscode-remote/wsl+Ubuntu/home/user/proj/src/foo.ts:1', + 'vscode://vscode-remote/wsl+Ubuntu/home/user/proj/src/foo.ts:1:1?windowId=_blank', + ) + }) +}) + +describe('buildEditorUrl — remote prefix', () => { + it('replaces vscode://file with the remote prefix', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + expect(buildEditorUrl('/home/user/file.ts', undefined, undefined, 'vscode-remote/ssh-remote+ia@192.168.1.35')).toBe( + 'vscode://vscode-remote/ssh-remote+ia@192.168.1.35/home/user/file.ts:1:1?windowId=_blank', + ) + }) + + it('normalizes trailing slashes on the prefix', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + expect( + buildEditorUrl('/home/user/file.ts', undefined, undefined, 'vscode-remote/ssh-remote+ia@192.168.1.35/'), + ).toBe('vscode://vscode-remote/ssh-remote+ia@192.168.1.35/home/user/file.ts:1:1?windowId=_blank') + }) + + it('keeps the provided line number', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + expect(buildEditorUrl('/home/user/file.ts', 7, undefined, 'vscode-remote/ssh-remote+user@host')).toBe( + 'vscode://vscode-remote/ssh-remote+user@host/home/user/file.ts:7:1?windowId=_blank', + ) + }) + + it('is ignored on WSL where detection takes priority', () => { + seedConfig({ isWSL: true, wslDistro: 'Ubuntu' }) + expect(buildEditorUrl('/home/user/file.ts', undefined, undefined, 'vscode-remote/ssh-remote+user@host')).toBe( + 'vscode://vscode-remote/wsl+Ubuntu/home/user/file.ts:1:1?windowId=_blank', + ) + }) + + it('treats an empty prefix as unset', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + expect(buildEditorUrl('/home/user/file.ts', undefined, undefined, '')).toBe( + 'vscode://file//home/user/file.ts:1:1?windowId=_blank', + ) + }) + + it('applies on unknown platform', () => { + seedConfig(null) + expect(buildEditorUrl('/path/file.ts', undefined, undefined, 'vscode-remote/ssh-remote+user@host')).toBe( + 'vscode://vscode-remote/ssh-remote+user@host/path/file.ts:1:1?windowId=_blank', ) }) }) @@ -86,7 +138,7 @@ describe('buildEditorUrl — WSL', () => { describe('buildEditorUrl — unknown platform', () => { it('defaults to vscode://file with double slash', () => { seedConfig(null) - expect(buildEditorUrl('/path/file.ts')).toBe('vscode://file//path/file.ts') + expect(buildEditorUrl('/path/file.ts')).toBe('vscode://file//path/file.ts:1:1?windowId=_blank') }) }) @@ -105,4 +157,18 @@ describe('buildWorkspaceUrl', () => { seedConfig({ isWSL: false, wslDistro: '' }) expect(buildWorkspaceUrl('/home/user/my project')).toBe('vscode://file//home/user/my%20project') }) + + it('applies the remote prefix instead of vscode://file', () => { + seedConfig({ isWSL: false, wslDistro: '' }) + expect(buildWorkspaceUrl('/home/user/project', 'vscode-remote/ssh-remote+ia@192.168.1.35/')).toBe( + 'vscode://vscode-remote/ssh-remote+ia@192.168.1.35/home/user/project', + ) + }) + + it('is ignored on WSL where detection takes priority', () => { + seedConfig({ isWSL: true, wslDistro: 'Ubuntu' }) + expect(buildWorkspaceUrl('/home/user/project', 'vscode-remote/ssh-remote+user@host')).toBe( + 'vscode://vscode-remote/wsl+Ubuntu/home/user/project', + ) + }) }) diff --git a/web/src/lib/editor-link.ts b/web/src/lib/editor-link.ts index d2edd8e3..b703565a 100644 --- a/web/src/lib/editor-link.ts +++ b/web/src/lib/editor-link.ts @@ -10,7 +10,19 @@ function encodePath(path: string): string { return encodeURI(normalized).replace(/#/g, '%23').replace(/\?/g, '%3F') } -export function buildEditorUrl(filePath: string, line?: number, workdir?: string): string { +/** User-configured remote prefix (e.g. "vscode-remote/ssh-remote+user@host"). + * A trailing slash is optional in the setting, so strip it here. */ +function normalizeRemotePrefix(prefix?: string): string { + return (prefix ?? '').trim().replace(/\/+$/, '') +} + +/** VS Code requires :line:column on file URLs and allows opening wsl / ssh + * folders when the target is addressed through a vscode-remote authority. */ +function editorLineSuffix(line?: number): string { + return `:${line ?? 1}:1?windowId=_blank` +} + +export function buildEditorUrl(filePath: string, line?: number, workdir?: string, remotePrefix?: string): string { const platform = getPlatform() const absolutePath = filePath.startsWith('/') || filePath.match(/^[a-zA-Z]:[/\\]/) @@ -20,23 +32,32 @@ export function buildEditorUrl(filePath: string, line?: number, workdir?: string : filePath const encoded = encodePath(absolutePath) + const remote = normalizeRemotePrefix(remotePrefix) + let url: string if (platform?.isWSL && platform.wslDistro) { - const url = `vscode://vscode-remote/wsl+${platform.wslDistro}${encoded}` - return `${url}:${line ?? 1}` + url = `vscode://vscode-remote/wsl+${platform.wslDistro}${encoded}` + } else if (remote !== '') { + url = `vscode://${remote}${encoded}` + } else { + url = `vscode://file/${encoded}` } - const url = `vscode://file/${encoded}` - return line ? `${url}:${line}` : url + return `${url}${editorLineSuffix(line)}` } -export function buildWorkspaceUrl(workdir: string): string { +export function buildWorkspaceUrl(workdir: string, remotePrefix?: string): string { const platform = getPlatform() const encoded = encodePath(workdir.replace(/\\/g, '/')) + const remote = normalizeRemotePrefix(remotePrefix) if (platform?.isWSL && platform.wslDistro) { return `vscode://vscode-remote/wsl+${platform.wslDistro}${encoded}` } + if (remote !== '') { + return `vscode://${remote}${encoded}` + } + return `vscode://file/${encoded}` } diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts index c8a62f59..fd2bbfa1 100644 --- a/web/src/lib/resources.ts +++ b/web/src/lib/resources.ts @@ -612,6 +612,7 @@ export const SETTINGS_KEYS = { FEATURES_PER_SESSION_MCP: 'features.perSessionMcp', PROXY_URL: 'network.proxyUrl', DEFAULT_AGENT: 'agent.defaultAgent', + VSCODE_REMOTE_PREFIX: 'editor.vscodeRemotePrefix', } as const export const DISPLAY_SETTINGS_KEYS = [