Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open remote files locally in VSCode #6475

Merged
merged 5 commits into from
Jan 15, 2025
Merged
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
34 changes: 34 additions & 0 deletions vscode/src/chat/chat-view/ChatController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,9 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
viewColumn: vscode.ViewColumn.Beside,
})
break
case 'openRemoteFile':
this.openRemoteFile(message.uri)
break
case 'newFile':
await handleCodeFromSaveToNewFile(message.text, this.editor)
break
Expand Down Expand Up @@ -928,6 +931,37 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
return
}

private openRemoteFile(uri: vscode.Uri) {
const json = uri.toJSON()
const searchParams = (json.query || '').split('&')

const sourcegraphSchemaURI = vscode.Uri.from({
...json,
query: '',
scheme: 'codysourcegraph',
})

// Supported line params examples: L42 (single line) or L42-45 (line range)
const lineParam = searchParams.find((value: string) => value.match(/^L\d+(?:-\d+)?$/)?.length)
const range = this.lineParamToRange(lineParam)

vscode.workspace.openTextDocument(sourcegraphSchemaURI).then(async doc => {
const textEditor = await vscode.window.showTextDocument(doc)

textEditor.revealRange(range)
})
}

private lineParamToRange(lineParam?: string | null): vscode.Range {
const lines = (lineParam ?? '0')
.replace('L', '')
.split('-')
.map(num => Number.parseInt(num))

// adding 20 lines to the end of the range to allow the start line to be visible in a more center position on the screen.
return new vscode.Range(lines.at(0) || 0, 0, lines.at(1) || (lines.at(0) || 0) + 20, 0)
}

