Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/server/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -94,6 +95,7 @@ export const SETTINGS_DEFAULTS: Record<string, string> = {
[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]
Expand Down
19 changes: 17 additions & 2 deletions web/src/components/plan/DiffViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,29 @@ describe('DiffViewer', () => {
settingResource.write('true', SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR)
render(<DiffViewer />)
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', () => {
seedConfig({ isWSL: true, wslDistro: 'Ubuntu' })
settingResource.write('true', SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR)
render(<DiffViewer />)
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(<DiffViewer />)
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',
)
})
})
8 changes: 5 additions & 3 deletions web/src/components/plan/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 = (
<>
Expand Down Expand Up @@ -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

Expand All @@ -102,7 +104,7 @@ export function DiffViewer() {
<ScrollArea className="mt-3 max-h-[150px]">
<div className="pr-1">
{diff.files.map((file, i) => (
<DiffRow key={i} file={file} showEditorLink={showEditorLink} workdir={workdir} />
<DiffRow key={i} file={file} showEditorLink={showEditorLink} workdir={workdir} remotePrefix={remotePrefix} />
))}
</div>
</ScrollArea>
Expand Down
5 changes: 4 additions & 1 deletion web/src/components/plan/WorkspaceBranchSection.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -39,7 +42,7 @@ export function WorkspaceBranchSection({
<div className="flex items-center gap-2 text-sm">
{showEditorLink && workdir ? (
<a
href={buildWorkspaceUrl(workdir)}
href={buildWorkspaceUrl(workdir, vscodeRemotePrefix)}
className="flex items-center gap-2 min-w-0 flex-1 no-underline group"
title={t({ en: 'Open workspace in VSCode', fr: 'Ouvrir l’espace de travail dans VSCode' })}
>
Expand Down
40 changes: 40 additions & 0 deletions web/src/components/settings/tabs/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,6 +35,7 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) {

const [retryPatterns, setRetryPatterns] = useState<RetryPatternsValue>({ 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()
Expand Down Expand Up @@ -74,6 +76,10 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) {
}
}, [proxyUrlSetting])

useEffect(() => {
setVscodeRemotePrefix(vscodeRemotePrefixSetting)
}, [vscodeRemotePrefixSetting])

useEffect(() => {
if (defaultAgentSetting !== '') {
setDefaultAgent(defaultAgentSetting)
Expand All @@ -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' })
Expand Down Expand Up @@ -256,6 +267,35 @@ export function AdvancedTab({ onClose }: { onClose: () => void }) {
enabled={localToggles.openInEditor}
onToggle={handleToggleOpenInEditor}
/>
{localToggles.openInEditor && (
<div className="mt-4">
<h3 className="text-sm font-medium text-text-primary mb-1">
{t({ en: 'VSCode SSH Remote Prefix', fr: 'Préfixe VSCode SSH distant' })}
</h3>
<p className="text-sm text-text-muted mb-3">
{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.',
})}
</p>
<div className="flex items-center gap-0.5 px-3 py-2 bg-bg-tertiary border border-border rounded font-mono text-sm focus-within:ring-2 focus-within:ring-accent-primary/50 focus-within:border-accent-primary">
<span className="text-text-secondary shrink-0 select-none">vscode://</span>
<input
type="text"
value={vscodeRemotePrefix}
onChange={(e) => 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"
/>
<span className="text-text-secondary shrink-0 select-none mr-4">/path/to/file</span>
</div>
<p className="text-xs text-text-muted mt-1">
{t({ en: 'Example:', fr: 'Exemple :' })}{' '}
<span className="font-mono">vscode://vscode-remote/ssh-remote+username@192.168.1.100/path/to/file</span>
</p>
</div>
)}
</div>
<hr className="border-border" />
<SettingsToggle
Expand Down
8 changes: 7 additions & 1 deletion web/src/components/shared/ToolCallDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const ToolCallDisplay = memo(function ToolCallDisplay({
const config = statusConfig[status]
const remoteProtocol = detectRemoteExecution(tool, args)
const showEditorLink = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_OPEN_IN_EDITOR).value === 'true'
const vscodeRemotePrefix = useSetting(SETTINGS_KEYS.VSCODE_REMOTE_PREFIX).value
const argsLabel = formatToolArgsWithMetadata(tool, args, metadata)

const editorLine =
Expand Down Expand Up @@ -439,7 +440,12 @@ export const ToolCallDisplay = memo(function ToolCallDisplay({
(tool === 'read_file' || tool === 'write_file' || tool === 'edit_file') &&
String(metadata?.path ?? args.path ?? '') && (
<a
href={buildEditorUrl(String(metadata?.path ?? args.path), editorLine)}
href={buildEditorUrl(
String(metadata?.path ?? args.path),
editorLine,
undefined,
vscodeRemotePrefix,
)}
className="text-accent-primary hover:underline"
>
{t({ en: 'Open in VSCode', fr: 'Ouvrir dans VSCode' })}
Expand Down
92 changes: 79 additions & 13 deletions web/src/lib/editor-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -60,33 +62,83 @@ 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',
)
})
})

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')
})
})

Expand All @@ -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',
)
})
})
Loading