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 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); 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 { 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..3bfa516f83d3 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -9,8 +9,10 @@ import { Webview, ColorThemeKind, ProgressLocation, + commands, + LocationLink, } 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"; @@ -20,7 +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 { getFileContextFromSelection, showFileContext, openTextDocument } from "./fileContext"; export class WebviewHelper { webview?: Webview; @@ -384,6 +386,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 +552,52 @@ 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 { + 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) { + 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, + }; + } + } + 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/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; 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::>(), 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-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 } 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() {