private submitOrEditOperation: AbortController | undefined
public startNewSubmitOrEditOperation(): AbortSignal {
this.submitOrEditOperation?.abort()
Expand Down
102 changes: 102 additions & 0 deletions vscode/src/chat/chat-view/sourcegraphRemoteFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { graphqlClient, isError } from '@sourcegraph/cody-shared'
import { LRUCache } from 'lru-cache'
import * as vscode from 'vscode'

export class SourcegraphRemoteFileProvider implements vscode.FileSystemProvider, vscode.Disposable {
private cache = new LRUCache<string, Promise<Uint8Array>>({ max: 128 })
private disposables: vscode.Disposable[] = []

constructor() {
this.disposables.push(
vscode.workspace.registerFileSystemProvider('codysourcegraph', this, { isReadonly: true })
)
}

async readFile(uri: vscode.Uri): Promise<Uint8Array> {
const cachedResult = this.cache.get(uri.toString())

if (cachedResult) {
return cachedResult
}

const contentPromise = getFileContentsFromURL(uri).then(content =>
new TextEncoder().encode(content)
)

this.cache.set(uri.toString(), contentPromise)

return contentPromise
}

public dispose(): void {
this.cache.clear()
for (const disposable of this.disposables) {
disposable.dispose()
}
this.disposables = []
}

// Below methods are unused

onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> = new vscode.EventEmitter<
vscode.FileChangeEvent[]
>().event

watch(): vscode.Disposable {
return new vscode.Disposable(() => {})
}

stat(uri: vscode.Uri): vscode.FileStat {
return {
type: vscode.FileType.File,
ctime: 0,
mtime: 0,
size: 0,
}
}
Comment on lines +49 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reporting the file size correctly might be interesting, but maybe not possible.


readDirectory() {
return []
}

createDirectory() {
throw new Error('Method not implemented.')
}

writeFile() {
throw new Error('Method not implemented.')
}

rename() {
throw new Error('Method not implemented.')
}

delete() {
throw new Error('Method not implemented.')
}
}

async function getFileContentsFromURL(URL: vscode.Uri): Promise<string> {
const path = URL.path
const [repoRev = '', filePath] = path.split('/-/blob/')
let [repoName, rev = 'HEAD'] = repoRev.split('@')
repoName = repoName.replace(/^\/+/, '')

if (!repoName || !filePath) {
throw new Error('Invalid URI')
}

const dataOrError = await graphqlClient.getFileContents(repoName, filePath, rev)

if (isError(dataOrError)) {
throw new Error(dataOrError.message)
}

const content = dataOrError.repository?.commit?.file?.content

if (!content) {
throw new Error('File not found')
}

return content
}
1 change: 1 addition & 0 deletions vscode/src/chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type WebviewMessage =
| { command: 'restoreHistory'; chatID: string }
| { command: 'links'; value: string }
| { command: 'openURI'; uri: Uri }
| { command: 'openRemoteFile'; uri: Uri }
| {
command: 'openFileLink'
uri: Uri
Expand Down
5 changes: 4 additions & 1 deletion vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type { MessageProviderOptions } from './chat/MessageProvider'
import { CodyToolProvider } from './chat/agentic/CodyToolProvider'
import { ChatsController, CodyChatEditorViewType } from './chat/chat-view/ChatsController'
import { ContextRetriever } from './chat/chat-view/ContextRetriever'
import { SourcegraphRemoteFileProvider } from './chat/chat-view/sourcegraphRemoteFile'
import type { ChatIntentAPIClient } from './chat/context/chatIntentAPIClient'
import {
ACCOUNT_LIMITS_INFO_URL,
Expand Down Expand Up @@ -859,7 +860,9 @@ function registerChat(
)
chatsController.registerViewsAndCommands()
const promptsManager = new PromptsManager({ chatsController })
disposables.push(new CodeActionProvider(), promptsManager)
const sourcegraphRemoteFileProvider = new SourcegraphRemoteFileProvider()

disposables.push(new CodeActionProvider(), promptsManager, sourcegraphRemoteFileProvider)

// Register a serializer for reviving the chat panel on reload
if (vscode.window.registerWebviewPanelSerializer) {
Expand Down
29 changes: 29 additions & 0 deletions vscode/webviews/components/codeSnippet/CodeSnippet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ import {
pluralize,
} from './utils'

import { CodyIDE } from '@sourcegraph/cody-shared'
import type {
NLSSearchFileMatch,
NLSSearchResult,
} from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql/client'
import type { Observable } from 'observable-fns'
import { useInView } from 'react-intersection-observer'
import { URI } from 'vscode-uri'
import { getVSCodeAPI } from '../../utils/VSCodeApi'
import { useConfig } from '../../utils/useConfig'
import styles from './CodeSnippet.module.css'

const DEFAULT_VISIBILITY_OFFSET = '500px'
Expand Down Expand Up @@ -123,6 +127,29 @@ export const FileMatchSearchResult: FC<PropsWithChildren<FileMatchSearchResultPr
const expandable = !showAllMatches && expandedHighlightCount > collapsedHighlightCount

useEffect(() => setExpanded(allExpanded || defaultExpanded), [allExpanded, defaultExpanded])
const {
clientCapabilities: { agentIDE },
} = useConfig()
const openRemoteFile = useCallback(
(line?: number) => {
const urlWithLineNumber = line ? `${fileURL}?L${line}` : fileURL
if (agentIDE !== CodyIDE.VSCode) {
getVSCodeAPI().postMessage({
command: 'links',
value: urlWithLineNumber,
})

return
}

const uri = URI.parse(urlWithLineNumber)
getVSCodeAPI().postMessage({
command: 'openRemoteFile',
uri,
})
},
[fileURL, agentIDE]
)

const handleVisibility = useCallback(
(inView: boolean, entry: IntersectionObserverEntry) => {
Expand Down Expand Up @@ -183,6 +210,7 @@ export const FileMatchSearchResult: FC<PropsWithChildren<FileMatchSearchResultPr
repoName={result.repository.name}
repoURL={repoAtRevisionURL}
filePath={result.file.path}
onFilePathClick={openRemoteFile}
pathMatchRanges={result.pathMatches ?? []}
fileURL={fileURL}
repoDisplayName={
Expand Down Expand Up @@ -231,6 +259,7 @@ export const FileMatchSearchResult: FC<PropsWithChildren<FileMatchSearchResultPr
serverEndpoint={serverEndpoint}
result={result}
grouped={expanded ? expandedGroups : collapsedGroups}
onLineClick={openRemoteFile}
/>
{expandable && (
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, useCallback } from 'react'
import type { FC, PropsWithChildren } from 'react'

import { clsx } from 'clsx'

Expand All @@ -9,18 +9,18 @@ import { getFileMatchUrl } from '../utils'
import { CodeExcerpt } from './CodeExcerpt'

import type { NLSSearchFileMatch } from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql/client'
import { getVSCodeAPI } from '../../../utils/VSCodeApi'
import resultStyles from '../CodeSnippet.module.css'
import styles from './FileMatchChildren.module.css'

interface FileMatchProps {
result: NLSSearchFileMatch
grouped: MatchGroup[]
serverEndpoint: string
onLineClick?: (line: number) => void
}

export const FileMatchChildren: FC<PropsWithChildren<FileMatchProps>> = props => {
const { result, grouped, serverEndpoint } = props
const { result, grouped, serverEndpoint, onLineClick } = props

const createCodeExcerptLink = (group: MatchGroup): string => {
const urlBuilder = SourcegraphURL.from(getFileMatchUrl(serverEndpoint, result))
Expand All @@ -39,17 +39,6 @@ export const FileMatchChildren: FC<PropsWithChildren<FileMatchProps>> = props =>
return urlBuilder.toString()
}

const navigateToFile = useCallback(
(line: number) => {
// TODO: this does not work on web as opening links from within a web worker does not work.
getVSCodeAPI().postMessage({
command: 'links',
value: `${getFileMatchUrl(serverEndpoint, result)}?L${line}`,
})
},
[serverEndpoint, result]
)

return (
<div data-testid="file-match-children">
{grouped.length > 0 &&
Expand All @@ -75,7 +64,7 @@ export const FileMatchChildren: FC<PropsWithChildren<FileMatchProps>> = props =>
highlightRanges={group.matches}
plaintextLines={group.plaintextLines}
highlightedLines={group.highlightedHTMLRows}
onLineClick={navigateToFile}
onLineClick={onLineClick}
/>
</div>
))}
Expand Down
10 changes: 9 additions & 1 deletion vscode/webviews/components/codeSnippet/components/RepoLink.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type * as React from 'react'
import { useEffect, useRef } from 'react'

import { CodyIDE } from '@sourcegraph/cody-shared'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useConfig } from '../../../utils/useConfig'
import { cn } from '../../shadcn/utils'
import { highlightNode } from '../highlights'
import type { Range } from '../types'
Expand All @@ -18,6 +20,7 @@ interface Props {
collapsed: boolean
onToggleCollapse: () => void
collapsible: boolean
onFilePathClick?: () => void
}

/**
Expand All @@ -37,6 +40,7 @@ export const RepoFileLink: React.FunctionComponent<React.PropsWithChildren<Props
collapsed,
onToggleCollapse,
collapsible,
onFilePathClick,
} = props

const [fileBase, fileName] = splitPath(filePath)
Expand All @@ -55,6 +59,9 @@ export const RepoFileLink: React.FunctionComponent<React.PropsWithChildren<Props
}, [pathMatchRanges, fileName])

const Chevron = collapsed ? ChevronDown : ChevronUp
const {
clientCapabilities: { agentIDE },
} = useConfig()

return (
<span className={cn(className, 'tw-flex tw-items-center tw-w-full')}>
Expand All @@ -71,11 +78,12 @@ export const RepoFileLink: React.FunctionComponent<React.PropsWithChildren<Props
</a>
<span aria-hidden={true}> ›</span>{' '}
<a
href={fileURL}
href={agentIDE === CodyIDE.VSCode ? '' : fileURL}
thenamankumar marked this conversation as resolved.
Show resolved Hide resolved
ref={containerElement}
target="_blank"
rel="noreferrer"
data-selectable-search-result={isKeyboardSelectable}
onClick={() => onFilePathClick?.()}
>
{fileBase ? `${fileBase}/` : null}
<strong>{fileName}</strong>
Expand Down
Loading