From e9b03954cd2f0757b2e29b4697a1515cb82a2fa1 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Mon, 9 Dec 2024 14:43:16 +0800 Subject: [PATCH 1/2] fix(vscode): fix path resolving in lookup symbols. (#3534) --- clients/vscode/src/chat/WebviewHelper.ts | 27 ++---- clients/vscode/src/chat/fileContext.ts | 102 ++++++++++++++++------- 2 files changed, 79 insertions(+), 50 deletions(-) diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index 44ace11c6b3a..2e6641616285 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -11,7 +11,6 @@ import { ProgressLocation, commands, LocationLink, - workspace, } from "vscode"; import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams, SymbolInfo } from "tabby-chat-panel"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; @@ -24,8 +23,7 @@ import { GitProvider } from "../git/GitProvider"; import { createClient } from "./chatPanel"; import { ChatFeature } from "../lsp/ChatFeature"; import { isBrowser } from "../env"; -import { getFileContextFromSelection, showFileContext } from "./fileContext"; -import path from "path"; +import { getFileContextFromSelection, showFileContext, openTextDocument } from "./fileContext"; export class WebviewHelper { webview?: Webview; @@ -571,38 +569,29 @@ export class WebviewHelper { return undefined; } try { - const workspaceRoot = workspace.workspaceFolders?.[0]; - if (!workspaceRoot) { - this.logger.error("No workspace folder found"); - return undefined; - } - const rootPath = workspaceRoot.uri; for (const filepath of filepaths) { - const normalizedPath = filepath.startsWith("/") ? filepath.slice(1) : filepath; - const fullPath = path.join(rootPath.path, normalizedPath); - const fileUri = Uri.file(fullPath); - const document = await workspace.openTextDocument(fileUri); + const document = await openTextDocument({ filePath: filepath }, this.gitProvider); + if (!document) { + this.logger.info(`File not found: ${filepath}`); + continue; + } const content = document.getText(); let pos = 0; while ((pos = content.indexOf(keyword, pos)) !== -1) { const position = document.positionAt(pos); const locations = await commands.executeCommand( "vscode.executeDefinitionProvider", - fileUri, + document.uri, position, ); if (locations && locations.length > 0) { const location = locations[0]; if (location) { - const targetPath = location.targetUri.fsPath; - const relativePath = path.relative(rootPath.path, targetPath); - const normalizedTargetPath = relativePath.startsWith("/") ? relativePath.slice(1) : relativePath; - return { sourceFile: filepath, sourceLine: position.line + 1, sourceCol: position.character, - targetFile: normalizedTargetPath, + targetFile: location.targetUri.toString(true), targetLine: location.targetRange.start.line + 1, targetCol: location.targetRange.start.character, }; diff --git a/clients/vscode/src/chat/fileContext.ts b/clients/vscode/src/chat/fileContext.ts index 6f193dad79a3..88e095327003 100644 --- a/clients/vscode/src/chat/fileContext.ts +++ b/clients/vscode/src/chat/fileContext.ts @@ -7,6 +7,11 @@ import { getLogger } from "../logger"; const logger = getLogger("FileContext"); +export interface FilePathParams { + filePath: string; + gitRemoteUrl?: string; +} + export async function getFileContextFromSelection( editor: TextEditor, gitProvider: GitProvider, @@ -19,7 +24,6 @@ export async function getFileContext( gitProvider: GitProvider, useSelection = false, ): Promise { - const uri = editor.document.uri; const text = editor.document.getText(useSelection ? editor.selection : undefined); if (!text || text.trim().length < 1) { return null; @@ -35,35 +39,25 @@ export async function getFileContext( end: editor.document.lineCount, }; - const workspaceFolder = - workspace.getWorkspaceFolder(uri) ?? (editor.document.isUntitled ? workspace.workspaceFolders?.[0] : undefined); - const repo = - gitProvider.getRepository(uri) ?? (workspaceFolder ? gitProvider.getRepository(workspaceFolder.uri) : undefined); - const gitRemoteUrl = repo ? gitProvider.getDefaultRemoteUrl(repo) : undefined; - let filePath = uri.toString(true); - if (repo && gitRemoteUrl) { - const relativeFilePath = path.relative(repo.rootUri.toString(true), filePath); - if (!relativeFilePath.startsWith("..")) { - filePath = relativeFilePath; - } - } else if (workspaceFolder) { - const relativeFilePath = path.relative(workspaceFolder.uri.toString(true), filePath); - if (!relativeFilePath.startsWith("..")) { - filePath = relativeFilePath; - } - } + const filePathParams = await buildFilePathParams(editor.document.uri, gitProvider); return { kind: "file", content, range, - filepath: filePath, - git_url: gitRemoteUrl ?? "", + filepath: filePathParams.filePath, + git_url: filePathParams.gitRemoteUrl ?? "", }; } export async function showFileContext(fileContext: FileContext, gitProvider: GitProvider): Promise { - const document = await openTextDocument(fileContext, gitProvider); + const document = await openTextDocument( + { + filePath: fileContext.filepath, + gitRemoteUrl: fileContext.git_url, + }, + gitProvider, + ); if (!document) { throw new Error(`File not found: ${fileContext.filepath}`); } @@ -81,43 +75,89 @@ export async function showFileContext(fileContext: FileContext, gitProvider: Git editor.revealRange(new Range(start, end), TextEditorRevealType.InCenter); } -async function openTextDocument(fileContext: FileContext, gitProvider: GitProvider): Promise { - const { filepath: filePath, git_url: gitUrl } = fileContext; +export async function buildFilePathParams(uri: Uri, gitProvider: GitProvider): Promise { + const workspaceFolder = + workspace.getWorkspaceFolder(uri) ?? (uri.scheme === "untitled" ? workspace.workspaceFolders?.[0] : undefined); + const repo = + gitProvider.getRepository(uri) ?? (workspaceFolder ? gitProvider.getRepository(workspaceFolder.uri) : undefined); + const gitRemoteUrl = repo ? gitProvider.getDefaultRemoteUrl(repo) : undefined; + let filePath = uri.toString(true); + if (repo && gitRemoteUrl) { + const relativeFilePath = path.relative(repo.rootUri.toString(true), filePath); + if (!relativeFilePath.startsWith("..")) { + filePath = relativeFilePath; + } + } else if (workspaceFolder) { + const relativeFilePath = path.relative(workspaceFolder.uri.toString(true), filePath); + if (!relativeFilePath.startsWith("..")) { + filePath = relativeFilePath; + } + } + return { + filePath, + gitRemoteUrl, + }; +} + +export async function openTextDocument( + filePathParams: FilePathParams, + gitProvider: GitProvider, +): Promise { + const { filePath, gitRemoteUrl } = filePathParams; + + // Try parse as absolute path try { - // try parse as absolute path const absoluteFilepath = Uri.parse(filePath, true); if (absoluteFilepath.scheme) { return workspace.openTextDocument(absoluteFilepath); } } catch (err) { - // Cannot open as absolute path, try to find file in git root + // ignore } - if (gitUrl && gitUrl.trim().length > 0) { - const localGitRoot = gitProvider.findLocalRootUriByRemoteUrl(gitUrl); + // Try find file in provided git repository + if (gitRemoteUrl && gitRemoteUrl.trim().length > 0) { + const localGitRoot = gitProvider.findLocalRootUriByRemoteUrl(gitRemoteUrl); if (localGitRoot) { try { const absoluteFilepath = Uri.joinPath(localGitRoot, filePath); return await workspace.openTextDocument(absoluteFilepath); } catch (err) { - // File not found in local git root, try to find file in workspace folders + // ignore } } } for (const root of workspace.workspaceFolders ?? []) { + // Try find file in workspace folder const absoluteFilepath = Uri.joinPath(root.uri, filePath); try { return await workspace.openTextDocument(absoluteFilepath); } catch (err) { - // File not found in workspace folder, try to use findFiles + // ignore + } + + // Try find file in git repository of workspace folder + const localGitRoot = gitProvider.getRepository(root.uri)?.rootUri; + if (localGitRoot) { + try { + const absoluteFilepath = Uri.joinPath(localGitRoot, filePath); + return await workspace.openTextDocument(absoluteFilepath); + } catch (err) { + // ignore + } } } - logger.info("File not found in workspace folders, trying with findFiles..."); + // Try find file in workspace folders using workspace.findFiles + logger.info("File not found in workspace folders, trying with findFiles..."); const files = await workspace.findFiles(filePath, undefined, 1); if (files[0]) { - return workspace.openTextDocument(files[0]); + try { + return await workspace.openTextDocument(files[0]); + } catch (err) { + // ignore + } } return null; From 74578d969bbfa7946d1820f33dd9555f65960844 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Fri, 13 Dec 2024 10:13:41 +0800 Subject: [PATCH 2/2] fix(chat-panel): improve the implement of lookup symbol. (#3551) * fix(chat-panel): improve the implement of lookup symbol. * fix(chat-panel): fix missing methods in createClient. * fix(chat-panel): fix filepath convert string to type Filepath. --- clients/tabby-chat-panel/package.json | 2 +- clients/tabby-chat-panel/src/index.ts | 173 +++++++++++++++--- clients/vscode/src/chat/WebviewHelper.ts | 170 +++++++++++++---- clients/vscode/src/chat/chatPanel.ts | 3 +- clients/vscode/src/chat/fileContext.ts | 1 + clients/vscode/src/chat/utils.ts | 102 +++++++++++ ee/tabby-ui/app/chat/page.tsx | 5 +- .../app/files/components/chat-side-bar.tsx | 4 +- .../components/assistant-message-section.tsx | 1 - ee/tabby-ui/components/chat/chat.tsx | 14 +- .../components/chat/question-answer.tsx | 3 +- .../components/message-markdown/code.tsx | 26 +-- .../components/message-markdown/index.tsx | 50 +++-- .../message-markdown/markdown-context.tsx | 9 +- 14 files changed, 453 insertions(+), 110 deletions(-) create mode 100644 clients/vscode/src/chat/utils.ts diff --git a/clients/tabby-chat-panel/package.json b/clients/tabby-chat-panel/package.json index 27775eac49c7..ca4726646d4f 100644 --- a/clients/tabby-chat-panel/package.json +++ b/clients/tabby-chat-panel/package.json @@ -1,7 +1,7 @@ { "name": "tabby-chat-panel", "type": "module", - "version": "0.3.0", + "version": "0.4.0", "keywords": [], "sideEffects": false, "exports": { diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index 0cfcfc43a0dc..da6a46aa0512 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -3,11 +3,54 @@ import { version } from '../package.json' export const TABBY_CHAT_PANEL_API_VERSION: string = version +/** + * Represents a position in a file. + */ +export interface Position { + /** + * 1-based line number + */ + line: number + /** + * 1-based character number + */ + character: number +} + +/** + * Represents a range in a file. + */ +export interface PositionRange { + /** + * The start position of the range. + */ + start: Position + /** + * The end position of the range. + */ + end: Position +} + +/** + * Represents a range of lines in a file. + */ export interface LineRange { + /** + * 1-based line number + */ start: number + /** + * 1-based line number + */ end: number } +/** + * Represents a location in a file. + * It could be a 1-based line number, a line range, a position or a position range. + */ +export type Location = number | LineRange | Position | PositionRange + export interface FileContext { kind: 'file' range: LineRange @@ -44,6 +87,96 @@ export interface NavigateOpts { openInEditor?: boolean } +/** + * Represents a filepath to identify a file. + */ +export type Filepath = FilepathInGitRepository | FilepathUri + +/** + * This is used for files in a Git repository, and should be used in priority. + */ +export interface FilepathInGitRepository { + kind: 'git' + + /** + * A string that is a relative path in the repository. + */ + filepath: string + + /** + * A URL used to identify the Git repository in both the client and server. + * The URL should be valid for use as a git remote URL, for example: + * 1. 'https://github.com/TabbyML/tabby' + * 2. 'git://github.com/TabbyML/tabby.git' + */ + gitUrl: string + + /** + * An optional Git revision which the file is at. + */ + revision?: string +} + +/** + * This is used for files not in a Git repository. + */ +export interface FilepathUri { + kind: 'uri' + + /** + * A string that can be parsed as a URI, used to identify the file in the client. + * The scheme of the URI could be: + * - 'untitled' means a new file not saved. + * - 'file', 'vscode-vfs' or some other protocol to access the file. + */ + uri: string +} + +/** + * Represents a file and a location in it. + */ +export interface FileLocation { + /** + * The filepath of the file. + */ + filepath: Filepath + + /** + * The location in the file. + * It could be a 1-based line number, a line range, a position or a position range. + */ + location: Location +} + +/** + * Represents a hint to help find a symbol. + */ +export interface LookupSymbolHint { + /** + * The filepath of the file to search the symbol. + */ + filepath?: Filepath + + /** + * The location in the file to search the symbol. + */ + location?: Location +} + +/** + * Includes information about a symbol returned by the {@link ClientApiMethods.lookupSymbol} method. + */ +export interface SymbolInfo { + /** + * Where the symbol is found. + */ + source: FileLocation + /** + * The target location to navigate to when the symbol is clicked. + */ + target: FileLocation +} + export interface ServerApi { init: (request: InitRequest) => void sendMessage: (message: ChatMessage) => void @@ -53,14 +186,7 @@ export interface ServerApi { updateTheme: (style: string, themeClass: string) => void updateActiveSelection: (context: Context | null) => void } -export interface SymbolInfo { - sourceFile: string - sourceLine: number - sourceCol: number - targetFile: string - targetLine: number - targetCol: number -} + export interface ClientApiMethods { navigate: (context: Context, opts?: NavigateOpts) => void refresh: () => Promise @@ -84,8 +210,20 @@ export interface ClientApiMethods { onKeyboardEvent: (type: 'keydown' | 'keyup' | 'keypress', event: KeyboardEventInit) => void - // find symbol definition location by hint filepaths and keyword - onLookupSymbol?: (hintFilepaths: string[], keyword: string) => Promise + /** + * Find the target symbol and return the symbol information. + * @param symbol The symbol to find. + * @param hints The optional {@link LookupSymbolHint} list to help find the symbol. The hints should be sorted by priority. + * @returns The symbol information if found, otherwise undefined. + */ + lookupSymbol?: (symbol: string, hints?: LookupSymbolHint[] | undefined) => Promise + + /** + * Open the target file location in the editor. + * @param target The target file location to open. + * @returns Whether the file location is opened successfully. + */ + openInEditor: (target: FileLocation) => Promise } export interface ClientApi extends ClientApiMethods { @@ -94,17 +232,6 @@ export interface ClientApi extends ClientApiMethods { hasCapability: (method: keyof ClientApiMethods) => Promise } -export const clientApiKeys: (keyof ClientApiMethods)[] = [ - 'navigate', - 'refresh', - 'onSubmitMessage', - 'onApplyInEditor', - 'onApplyInEditorV2', - 'onLoaded', - 'onCopy', - 'onKeyboardEvent', -] - export interface ChatMessage { message: string @@ -125,10 +252,12 @@ export function createClient(target: HTMLIFrameElement, api: ClientApiMethods): refresh: api.refresh, onSubmitMessage: api.onSubmitMessage, onApplyInEditor: api.onApplyInEditor, + onApplyInEditorV2: api.onApplyInEditorV2, onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, - onLookupSymbol: api.onLookupSymbol, + lookupSymbol: api.lookupSymbol, + openInEditor: api.openInEditor, }, }) } diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index 2e6641616285..5479c0fc2fdf 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -4,15 +4,26 @@ import { env, TextEditor, window, + Range, Selection, TextDocument, Webview, ColorThemeKind, ProgressLocation, commands, + Location, LocationLink, } from "vscode"; -import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams, SymbolInfo } from "tabby-chat-panel"; +import type { + ServerApi, + ChatMessage, + Context, + NavigateOpts, + OnLoadedParams, + LookupSymbolHint, + SymbolInfo, + FileLocation, +} from "tabby-chat-panel"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; import hashObject from "object-hash"; import * as semver from "semver"; @@ -24,6 +35,13 @@ import { createClient } from "./chatPanel"; import { ChatFeature } from "../lsp/ChatFeature"; import { isBrowser } from "../env"; import { getFileContextFromSelection, showFileContext, openTextDocument } from "./fileContext"; +import { + localUriToChatPanelFilepath, + chatPanelFilepathToLocalUri, + vscodePositionToChatPanelPosition, + vscodeRangeToChatPanelPositionRange, + chatPanelLocationToVSCodeRange, +} from "./utils"; export class WebviewHelper { webview?: Webview; @@ -562,51 +580,129 @@ export class WebviewHelper { this.logger.debug(`Dispatching keyboard event: ${type} ${JSON.stringify(event)}`); this.webview?.postMessage({ action: "dispatchKeyboardEvent", type, event }); }, - onLookupSymbol: async (hintFilepaths: string[], keyword: string): Promise => { - const findSymbolInfo = async (filepaths: string[], keyword: string): Promise => { - if (!keyword || !filepaths.length) { - this.logger.info("No keyword or filepaths provided"); - return undefined; + lookupSymbol: async (symbol: string, hints?: LookupSymbolHint[] | undefined): Promise => { + if (!symbol.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/)) { + // Do not process invalid symbols + return undefined; + } + /// FIXME: When no hints provided, try to use `vscode.executeWorkspaceSymbolProvider` to find the symbol. + + // Find the symbol in the hints + for (const hint of hints ?? []) { + if (!hint.filepath) { + this.logger.debug("No filepath in the hint:", hint); + continue; } - try { - for (const filepath of filepaths) { - const document = await openTextDocument({ filePath: filepath }, this.gitProvider); - if (!document) { - this.logger.info(`File not found: ${filepath}`); - continue; - } - const content = document.getText(); - let pos = 0; - while ((pos = content.indexOf(keyword, pos)) !== -1) { - const position = document.positionAt(pos); - const locations = await commands.executeCommand( - "vscode.executeDefinitionProvider", - document.uri, - position, - ); - if (locations && locations.length > 0) { - const location = locations[0]; - if (location) { + const uri = chatPanelFilepathToLocalUri(hint.filepath, this.gitProvider); + if (!uri) { + continue; + } + const document = await openTextDocument({ filePath: uri.toString(true) }, this.gitProvider); + if (!document) { + continue; + } + + const findSymbolInContent = async ( + content: string, + offsetInDocument: number, + ): Promise => { + // Add word boundary to perform exact match + const matchRegExp = new RegExp(`\\b${symbol}\\b`, "g"); + let match; + while ((match = matchRegExp.exec(content)) !== null) { + const offset = offsetInDocument + match.index; + const position = document.positionAt(offset); + const locations = await commands.executeCommand( + "vscode.executeDefinitionProvider", + document.uri, + position, + ); + if (locations && locations.length > 0) { + const location = locations[0]; + if (location) { + if ("targetUri" in location) { + const targetLocation = location.targetSelectionRange ?? location.targetRange; + return { + source: { + filepath: localUriToChatPanelFilepath(document.uri, this.gitProvider), + location: vscodePositionToChatPanelPosition(position), + }, + target: { + filepath: localUriToChatPanelFilepath(location.targetUri, this.gitProvider), + location: vscodeRangeToChatPanelPositionRange(targetLocation), + }, + }; + } else if ("uri" in location) { return { - sourceFile: filepath, - sourceLine: position.line + 1, - sourceCol: position.character, - targetFile: location.targetUri.toString(true), - targetLine: location.targetRange.start.line + 1, - targetCol: location.targetRange.start.character, + source: { + filepath: localUriToChatPanelFilepath(document.uri, this.gitProvider), + location: vscodePositionToChatPanelPosition(position), + }, + target: { + filepath: localUriToChatPanelFilepath(location.uri, this.gitProvider), + location: vscodeRangeToChatPanelPositionRange(location.range), + }, }; } } - pos += keyword.length; } } - } catch (error) { - this.logger.error("Error in findSymbolInfo:", error); + return undefined; + }; + + let symbolInfo: SymbolInfo | undefined; + if (hint.location) { + // Find in the hint location + const location = chatPanelLocationToVSCodeRange(hint.location); + if (location) { + let range: Range; + if (!location.isEmpty) { + range = location; + } else { + // a empty range, create a new range with this line to the end of the file + range = new Range(location.start.line, 0, document.lineCount, 0); + } + const content = document.getText(range); + const offset = document.offsetAt(range.start); + symbolInfo = await findSymbolInContent(content, offset); + } } - return undefined; - }; + if (!symbolInfo) { + // Fallback to find in full content + const content = document.getText(); + symbolInfo = await findSymbolInContent(content, 0); + } + if (symbolInfo) { + // Symbol found + this.logger.debug( + `Symbol found: ${symbol} with hints: ${JSON.stringify(hints)}: ${JSON.stringify(symbolInfo)}`, + ); + return symbolInfo; + } + } + this.logger.debug(`Symbol not found: ${symbol} with hints: ${JSON.stringify(hints)}`); + return undefined; + }, + openInEditor: async (fileLocation: FileLocation): Promise => { + const uri = chatPanelFilepathToLocalUri(fileLocation.filepath, this.gitProvider); + if (!uri) { + return false; + } - return await findSymbolInfo(hintFilepaths, keyword); + const targetRange = chatPanelLocationToVSCodeRange(fileLocation.location) ?? new Range(0, 0, 0, 0); + try { + await commands.executeCommand( + "editor.action.goToLocations", + uri, + targetRange.start, + [new Location(uri, targetRange)], + "goto", + ); + return true; + } catch (error) { + this.logger.error("Failed to go to location:", fileLocation, error); + return false; + } }, }); } diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 3ef44a34a236..0d3c50de1582 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -33,7 +33,8 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, - onLookupSymbol: api.onLookupSymbol, + lookupSymbol: api.lookupSymbol, + openInEditor: api.openInEditor, }, }); } diff --git a/clients/vscode/src/chat/fileContext.ts b/clients/vscode/src/chat/fileContext.ts index 88e095327003..5ab97bbe3945 100644 --- a/clients/vscode/src/chat/fileContext.ts +++ b/clients/vscode/src/chat/fileContext.ts @@ -160,6 +160,7 @@ export async function openTextDocument( } } + logger.warn(`File not found: ${filePath}`); return null; } diff --git a/clients/vscode/src/chat/utils.ts b/clients/vscode/src/chat/utils.ts new file mode 100644 index 000000000000..1721277345a9 --- /dev/null +++ b/clients/vscode/src/chat/utils.ts @@ -0,0 +1,102 @@ +import path from "path"; +import { Position as VSCodePosition, Range as VSCodeRange, Uri, workspace } from "vscode"; +import type { Filepath, Position as ChatPanelPosition, LineRange, PositionRange, Location } from "tabby-chat-panel"; +import type { GitProvider } from "../git/GitProvider"; +import { getLogger } from "../logger"; + +const logger = getLogger("chat/utils"); + +export function localUriToChatPanelFilepath(uri: Uri, gitProvider: GitProvider): Filepath { + const workspaceFolder = workspace.getWorkspaceFolder(uri); + + let repo = gitProvider.getRepository(uri); + if (!repo && workspaceFolder) { + repo = gitProvider.getRepository(workspaceFolder.uri); + } + const gitRemoteUrl = repo ? gitProvider.getDefaultRemoteUrl(repo) : undefined; + + if (repo && gitRemoteUrl) { + const relativeFilePath = path.relative(repo.rootUri.toString(true), uri.toString(true)); + if (!relativeFilePath.startsWith("..")) { + return { + kind: "git", + filepath: relativeFilePath, + gitUrl: gitRemoteUrl, + }; + } + } + + return { + kind: "uri", + uri: uri.toString(true), + }; +} + +export function chatPanelFilepathToLocalUri(filepath: Filepath, gitProvider: GitProvider): Uri | null { + if (filepath.kind === "uri") { + try { + return Uri.parse(filepath.uri, true); + } catch (e) { + // FIXME(@icycodes): this is a hack for uri is relative filepaths in workspaces + const workspaceRoot = workspace.workspaceFolders?.[0]; + if (workspaceRoot) { + return Uri.joinPath(workspaceRoot.uri, filepath.uri); + } + } + } else if (filepath.kind === "git") { + const localGitRoot = gitProvider.findLocalRootUriByRemoteUrl(filepath.gitUrl); + if (localGitRoot) { + return Uri.joinPath(localGitRoot, filepath.filepath); + } + } + logger.warn(`Invalid filepath params.`, filepath); + return null; +} + +export function vscodePositionToChatPanelPosition(position: VSCodePosition): ChatPanelPosition { + return { + line: position.line + 1, + character: position.character + 1, + }; +} + +export function chatPanelPositionToVSCodePosition(position: ChatPanelPosition): VSCodePosition { + return new VSCodePosition(Math.max(0, position.line - 1), Math.max(0, position.character - 1)); +} + +export function vscodeRangeToChatPanelPositionRange(range: VSCodeRange): PositionRange { + return { + start: vscodePositionToChatPanelPosition(range.start), + end: vscodePositionToChatPanelPosition(range.end), + }; +} + +export function chatPanelPositionRangeToVSCodeRange(positionRange: PositionRange): VSCodeRange { + return new VSCodeRange( + chatPanelPositionToVSCodePosition(positionRange.start), + chatPanelPositionToVSCodePosition(positionRange.end), + ); +} + +export function chatPanelLineRangeToVSCodeRange(lineRange: LineRange): VSCodeRange { + // Do not minus 1 from end line number, as we want to include the last line. + return new VSCodeRange(Math.max(0, lineRange.start - 1), 0, lineRange.end, 0); +} + +export function chatPanelLocationToVSCodeRange(location: Location): VSCodeRange | null { + if (typeof location === "number") { + const position = new VSCodePosition(Math.max(0, location - 1), 0); + return new VSCodeRange(position, position); + } else if ("line" in location) { + const position = chatPanelPositionToVSCodePosition(location); + return new VSCodeRange(position, position); + } else if ("start" in location) { + if (typeof location.start === "number") { + return chatPanelLineRangeToVSCodeRange(location as LineRange); + } else { + return chatPanelPositionRangeToVSCodeRange(location as PositionRange); + } + } + logger.warn(`Invalid location params.`, location); + return null; +} diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index 2c5da6d76dbd..48287de58c56 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -237,7 +237,7 @@ export default function ChatPage() { server ?.hasCapability('onApplyInEditorV2') .then(setSupportsOnApplyInEditorV2) - server?.hasCapability('onLookupSymbol').then(setSupportsOnLookupSymbol) + server?.hasCapability('lookupSymbol').then(setSupportsOnLookupSymbol) } checkCapabilities() @@ -392,8 +392,9 @@ export default function ChatPage() { supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} onLookupSymbol={ isInEditor && - (supportsOnLookupSymbol ? server?.onLookupSymbol : undefined) + (supportsOnLookupSymbol ? server?.lookupSymbol : undefined) } + openInEditor={isInEditor && server?.openInEditor} /> ) diff --git a/ee/tabby-ui/app/files/components/chat-side-bar.tsx b/ee/tabby-ui/app/files/components/chat-side-bar.tsx index 824140f4c94c..f8f673dfae95 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -80,8 +80,8 @@ export const ChatSideBar: React.FC = ({ onLoaded() {}, onCopy(_content) {}, onKeyboardEvent() {}, - async onLookupSymbol(_filepath, _keywords) { - return undefined + async openInEditor() { + return false } }) diff --git a/ee/tabby-ui/app/search/components/assistant-message-section.tsx b/ee/tabby-ui/app/search/components/assistant-message-section.tsx index fd21467870f3..9eb3a68e6aa1 100644 --- a/ee/tabby-ui/app/search/components/assistant-message-section.tsx +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -375,7 +375,6 @@ export function AssistantMessageSection({ fetchingContextInfo={fetchingContextInfo} canWrapLongLines={!isLoading} supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} - onNavigateToContext={onNavigateToContext} /> {/* if isEditing, do not display error message block */} {message.error && } diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index 03212c6c3295..31852e599a7d 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -3,6 +3,8 @@ import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es' import type { Context, FileContext, + FileLocation, + LookupSymbolHint, NavigateOpts, SymbolInfo } from 'tabby-chat-panel' @@ -52,9 +54,10 @@ type ChatContextValue = { | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) onLookupSymbol?: ( - filepaths: string[], - keyword: string + symbol: string, + hints?: LookupSymbolHint[] | undefined ) => Promise + openInEditor?: (target: FileLocation) => void relevantContext: Context[] activeSelection: Context | null removeRelevantContext: (index: number) => void @@ -94,9 +97,10 @@ interface ChatProps extends React.ComponentProps<'div'> { | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) onLookupSymbol?: ( - filepaths: string[], - keyword: string + symbol: string, + hints?: LookupSymbolHint[] | undefined ) => Promise + openInEditor?: (target: FileLocation) => void chatInputRef: RefObject supportsOnApplyInEditorV2: boolean } @@ -119,6 +123,7 @@ function ChatRenderer( onSubmitMessage, onApplyInEditor, onLookupSymbol, + openInEditor, chatInputRef, supportsOnApplyInEditorV2 }: ChatProps, @@ -546,6 +551,7 @@ function ChatRenderer( onCopyContent, onApplyInEditor, onLookupSymbol, + openInEditor, relevantContext, removeRelevantContext, chatInputRef, diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index 5c1de52133a9..f9dd02821ae9 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -262,6 +262,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onApplyInEditor, onCopyContent, onLookupSymbol, + openInEditor, supportsOnApplyInEditorV2 } = React.useContext(ChatContext) const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = @@ -406,9 +407,9 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onCodeCitationMouseLeave={onCodeCitationMouseLeave} canWrapLongLines={!isLoading} onLookupSymbol={onLookupSymbol} + openInEditor={openInEditor} supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} activeSelection={userMessage.activeContext} - onNavigateToContext={onNavigateToContext} /> {!!message.error && } diff --git a/ee/tabby-ui/components/message-markdown/code.tsx b/ee/tabby-ui/components/message-markdown/code.tsx index f6759acdc2bb..f907b78b0c93 100644 --- a/ee/tabby-ui/components/message-markdown/code.tsx +++ b/ee/tabby-ui/components/message-markdown/code.tsx @@ -25,16 +25,16 @@ export function CodeElement({ }: CodeElementProps) { const { lookupSymbol, + openInEditor, canWrapLongLines, onApplyInEditor, onCopyContent, supportsOnApplyInEditorV2, - onNavigateToContext, symbolPositionMap } = useContext(MessageMarkdownContext) const keyword = children[0]?.toString() - const symbolLocation = keyword ? symbolPositionMap.get(keyword) : undefined + const symbolInfo = keyword ? symbolPositionMap.get(keyword) : undefined useEffect(() => { if (!inline || !lookupSymbol || !keyword) return @@ -49,26 +49,12 @@ export function CodeElement({ } if (inline) { - const isSymbolNavigable = Boolean(symbolLocation) + const isSymbolNavigable = Boolean(symbolInfo?.target) const handleClick = () => { - if (!isSymbolNavigable || !symbolLocation || !onNavigateToContext) return - - onNavigateToContext( - { - filepath: symbolLocation.targetFile, - range: { - start: symbolLocation.targetLine, - end: symbolLocation.targetLine - }, - git_url: '', - content: '', - kind: 'file' - }, - { - openInEditor: true - } - ) + if (isSymbolNavigable && openInEditor && symbolInfo?.target) { + openInEditor(symbolInfo.target) + } } return ( diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index 248241d2cde3..fdead1b26d39 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -25,9 +25,10 @@ import { MemoizedReactMarkdown } from '@/components/markdown' import './style.css' import { - Context, FileContext, - NavigateOpts, + FileLocation, + Filepath, + LookupSymbolHint, SymbolInfo } from 'tabby-chat-panel/index' @@ -84,10 +85,10 @@ export interface MessageMarkdownProps { opts?: { languageId: string; smart: boolean } ) => void onLookupSymbol?: ( - filepaths: string[], - keyword: string + symbol: string, + hints?: LookupSymbolHint[] | undefined ) => Promise - onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void + openInEditor?: (target: FileLocation) => void onCodeCitationClick?: (code: AttachmentCodeItem) => void onCodeCitationMouseEnter?: (index: number) => void onCodeCitationMouseLeave?: (index: number) => void @@ -113,9 +114,9 @@ export function MessageMarkdown({ className, canWrapLongLines, onLookupSymbol, + openInEditor, supportsOnApplyInEditorV2, activeSelection, - onNavigateToContext, ...rest }: MessageMarkdownProps) { const [symbolPositionMap, setSymbolLocationMap] = useState< @@ -195,10 +196,35 @@ export function MessageMarkdown({ if (symbolPositionMap.has(keyword)) return setSymbolLocationMap(map => new Map(map.set(keyword, undefined))) - const symbolInfo = await onLookupSymbol( - activeSelection?.filepath ? [activeSelection?.filepath] : [], - keyword - ) + const hints: LookupSymbolHint[] = [] + if (activeSelection) { + // FIXME(@icycodes): this is intended to convert the filepath to Filepath type + // We should remove this after FileContext.filepath use type Filepath instead of string + let filepath: Filepath + if ( + activeSelection.git_url.length > 1 && + !activeSelection.filepath.includes(':') + ) { + filepath = { + kind: 'git', + filepath: activeSelection.filepath, + gitUrl: activeSelection.git_url + } + } else { + filepath = { + kind: 'uri', + uri: activeSelection.filepath + } + } + hints.push({ + filepath, + location: { + start: activeSelection.range.start, + end: activeSelection.range.end + } + }) + } + const symbolInfo = await onLookupSymbol(keyword, hints) setSymbolLocationMap(map => new Map(map.set(keyword, symbolInfo))) } @@ -215,9 +241,9 @@ export function MessageMarkdown({ canWrapLongLines: !!canWrapLongLines, supportsOnApplyInEditorV2, activeSelection, - onNavigateToContext, symbolPositionMap, - lookupSymbol: onLookupSymbol ? lookupSymbol : undefined + lookupSymbol: onLookupSymbol ? lookupSymbol : undefined, + openInEditor }} > void supportsOnApplyInEditorV2: boolean activeSelection?: FileContext symbolPositionMap: Map + openInEditor?: (target: FileLocation) => void lookupSymbol?: (keyword: string) => void }