From 0a57a6659d27a9450b3ab3ec10483e362508244b Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Sun, 8 Dec 2024 11:03:48 +0800 Subject: [PATCH 1/7] feat(graphQL): support list users by email (#3529) Signed-off-by: Wei Zhang --- ee/tabby-db/src/users.rs | 131 ++++++++++++++++++++----- ee/tabby-schema/graphql/schema.graphql | 2 +- ee/tabby-schema/src/schema/auth.rs | 1 + ee/tabby-schema/src/schema/mod.rs | 3 +- ee/tabby-webserver/src/service/auth.rs | 78 ++++++++++++++- 5 files changed, 183 insertions(+), 32 deletions(-) diff --git a/ee/tabby-db/src/users.rs b/ee/tabby-db/src/users.rs index 347415e04735..c7add2dfa056 100644 --- a/ee/tabby-db/src/users.rs +++ b/ee/tabby-db/src/users.rs @@ -125,10 +125,20 @@ impl DbConn { pub async fn list_users_with_filter( &self, + emails: Option>, limit: Option, skip_id: Option, backwards: bool, ) -> Result> { + let email_condition = emails.map(|emails| { + let emails = emails + .into_iter() + .map(|e| format!("'{}'", e)) + .collect::>() + .join(", "); + format!("email in ({emails})") + }); + let users = query_paged_as!( UserDAO, "users", @@ -145,7 +155,8 @@ impl DbConn { ], limit, skip_id, - backwards + backwards, + email_condition ) .fetch_all(&self.pool) .await?; @@ -399,7 +410,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(None, None, false) + conn.list_users_with_filter(None, None, None, false) .await .unwrap() ) @@ -407,7 +418,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(2), None, false) + conn.list_users_with_filter(None, Some(2), None, false) .await .unwrap() ) @@ -415,7 +426,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(None, Some(1), false) + conn.list_users_with_filter(None, None, Some(1), false) .await .unwrap() ) @@ -423,7 +434,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(2), Some(1), false) + conn.list_users_with_filter(None, Some(2), Some(1), false) .await .unwrap() ) @@ -431,12 +442,16 @@ mod tests { // backwards assert_eq!( empty, - to_ids(conn.list_users_with_filter(None, None, true).await.unwrap()) + to_ids( + conn.list_users_with_filter(None, None, None, true) + .await + .unwrap() + ) ); assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(2), None, true) + conn.list_users_with_filter(None, Some(2), None, true) .await .unwrap() ) @@ -444,7 +459,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(None, Some(1), true) + conn.list_users_with_filter(None, None, Some(1), true) .await .unwrap() ) @@ -452,7 +467,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(1), Some(1), true) + conn.list_users_with_filter(None, Some(1), Some(1), true) .await .unwrap() ) @@ -473,7 +488,7 @@ mod tests { assert_eq!( vec![id1], to_ids( - conn.list_users_with_filter(None, None, false) + conn.list_users_with_filter(None, None, None, false) .await .unwrap() ) @@ -481,7 +496,7 @@ mod tests { assert_eq!( vec![id1], to_ids( - conn.list_users_with_filter(Some(2), None, false) + conn.list_users_with_filter(None, Some(2), None, false) .await .unwrap() ) @@ -489,7 +504,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(None, Some(1), false) + conn.list_users_with_filter(None, None, Some(1), false) .await .unwrap() ) @@ -497,7 +512,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(2), Some(1), false) + conn.list_users_with_filter(None, Some(2), Some(1), false) .await .unwrap() ) @@ -505,12 +520,16 @@ mod tests { // backwards assert_eq!( vec![id1], - to_ids(conn.list_users_with_filter(None, None, true).await.unwrap()) + to_ids( + conn.list_users_with_filter(None, None, None, true) + .await + .unwrap() + ) ); assert_eq!( vec![id1], to_ids( - conn.list_users_with_filter(Some(2), None, true) + conn.list_users_with_filter(None, Some(2), None, true) .await .unwrap() ) @@ -518,7 +537,7 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(None, Some(1), true) + conn.list_users_with_filter(None, None, Some(1), true) .await .unwrap() ) @@ -526,12 +545,55 @@ mod tests { assert_eq!( empty, to_ids( - conn.list_users_with_filter(Some(1), Some(1), true) + conn.list_users_with_filter(None, Some(1), Some(1), true) .await .unwrap() ) ); + // by email + assert_eq!( + vec![id1], + to_ids( + conn.list_users_with_filter( + Some(vec!["use1@example.com".into()]), + None, + None, + true + ) + .await + .unwrap() + ) + ); + + assert_eq!( + vec![id1], + to_ids( + conn.list_users_with_filter( + Some(vec!["use1@example.com".into()]), + Some(1), + None, + true + ) + .await + .unwrap() + ) + ); + + assert_eq!( + empty, + to_ids( + conn.list_users_with_filter( + Some(vec!["notexisted@example.com".into()]), + None, + None, + true + ) + .await + .unwrap() + ) + ); + let id2 = conn .create_user( "use2@example.com".into(), @@ -574,7 +636,7 @@ mod tests { assert_eq!( vec![id1, id2, id3, id4, id5], to_ids( - conn.list_users_with_filter(None, None, false) + conn.list_users_with_filter(None, None, None, false) .await .unwrap() ) @@ -582,7 +644,7 @@ mod tests { assert_eq!( vec![id1, id2], to_ids( - conn.list_users_with_filter(Some(2), None, false) + conn.list_users_with_filter(None, Some(2), None, false) .await .unwrap() ) @@ -590,7 +652,7 @@ mod tests { assert_eq!( vec![id3, id4, id5], to_ids( - conn.list_users_with_filter(None, Some(2), false) + conn.list_users_with_filter(None, None, Some(2), false) .await .unwrap() ) @@ -598,7 +660,7 @@ mod tests { assert_eq!( vec![id3, id4], to_ids( - conn.list_users_with_filter(Some(2), Some(2), false) + conn.list_users_with_filter(None, Some(2), Some(2), false) .await .unwrap() ) @@ -606,12 +668,16 @@ mod tests { // backwards assert_eq!( vec![id1, id2, id3, id4, id5], - to_ids(conn.list_users_with_filter(None, None, true).await.unwrap()) + to_ids( + conn.list_users_with_filter(None, None, None, true) + .await + .unwrap() + ) ); assert_eq!( vec![id4, id5], to_ids( - conn.list_users_with_filter(Some(2), None, true) + conn.list_users_with_filter(None, Some(2), None, true) .await .unwrap() ) @@ -619,7 +685,7 @@ mod tests { assert_eq!( vec![id1, id2, id3], to_ids( - conn.list_users_with_filter(None, Some(4), true) + conn.list_users_with_filter(None, None, Some(4), true) .await .unwrap() ) @@ -627,11 +693,26 @@ mod tests { assert_eq!( vec![id2, id3], to_ids( - conn.list_users_with_filter(Some(2), Some(4), true) + conn.list_users_with_filter(None, Some(2), Some(4), true) .await .unwrap() ) ); + + // by email + assert_eq!( + vec![id2, id3], + to_ids( + conn.list_users_with_filter( + Some(vec!["use2@example.com".into(), "use3@example.com".into()]), + None, + None, + false + ) + .await + .unwrap() + ) + ); } #[tokio::test] diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 97c59b64c9d0..2edf2abdff18 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -678,7 +678,7 @@ type Query { registrationToken: String! me: UserSecured! "List users, accessible for all login users." - users(after: String, before: String, first: Int, last: Int): UserConnection! + users(emails: [String!], after: String, before: String, first: Int, last: Int): UserConnection! invitations(after: String, before: String, first: Int, last: Int): InvitationConnection! jobRuns(ids: [ID!], jobs: [String!], after: String, before: String, first: Int, last: Int): JobRunConnection! jobRunStats(jobs: [String!]): JobStats! diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index c05ed1b6d27b..8956ce628e9a 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -386,6 +386,7 @@ pub trait AuthenticationService: Send + Sync { async fn list_users( &self, + emails: Option>, after: Option, before: Option, first: Option, diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 504b9ef0a02a..3d6a1826b82c 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -265,6 +265,7 @@ impl Query { /// List users, accessible for all login users. async fn users( ctx: &Context, + emails: Option>, after: Option, before: Option, first: Option, @@ -279,7 +280,7 @@ impl Query { |after, before, first, last| async move { ctx.locator .auth() - .list_users(after, before, first, last) + .list_users(emails, after, before, first, last) .await .map(|users| users.into_iter().map(UserValue::UserSecured).collect()) }, diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 8df4f01f4f15..5740aafcf362 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -450,6 +450,7 @@ impl AuthenticationService for AuthenticationServiceImpl { async fn list_users( &self, + emails: Option>, after: Option, before: Option, first: Option, @@ -459,7 +460,7 @@ impl AuthenticationService for AuthenticationServiceImpl { Ok(self .db - .list_users_with_filter(skip_id, limit, backwards) + .list_users_with_filter(emails, skip_id, limit, backwards) .await? .into_iter() .map(|x| UserSecured::new(self.db.clone(), x)) @@ -981,6 +982,7 @@ mod tests { async fn list_users( db: &AuthenticationServiceImpl, + emails: Option>, after: Option, before: Option, first: Option, @@ -992,7 +994,10 @@ mod tests { first, last, |after, before, first, last| async move { - Ok(db.list_users(after, before, first, last).await.unwrap()) + Ok(db + .list_users(emails, after, before, first, last) + .await + .unwrap()) }, ) .await @@ -1288,13 +1293,14 @@ mod tests { .await .unwrap(); - let all_users = list_users(&service, None, None, None, None).await; + let all_users = list_users(&service, None, None, None, None, None).await; assert!(!all_users.page_info.has_next_page); assert!(!all_users.page_info.has_previous_page); let users = list_users( &service, + None, Some(all_users.edges[0].cursor.clone()), None, None, @@ -1305,7 +1311,7 @@ mod tests { assert!(!users.page_info.has_next_page); assert!(users.page_info.has_previous_page); - let users = list_users(&service, None, None, Some(2), None).await; + let users = list_users(&service, None, None, None, Some(2), None).await; assert!(users.page_info.has_next_page); assert!(!users.page_info.has_previous_page); @@ -1313,6 +1319,7 @@ mod tests { let users = list_users( &service, None, + None, Some(all_users.edges[1].cursor.clone()), None, Some(1), @@ -1324,6 +1331,7 @@ mod tests { let users = list_users( &service, + None, Some(all_users.edges[2].cursor.clone()), None, None, @@ -1333,12 +1341,13 @@ mod tests { assert!(!users.page_info.has_next_page); assert!(users.page_info.has_previous_page); - let users = list_users(&service, None, None, Some(3), None).await; + let users = list_users(&service, None, None, None, Some(3), None).await; assert!(!users.page_info.has_next_page); assert!(!users.page_info.has_previous_page); let users = list_users( &service, + None, Some(all_users.edges[0].cursor.clone()), None, Some(2), @@ -1349,6 +1358,65 @@ mod tests { assert!(users.page_info.has_previous_page); } + #[tokio::test] + async fn test_users_by_emails() { + let service = test_authentication_service().await; + let name1 = "User1"; + let name2 = "User2"; + let name3 = "User3"; + service + .db + .create_user( + "a@example.com".into(), + Some("pass".into()), + false, + Some(name1.into()), + ) + .await + .unwrap(); + service + .db + .create_user( + "b@example.com".into(), + Some("pass".into()), + false, + Some(name2.into()), + ) + .await + .unwrap(); + service + .db + .create_user( + "c@example.com".into(), + Some("pass".into()), + false, + Some(name3.into()), + ) + .await + .unwrap(); + + let cases = vec![ + (vec![name1, name2, name3], None), + ( + vec![name2, name3], + Some(vec!["b@example.com".into(), "c@example.com".into()]), + ), + (vec![name2], Some(vec!["b@example.com".into()])), + ]; + + for (expected, emails) in cases { + let users = list_users(&service, emails, None, None, None, None).await; + assert_eq!(users.edges.len(), expected.len()); + let mut names = users + .edges + .into_iter() + .map(|x| x.node.name.clone()) + .collect::>(); + names.sort(); + assert_eq!(names, expected); + } + } + #[tokio::test] #[serial] async fn test_allow_self_signup() { From 5b6c84543f24c2ebb16aaf5f363253b43a266222 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Sun, 8 Dec 2024 11:19:47 +0800 Subject: [PATCH 2/7] fix(codeBrowser): fix broken tree view on Windows. (#3528) --- crates/tabby-git/src/serve_git.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tabby-git/src/serve_git.rs b/crates/tabby-git/src/serve_git.rs index 70b2fc6557c9..ba1fbbda93c1 100644 --- a/crates/tabby-git/src/serve_git.rs +++ b/crates/tabby-git/src/serve_git.rs @@ -64,7 +64,8 @@ fn resolve<'a>( basename: relpath .join(entry.name().expect("failed to resolve entry name")) .display() - .to_string(), + .to_string() + .replace("\\", "/"), } }) .collect::>(), From c61a9eefad63c026310ee8ffb188d1b8ae1a05ff Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Sun, 8 Dec 2024 10:05:25 -0600 Subject: [PATCH 3/7] feat(vscode): adding clickble symbol render in chat panel (#3420) * feat: add onRenderLsp event handler for rendering language server protocol * feat: Add onRenderLsp event handler for rendering language server protocol * chore(vscode): update targetFile path to use workspace relative path * refactor: Update code to use KeywordInfo type for onRenderLsp event handler * feat: add onNavigateSymbol method to ClientApi interface * feat: add onNavigateSymbol method to ClientApi interface * feat: add onNavigateSymbol method to ClientApi interface * feat: add onHoverSymbol method to ClientApi interface * feat: add onHoverSymbol and findSymbolInfo methods to WebviewHelper * feat: add onHoverSymbol and findSymbolInfo methods to WebviewHelper * fix: update onNavigateSymbol parameter name in ClientApi interface * fix: update onNavigateSymbol parameter name in ClientApi interface * feat: Add support for onNavigateSymbol and onHoverSymbol in ChatPage The code changes in `ChatPage` component add support for the `onNavigateSymbol` and `onHoverSymbol` capabilities. These capabilities are checked and set using the `hasCapability` method of the server. The `onNavigateSymbol` and `onHoverSymbol` methods are conditionally used based on the support for these capabilities. This change enhances the functionality of the ChatPage component in the Tabby UI. * chore: remove unused type * feat: Update ClientApi interface to make onNavigateSymbol optional The ClientApi interface has been updated to make the onNavigateSymbol method optional. This change allows for better flexibility in implementing the interface, as the onNavigateSymbol method is now conditionally used based on the support for the capability. This update enhances the usability of the ClientApi interface in the Tabby UI. * feat: Add support for onHoverSymbol in ChatPage The code changes in `ChatPage` component add support for the `onHoverSymbol` capability. This capability is checked and set using the `hasCapability` method of the server. The `onHoverSymbol` method is conditionally used based on the support for this capability. This change enhances the functionality of the ChatPage component in the Tabby UI. * [autofix.ci] apply automated fixes * feat: Add activeSelection prop to MessageMarkdown and update imports * feat: Rename parameter in onNavigateSymbol to hintFilepaths for clarity * feat: Implement CodeElement component for rendering code in Markdown * refactor: Remove onNavigateToContext prop from MessageMarkdown and related components for simplification * feat: Rename onNavigateSymbol to onLookupSymbol and update its signature for improved clarity * feat: Rename onNavigateSymbol to onLookupSymbol and refactor symbol lookup logic for improved clarity and maintainability * feat: Rename onNavigateSymbol to onLookupSymbol and update related logic for consistency across components * [autofix.ci] apply automated fixes * update: render symbol * Update icons.tsx * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: liangfung Co-authored-by: Meng Zhang --- clients/tabby-chat-panel/src/index.ts | 12 +- clients/vscode/src/chat/WebviewHelper.ts | 64 +++++++++- clients/vscode/src/chat/chatPanel.ts | 1 + ee/tabby-ui/app/chat/page.tsx | 6 + .../app/files/components/chat-side-bar.tsx | 5 +- .../components/assistant-message-section.tsx | 4 +- ee/tabby-ui/components/chat/chat.tsx | 17 ++- .../components/chat/question-answer.tsx | 4 + .../components/message-markdown/code.tsx | 113 +++++++++++++++++ .../components/message-markdown/index.tsx | 115 ++++++++---------- .../message-markdown/markdown-context.tsx | 32 +++++ .../components/message-markdown/style.css | 9 ++ ee/tabby-ui/components/ui/icons.tsx | 9 ++ 13 files changed, 320 insertions(+), 71 deletions(-) create mode 100644 ee/tabby-ui/components/message-markdown/code.tsx create mode 100644 ee/tabby-ui/components/message-markdown/markdown-context.tsx diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index e2cc1ec63c85..0cfcfc43a0dc 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -53,7 +53,14 @@ 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 @@ -77,6 +84,8 @@ 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 } export interface ClientApi extends ClientApiMethods { @@ -119,6 +128,7 @@ export function createClient(target: HTMLIFrameElement, api: ClientApiMethods): onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, + onLookupSymbol: api.onLookupSymbol, }, }) } diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index a4f22d40bb19..fdf8e2544852 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -9,8 +9,11 @@ import { Webview, ColorThemeKind, ProgressLocation, + commands, + LocationLink, + workspace, } from "vscode"; -import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams } from "tabby-chat-panel"; +import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams, SymbolInfo } from "tabby-chat-panel"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; import hashObject from "object-hash"; import * as semver from "semver"; @@ -21,6 +24,7 @@ import { createClient } from "./chatPanel"; import { Client as LspClient } from "../lsp/Client"; import { isBrowser } from "../env"; import { getFileContextFromSelection, showFileContext } from "./fileContext"; +import path from "path"; export class WebviewHelper { webview?: Webview; @@ -384,6 +388,9 @@ export class WebviewHelper { } public createChatClient(webview: Webview) { + /* + utility functions for createClient + */ const getIndentInfo = (document: TextDocument, selection: Selection) => { // Determine the indentation for the content // The calculation is based solely on the indentation of the first line @@ -547,6 +554,61 @@ 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; + } + 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 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, + 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, + targetLine: location.targetRange.start.line + 1, + targetCol: location.targetRange.start.character, + }; + } + } + pos += keyword.length; + } + } + } catch (error) { + this.logger.error("Error in findSymbolInfo:", error); + } + return undefined; + }; + + return await findSymbolInfo(hintFilepaths, keyword); + }, }); } } diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 553880f532c4..3ef44a34a236 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -33,6 +33,7 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, + onLookupSymbol: api.onLookupSymbol, }, }); } diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index 6f14266a0909..2c5da6d76dbd 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -74,6 +74,7 @@ export default function ChatPage() { // server feature support check const [supportsOnApplyInEditorV2, setSupportsOnApplyInEditorV2] = useState(false) + const [supportsOnLookupSymbol, setSupportsOnLookupSymbol] = useState(false) const sendMessage = (message: ChatMessage) => { if (chatRef.current) { @@ -236,6 +237,7 @@ export default function ChatPage() { server ?.hasCapability('onApplyInEditorV2') .then(setSupportsOnApplyInEditorV2) + server?.hasCapability('onLookupSymbol').then(setSupportsOnLookupSymbol) } checkCapabilities() @@ -388,6 +390,10 @@ export default function ChatPage() { : server?.onApplyInEditor) } supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} + onLookupSymbol={ + isInEditor && + (supportsOnLookupSymbol ? server?.onLookupSymbol : undefined) + } /> ) 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 de87e95fc496..824140f4c94c 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -79,7 +79,10 @@ export const ChatSideBar: React.FC = ({ onApplyInEditor(_content) {}, onLoaded() {}, onCopy(_content) {}, - onKeyboardEvent() {} + onKeyboardEvent() {}, + async onLookupSymbol(_filepath, _keywords) { + return undefined + } }) const getPrompt = ({ action }: QuickActionEventPayload) => { 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 7ec479e603c7..fd21467870f3 100644 --- a/ee/tabby-ui/app/search/components/assistant-message-section.tsx +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -101,7 +101,8 @@ export function AssistantMessageSection({ onUpdateMessage } = useContext(SearchContext) - const { supportsOnApplyInEditorV2 } = useContext(ChatContext) + const { supportsOnApplyInEditorV2, onNavigateToContext } = + useContext(ChatContext) const [isEditing, setIsEditing] = useState(false) const [showMoreSource, setShowMoreSource] = useState(false) @@ -374,6 +375,7 @@ 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 c86caee905a1..03212c6c3295 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -1,6 +1,11 @@ import React, { RefObject } from 'react' import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es' -import type { Context, FileContext, NavigateOpts } from 'tabby-chat-panel' +import type { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel' import { ERROR_CODE_NOT_FOUND } from '@/lib/constants' import { @@ -46,6 +51,10 @@ type ChatContextValue = { onApplyInEditor?: | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise relevantContext: Context[] activeSelection: Context | null removeRelevantContext: (index: number) => void @@ -84,6 +93,10 @@ interface ChatProps extends React.ComponentProps<'div'> { onApplyInEditor?: | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise chatInputRef: RefObject supportsOnApplyInEditorV2: boolean } @@ -105,6 +118,7 @@ function ChatRenderer( onCopyContent, onSubmitMessage, onApplyInEditor, + onLookupSymbol, chatInputRef, supportsOnApplyInEditorV2 }: ChatProps, @@ -531,6 +545,7 @@ function ChatRenderer( container, onCopyContent, onApplyInEditor, + onLookupSymbol, relevantContext, removeRelevantContext, chatInputRef, diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index 3f767fb07e2b..5c1de52133a9 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -261,6 +261,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onNavigateToContext, onApplyInEditor, onCopyContent, + onLookupSymbol, supportsOnApplyInEditorV2 } = React.useContext(ChatContext) const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = @@ -404,7 +405,10 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onCodeCitationMouseEnter={onCodeCitationMouseEnter} onCodeCitationMouseLeave={onCodeCitationMouseLeave} canWrapLongLines={!isLoading} + onLookupSymbol={onLookupSymbol} 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 new file mode 100644 index 000000000000..26f42ab12b8c --- /dev/null +++ b/ee/tabby-ui/components/message-markdown/code.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useContext, useEffect } from 'react' +import { Element } from 'react-markdown/lib/ast-to-react' + +import { cn } from '@/lib/utils' + +import { CodeBlock } from '../ui/codeblock' +import { IconSquareChevronRight } from '../ui/icons' +import { MessageMarkdownContext } from './markdown-context' + +export interface CodeElementProps { + node: Element + inline?: boolean + className?: string + children: ReactNode & ReactNode[] +} + +/** + * Code element in Markdown AST. + */ +export function CodeElement({ + inline, + className, + children, + ...props +}: CodeElementProps) { + const { + lookupSymbol, + canWrapLongLines, + onApplyInEditor, + onCopyContent, + supportsOnApplyInEditorV2, + onNavigateToContext, + symbolPositionMap + } = useContext(MessageMarkdownContext) + + const keyword = children[0]?.toString() + const symbolLocation = keyword ? symbolPositionMap.get(keyword) : undefined + + useEffect(() => { + if (!inline || !lookupSymbol || !keyword) return + lookupSymbol(keyword) + }, [inline, keyword, lookupSymbol]) + + if (children.length) { + if (children[0] === '▍') { + return + } + children[0] = (children[0] as string).replace('`▍`', '▍') + } + + if (inline) { + const isSymbolNavigable = Boolean(symbolLocation) + + 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 + } + ) + } + + return ( + + {isSymbolNavigable && ( + + )} + + {children} + + + ) + } + + const match = /language-(\w+)/.exec(className || '') + return ( + + ) +} diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index a0955880af8e..248241d2cde3 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -1,4 +1,4 @@ -import { createContext, ReactNode, useContext, useMemo, useState } from 'react' +import { ReactNode, useContext, useMemo, useState } from 'react' import Image from 'next/image' import defaultFavicon from '@/assets/default-favicon.png' import DOMPurify from 'dompurify' @@ -15,7 +15,6 @@ import { } from '@/lib/gql/generates/graphql' import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' import { cn, getContent } from '@/lib/utils' -import { CodeBlock, CodeBlockProps } from '@/components/ui/codeblock' import { HoverCard, HoverCardContent, @@ -25,6 +24,13 @@ import { MemoizedReactMarkdown } from '@/components/markdown' import './style.css' +import { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel/index' + import { MARKDOWN_CITATION_REGEX, MARKDOWN_SOURCE_REGEX @@ -39,6 +45,8 @@ import { IconGitPullRequest } from '../ui/icons' import { Skeleton } from '../ui/skeleton' +import { CodeElement } from './code' +import { MessageMarkdownContext } from './markdown-context' type RelevantDocItem = { type: 'doc' @@ -75,6 +83,11 @@ export interface MessageMarkdownProps { content: string, opts?: { languageId: string; smart: boolean } ) => void + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise + onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void onCodeCitationClick?: (code: AttachmentCodeItem) => void onCodeCitationMouseEnter?: (index: number) => void onCodeCitationMouseLeave?: (index: number) => void @@ -84,27 +97,9 @@ export interface MessageMarkdownProps { // wrapLongLines for code block canWrapLongLines?: boolean supportsOnApplyInEditorV2: boolean + activeSelection?: FileContext } -type MessageMarkdownContextValue = { - onCopyContent?: ((value: string) => void) | undefined - onApplyInEditor?: ( - content: string, - opts?: { languageId: string; smart: boolean } - ) => void - onCodeCitationClick?: (code: AttachmentCodeItem) => void - onCodeCitationMouseEnter?: (index: number) => void - onCodeCitationMouseLeave?: (index: number) => void - contextInfo: ContextInfo | undefined - fetchingContextInfo: boolean - canWrapLongLines: boolean - supportsOnApplyInEditorV2: boolean -} - -const MessageMarkdownContext = createContext( - {} as MessageMarkdownContextValue -) - export function MessageMarkdown({ message, headline = false, @@ -117,9 +112,15 @@ export function MessageMarkdown({ fetchingContextInfo, className, canWrapLongLines, + onLookupSymbol, supportsOnApplyInEditorV2, + activeSelection, + onNavigateToContext, ...rest }: MessageMarkdownProps) { + const [symbolPositionMap, setSymbolLocationMap] = useState< + Map + >(new Map()) const messageAttachments: MessageAttachments = useMemo(() => { const docs: MessageAttachments = attachmentDocs?.map(item => ({ @@ -189,6 +190,18 @@ export function MessageMarkdown({ return elements } + const lookupSymbol = async (keyword: string) => { + if (!onLookupSymbol) return + if (symbolPositionMap.has(keyword)) return + + setSymbolLocationMap(map => new Map(map.set(keyword, undefined))) + const symbolInfo = await onLookupSymbol( + activeSelection?.filepath ? [activeSelection?.filepath] : [], + keyword + ) + setSymbolLocationMap(map => new Map(map.set(keyword, symbolInfo))) + } + return ( {childrenItem} })} @@ -240,37 +259,15 @@ export function MessageMarkdown({ return
  • {children}
  • }, code({ node, inline, className, children, ...props }) { - if (children.length) { - if (children[0] == '▍') { - return ( - - ) - } - - children[0] = (children[0] as string).replace('`▍`', '▍') - } - - const match = /language-(\w+)/.exec(className || '') - - if (inline) { - return ( - - {children} - - ) - } - return ( - + > + {children} + ) } }} @@ -317,20 +314,6 @@ export function ErrorMessageBlock({ ) } -function CodeBlockWrapper(props: CodeBlockProps) { - const { canWrapLongLines, supportsOnApplyInEditorV2 } = useContext( - MessageMarkdownContext - ) - - return ( - - ) -} - function CitationTag({ citationIndex, showcitation, diff --git a/ee/tabby-ui/components/message-markdown/markdown-context.tsx b/ee/tabby-ui/components/message-markdown/markdown-context.tsx new file mode 100644 index 000000000000..8e00f225fddc --- /dev/null +++ b/ee/tabby-ui/components/message-markdown/markdown-context.tsx @@ -0,0 +1,32 @@ +import { createContext } from 'react' +import { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel/index' + +import { ContextInfo } from '@/lib/gql/generates/graphql' +import { AttachmentCodeItem } from '@/lib/types' + +export type MessageMarkdownContextValue = { + onCopyContent?: ((value: string) => void) | undefined + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void + onCodeCitationClick?: (code: AttachmentCodeItem) => void + onCodeCitationMouseEnter?: (index: number) => void + onCodeCitationMouseLeave?: (index: number) => void + contextInfo: ContextInfo | undefined + fetchingContextInfo: boolean + canWrapLongLines: boolean + onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void + supportsOnApplyInEditorV2: boolean + activeSelection?: FileContext + symbolPositionMap: Map + lookupSymbol?: (keyword: string) => void +} + +export const MessageMarkdownContext = + createContext({} as MessageMarkdownContextValue) diff --git a/ee/tabby-ui/components/message-markdown/style.css b/ee/tabby-ui/components/message-markdown/style.css index b703da22d482..d93782c4cefb 100644 --- a/ee/tabby-ui/components/message-markdown/style.css +++ b/ee/tabby-ui/components/message-markdown/style.css @@ -1,3 +1,12 @@ .message-markdown li > * { vertical-align: top; +} + +.message-markdown code.symbol { + @apply rounded-md font-normal mx-1 px-1; +} + +.message-markdown code.symbol::before, +.message-markdown code.symbol::after { + content: none; } \ No newline at end of file diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index 40e806a33717..223cd38b8888 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -39,6 +39,7 @@ import { Share2, Sparkles, SquareActivity, + SquareChevronRight, Star, Tag, WrapText, @@ -1730,6 +1731,13 @@ function IconGitMerge({ return } +function IconSquareChevronRight({ + className, + ...props +}: React.ComponentProps) { + return +} + function IconFileSearch2({ className, ...props @@ -1848,5 +1856,6 @@ export { IconCircleDot, IconGitPullRequest, IconGitMerge, + IconSquareChevronRight, IconFileSearch2 } From 1f7e7e161a5af4b0efd307551a5fa84309e02d4b Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Sun, 8 Dec 2024 20:06:43 -0600 Subject: [PATCH 4/7] fix(tabby-agent): Fix when auto completion shown wrong processed range (#3524) * fix(codeCompletion): correct processedRange end calculation in CompletionItem * fix(codeCompletion): update processedRange end calculation to use processedText length * fix(codeCompletion): refactor processedRange calculation for improved accuracy --- .../src/codeCompletion/solution.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/clients/tabby-agent/src/codeCompletion/solution.ts b/clients/tabby-agent/src/codeCompletion/solution.ts index cb9b6a6596b7..8735db8f0b86 100644 --- a/clients/tabby-agent/src/codeCompletion/solution.ts +++ b/clients/tabby-agent/src/codeCompletion/solution.ts @@ -59,23 +59,22 @@ export class CompletionItem { this.isBlank = isBlank(this.text); // if with auto complete item, insert the completion item with the predicted text + let position = this.context.position; + this.processedText = this.fullText; + if (this.context.isWithCorrectAutoComplete()) { - this.processedText = this.context.insertSeg + this.fullText; - this.processedRange = { - start: this.context.insertPosition, - end: this.context.insertPosition, - }; - } else { - this.processedText = this.fullText; - this.processedRange = { - start: this.context.currentLinePrefix.endsWith(this.replacePrefix) - ? this.context.position - this.replacePrefix.length - : this.context.position, - end: this.context.currentLineSuffix.startsWith(this.replaceSuffix) - ? this.context.position + this.replaceSuffix.length - : this.context.position, - }; + position = this.context.insertPosition; + this.processedText = this.context.insertSeg + this.processedText; } + + this.processedRange = { + start: this.context.currentLinePrefix.endsWith(this.replacePrefix) + ? position - this.replacePrefix.length + : position, + end: this.context.currentLineSuffix.startsWith(this.replaceSuffix) + ? position + this.replaceSuffix.length + : position, + }; } static createBlankItem(context: CompletionContext): CompletionItem { From 5fb03f2420d90b1abe4dc5d9cf30f6c85b4042a3 Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Sun, 8 Dec 2024 20:10:49 -0600 Subject: [PATCH 5/7] fix(codeCompletion): improve auto-complete handling by validating current segment (#3531) --- clients/tabby-agent/src/codeCompletion/contexts.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/clients/tabby-agent/src/codeCompletion/contexts.ts b/clients/tabby-agent/src/codeCompletion/contexts.ts index 8b75e87f7818..0b5c2e39cec3 100644 --- a/clients/tabby-agent/src/codeCompletion/contexts.ts +++ b/clients/tabby-agent/src/codeCompletion/contexts.ts @@ -174,15 +174,14 @@ export class CompletionContext { */ private handleAutoComplete(request: CompletionRequest): void { if (!request.autoComplete?.completionItem) return; + // check if the completion item is the same as the curr segment + if (!request.autoComplete.currSeg || !request.autoComplete.completionItem.startsWith(request.autoComplete.currSeg)) + return; + this.completionItem = request.autoComplete.completionItem; this.currSeg = request.autoComplete.currSeg ?? ""; this.insertSeg = request.autoComplete.insertSeg ?? ""; - // check if the completion item is the same as the insert segment - if (!this.completionItem.startsWith(this.insertSeg)) { - return; - } - const prefixText = request.text.slice(0, request.position); const lastIndex = prefixText.lastIndexOf(this.currSeg); From 34cb570984baef8de722fd5fefc7bca5fac3fafe Mon Sep 17 00:00:00 2001 From: Meng Zhang Date: Mon, 9 Dec 2024 11:57:16 +0800 Subject: [PATCH 6/7] docs(changelog): add release notes for version 0.21.1 (#3533) --- .changes/v0.21.1.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changes/v0.21.1.md diff --git a/.changes/v0.21.1.md b/.changes/v0.21.1.md new file mode 100644 index 000000000000..e3372825ff08 --- /dev/null +++ b/.changes/v0.21.1.md @@ -0,0 +1,9 @@ +## v0.21.1 (2024-12-09) + +### ⚠️ Notice + +* This is a patch release, please also check [the full release note](https://github.com/TabbyML/tabby/releases/tag/v0.21.0) for 0.21. + +### 🧰 Fixed and Improvements + +* Fixed Gitlab Context Provider. \ No newline at end of file From 301c8573ee1287e9778e6d8c4a1b6726ed7ed2e6 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Mon, 9 Dec 2024 14:43:16 +0800 Subject: [PATCH 7/7] 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 fdf8e2544852..3bfa516f83d3 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"; @@ -23,8 +22,7 @@ import { GitProvider } from "../git/GitProvider"; import { createClient } from "./chatPanel"; import { Client as LspClient } from "../lsp/Client"; import { isBrowser } from "../env"; -import { getFileContextFromSelection, showFileContext } from "./fileContext"; -import path from "path"; +import { getFileContextFromSelection, showFileContext, openTextDocument } from "./fileContext"; export class WebviewHelper { webview?: Webview; @@ -561,38 +559,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 fe4d64a248dd..4a4f175366cb 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,30 +39,14 @@ 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 ?? "", }; } @@ -69,7 +57,13 @@ export async function showFileContext(fileContext: FileContext, gitProvider: Git return; } - 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}`); } @@ -87,43 +81,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 await 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;