diff --git a/clients/vscode/src/chat/createClient.ts b/clients/vscode/src/chat/createClient.ts index a0c65b93b98d..ef71adf773fe 100644 --- a/clients/vscode/src/chat/createClient.ts +++ b/clients/vscode/src/chat/createClient.ts @@ -35,6 +35,7 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi openInEditor: api.openInEditor, openExternal: api.openExternal, readWorkspaceGitRepositories: api.readWorkspaceGitRepositories, + lookupDefinitions: api.lookupDefinitions, }, }); } diff --git a/clients/vscode/src/chat/definitions.ts b/clients/vscode/src/chat/definitions.ts new file mode 100644 index 000000000000..ca6a395e697b --- /dev/null +++ b/clients/vscode/src/chat/definitions.ts @@ -0,0 +1,82 @@ +import { LookupDefinitionsHint, SymbolInfo } from "tabby-chat-panel/index"; +import { + chatPanelLocationToVSCodeRange, + getActualChatPanelFilepath, + vscodeRangeToChatPanelPositionRange, +} from "./utils"; +import { Range as VSCodeRange } from "vscode"; + +/** + * Filters out SymbolInfos whose target is inside the given context range, + * and merges overlapping target ranges in the same file. + */ +export function filterSymbolInfosByContextAndOverlap( + symbolInfos: SymbolInfo[], + context: LookupDefinitionsHint | undefined, +): SymbolInfo[] { + if (!symbolInfos.length) { + return []; + } + + // Filter out target inside context + let filtered = symbolInfos; + if (context?.location) { + const contextRange = chatPanelLocationToVSCodeRange(context.location); + const contextPath = context.filepath ? getActualChatPanelFilepath(context.filepath) : undefined; + if (contextRange && contextPath) { + filtered = filtered.filter((symbolInfo) => { + const targetPath = getActualChatPanelFilepath(symbolInfo.target.filepath); + if (targetPath !== contextPath) { + return true; + } + // Check if target is outside contextRange + const targetRange = chatPanelLocationToVSCodeRange(symbolInfo.target.location); + if (!targetRange) { + return true; + } + return targetRange.end.isBefore(contextRange.start) || targetRange.start.isAfter(contextRange.end); + }); + } + } + + // Merge overlapping target ranges in same file + const merged: SymbolInfo[] = []; + for (const current of filtered) { + const currentUri = getActualChatPanelFilepath(current.target.filepath); + const currentRange = chatPanelLocationToVSCodeRange(current.target.location); + if (!currentRange) { + merged.push(current); + continue; + } + + // Try find a previously added symbol that is in the same file and has overlap + let hasMerged = false; + for (const existing of merged) { + const existingUri = getActualChatPanelFilepath(existing.target.filepath); + if (existingUri !== currentUri) { + continue; + } + const existingRange = chatPanelLocationToVSCodeRange(existing.target.location); + if (!existingRange) { + continue; + } + // Check overlap + const isOverlap = !( + currentRange.end.isBefore(existingRange.start) || currentRange.start.isAfter(existingRange.end) + ); + if (isOverlap) { + // Merge + const newStart = currentRange.start.isBefore(existingRange.start) ? currentRange.start : existingRange.start; + const newEnd = currentRange.end.isAfter(existingRange.end) ? currentRange.end : existingRange.end; + const mergedRange = new VSCodeRange(newStart, newEnd); + existing.target.location = vscodeRangeToChatPanelPositionRange(mergedRange); + hasMerged = true; + break; + } + } + if (!hasMerged) { + merged.push(current); + } + } + return merged; +} diff --git a/clients/vscode/src/chat/utils.ts b/clients/vscode/src/chat/utils.ts index 721468c01069..9be0add323c8 100644 --- a/clients/vscode/src/chat/utils.ts +++ b/clients/vscode/src/chat/utils.ts @@ -1,12 +1,23 @@ import path from "path"; -import { TextEditor, Position as VSCodePosition, Range as VSCodeRange, Uri, workspace } from "vscode"; +import { + TextEditor, + Position as VSCodePosition, + Range as VSCodeRange, + Uri, + workspace, + TextDocument, + commands, + LocationLink, + Location as VSCodeLocation, +} from "vscode"; import type { Filepath, Position as ChatPanelPosition, LineRange, PositionRange, - Location, + Location as ChatPanelLocation, FilepathInGitRepository, + SymbolInfo, } from "tabby-chat-panel"; import type { GitProvider } from "../git/GitProvider"; import { getLogger } from "../logger"; @@ -170,7 +181,7 @@ export function chatPanelLineRangeToVSCodeRange(lineRange: LineRange): VSCodeRan return new VSCodeRange(Math.max(0, lineRange.start - 1), 0, lineRange.end, 0); } -export function chatPanelLocationToVSCodeRange(location: Location | undefined): VSCodeRange | null { +export function chatPanelLocationToVSCodeRange(location: ChatPanelLocation | undefined): VSCodeRange | null { if (!location) { return null; } @@ -221,3 +232,69 @@ export function generateLocalNotebookCellUri(notebook: Uri, handle: number): Uri const fragment = `${p}${s}s${Buffer.from(notebook.scheme).toString("base64")}`; return notebook.with({ scheme: DocumentSchemes.vscodeNotebookCell, fragment }); } + +/** + * Calls the built-in VSCode definition provider and returns an array of definitions + * (Location or LocationLink). + */ +export async function getDefinitionLocations( + uri: Uri, + position: VSCodePosition, +): Promise<(VSCodeLocation | LocationLink)[]> { + const results = await commands.executeCommand( + "vscode.executeDefinitionProvider", + uri, + position, + ); + return results ?? []; +} + +/** + * Converts a single VS Code Definition result (Location or LocationLink) + * into a SymbolInfo object for the chat panel. + */ +export function convertDefinitionToSymbolInfo( + document: TextDocument, + position: VSCodePosition, + definition: VSCodeLocation | LocationLink, + gitProvider: GitProvider, +): SymbolInfo | undefined { + let targetUri: Uri | undefined; + let targetRange: VSCodeRange | undefined; + + if ("targetUri" in definition) { + // LocationLink + targetUri = definition.targetUri; + targetRange = definition.targetSelectionRange ?? definition.targetRange; + } else { + // Location + targetUri = definition.uri; + targetRange = definition.range; + } + + if (!targetUri || !targetRange) { + return undefined; + } + + return { + source: { + filepath: localUriToChatPanelFilepath(document.uri, gitProvider), + location: vscodePositionToChatPanelPosition(position), + }, + target: { + filepath: localUriToChatPanelFilepath(targetUri, gitProvider), + location: vscodeRangeToChatPanelPositionRange(targetRange), + }, + }; +} + +/** + * Gets the string path (either from 'kind=git' or 'kind=uri'). + */ +export function getActualChatPanelFilepath(filepath: Filepath): string { + if (filepath.kind === "git") { + return filepath.filepath; + } else { + return filepath.uri; + } +} diff --git a/clients/vscode/src/chat/webview.ts b/clients/vscode/src/chat/webview.ts index e51747620210..9f65a629c203 100644 --- a/clients/vscode/src/chat/webview.ts +++ b/clients/vscode/src/chat/webview.ts @@ -14,6 +14,7 @@ import { ProgressLocation, Location, LocationLink, + Position, } from "vscode"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; import type { @@ -25,6 +26,7 @@ import type { SymbolInfo, FileLocation, GitRepository, + LookupDefinitionsHint, } from "tabby-chat-panel"; import * as semver from "semver"; import type { StatusInfo, Config } from "tabby-agent"; @@ -35,15 +37,15 @@ import { isBrowser } from "../env"; import { getLogger } from "../logger"; import { getFileContextFromSelection } from "./fileContext"; import { - localUriToChatPanelFilepath, chatPanelFilepathToLocalUri, - vscodePositionToChatPanelPosition, - vscodeRangeToChatPanelPositionRange, chatPanelLocationToVSCodeRange, isValidForSyncActiveEditorSelection, + convertDefinitionToSymbolInfo, + getDefinitionLocations, } from "./utils"; import mainHtml from "./html/main.html"; import errorHtml from "./html/error.html"; +import { filterSymbolInfosByContextAndOverlap } from "./definitions"; export class ChatWebview { private readonly logger = getLogger("ChatWebView"); @@ -184,6 +186,14 @@ export class ChatWebview { } } + private async getDefinitionLocations(uri: Uri, position: Position) { + return await commands.executeCommand( + "vscode.executeDefinitionProvider", + uri, + position, + ); + } + private createChatPanelApiClient(): ServerApi | undefined { const webview = this.webview; if (!webview) { @@ -310,40 +320,13 @@ export class ChatWebview { 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 { - source: { - filepath: localUriToChatPanelFilepath(document.uri, this.gitProvider), - location: vscodePositionToChatPanelPosition(position), - }, - target: { - filepath: localUriToChatPanelFilepath(location.uri, this.gitProvider), - location: vscodeRangeToChatPanelPositionRange(location.range), - }, - }; - } - } + // get definitions + const locations = await this.getDefinitionLocations(document.uri, position); + if (!locations || locations.length === 0 || !locations[0]) { + continue; } + + return convertDefinitionToSymbolInfo(document, position, locations[0], this.gitProvider); } return undefined; }; @@ -448,8 +431,93 @@ export class ChatWebview { } return infoList; }, + + lookupDefinitions: async (context: LookupDefinitionsHint): Promise => { + if (!context?.filepath) { + this.logger.info("lookupDefinitions: Missing filepath in context."); + return []; + } + + // convert ChatPanel filepath to a local URI + const uri = chatPanelFilepathToLocalUri(context.filepath, this.gitProvider); + if (!uri) { + this.logger.info("lookupDefinitions: Could not resolve local URI for:", context.filepath); + return []; + } + + // open the document + let document; + try { + document = await workspace.openTextDocument(uri); + } catch (e) { + this.logger.info("lookupDefinitions: Can't open file:", uri); + return []; + } + + // determine the snippet range and get its text + const snippetRange = this.getSnippetRange(document, context); + const snippetText = document.getText(snippetRange); + + // split the text into words + const words = snippetText.split(/\b/); + + // Use an offset accumulator to track each word's position + let offset = 0; + + // Map each word to an async definition lookup + const tasks = words.map((rawWord) => { + const currentOffset = offset; + offset += rawWord.length; + + const trimmedWord = rawWord.trim(); + if (!trimmedWord || trimmedWord.match(/^\W+$/)) { + return Promise.resolve([]); + } + + const position = document.positionAt(document.offsetAt(snippetRange.start) + currentOffset); + + return getDefinitionLocations(document.uri, position) + .then((definitions) => { + if (!definitions || definitions.length === 0) { + return []; + } + const result: SymbolInfo[] = []; + definitions.forEach((def) => { + const info = convertDefinitionToSymbolInfo(document, position, def, this.gitProvider); + if (info) { + result.push(info); + } + }); + return result; + }) + .catch((err) => { + this.logger.error(`lookupDefinitions: DefinitionProvider error: ${err}`); + return []; + }); + }); + + // await all lookups in parallel and flatten the results + const symbolInfosArrays = await Promise.all(tasks); + const symbolInfos = symbolInfosArrays.flat(); + + // filter and merge final results + return filterSymbolInfosByContextAndOverlap(symbolInfos, context); + }, }); } + /** + * Helper: decide snippet range from context.location or entire doc. + */ + private getSnippetRange(document: TextDocument, context: LookupDefinitionsHint): Range { + if (!context.location) { + return new Range(0, 0, document.lineCount, 0); + } + const vsRange = chatPanelLocationToVSCodeRange(context.location); + if (!vsRange || vsRange.isEmpty) { + return new Range(0, 0, document.lineCount, 0); + } + return vsRange; + } private checkStatusAndLoadContent() { const statusInfo = this.lspClient.status.current;