From 88991eee528549bfc6d6e28500b8aad4947401ce Mon Sep 17 00:00:00 2001 From: aliang Date: Fri, 3 Jan 2025 14:20:03 +0800 Subject: [PATCH] refactor(ui): display active editor selection in chat panel within the code browser (#3639) * refactor(chat): change the implementation of synchronizing the active editor selection * update * update: required * feat(ui): refine the implementation of retrieving active selection * update * update * update * update --- ee/tabby-ui/app/chat/page.tsx | 37 ++-- .../app/files/components/chat-side-bar.tsx | 202 ++++++++++++------ .../app/files/components/code-editor-view.tsx | 62 ++++-- .../files/components/code-search-result.tsx | 2 +- .../files/components/source-code-browser.tsx | 40 +--- .../action-bar-widget-extension.tsx | 0 .../action-bar-widget/action-bar-widget.tsx | 21 +- .../editor-search-extension/search-panel.tsx | 0 .../editor-search-extension/search.tsx | 0 ee/tabby-ui/app/files/lib/event-emitter.ts | 31 +-- .../line-menu-extension.tsx | 4 +- .../line-menu-extension/line-menu.css | 0 .../search-match-extension/index.ts | 0 .../search-match-extension/style.css | 0 .../files/lib/selection-extension/index.ts | 57 +++++ ee/tabby-ui/components/chat/chat.tsx | 69 +++--- 16 files changed, 326 insertions(+), 199 deletions(-) rename ee/tabby-ui/app/files/{components => lib}/action-bar-widget/action-bar-widget-extension.tsx (100%) rename ee/tabby-ui/app/files/{components => lib}/action-bar-widget/action-bar-widget.tsx (78%) rename ee/tabby-ui/app/files/{components => lib}/editor-search-extension/search-panel.tsx (100%) rename ee/tabby-ui/app/files/{components => lib}/editor-search-extension/search.tsx (100%) rename ee/tabby-ui/app/files/{components => lib}/line-menu-extension/line-menu-extension.tsx (99%) rename ee/tabby-ui/app/files/{components => lib}/line-menu-extension/line-menu.css (100%) rename ee/tabby-ui/app/files/{components => lib}/search-match-extension/index.ts (100%) rename ee/tabby-ui/app/files/{components => lib}/search-match-extension/style.css (100%) create mode 100644 ee/tabby-ui/app/files/lib/selection-extension/index.ts diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index d7864b99000a..87567bf4e983 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -46,7 +46,8 @@ const convertToHSLColor = (style: string) => { } export default function ChatPage() { - const [isInit, setIsInit] = useState(false) + const [isChatComponentLoaded, setIsChatComponentLoaded] = useState(false) + const [isServerLoaded, setIsServerLoaded] = useState(false) const [fetcherOptions, setFetcherOptions] = useState( null ) @@ -61,7 +62,6 @@ export default function ChatPage() { const [isRefreshLoading, setIsRefreshLoading] = useState(false) const chatRef = useRef(null) - const [chatLoaded, setChatLoaded] = useState(false) const { width } = useWindowSize() const prevWidthRef = useRef(width) const chatInputRef = useRef(null) @@ -76,8 +76,8 @@ export default function ChatPage() { useState(false) const [supportsOnLookupSymbol, setSupportsOnLookupSymbol] = useState(false) const [ - supportsProvideWorkspaceGitRepoInfo, - setSupportsProvideWorkspaceGitRepoInfo + supportsReadWorkspaceGitRepoInfo, + setSupportsReadWorkspaceGitRepoInfo ] = useState(false) const executeCommand = (command: ChatCommand) => { @@ -116,7 +116,6 @@ export default function ChatPage() { } setActiveChatId(nanoid()) - setIsInit(true) setFetcherOptions(request.fetcherOptions) useMacOSKeyboardEventHandler.current = request.useMacOSKeyboardEventHandler @@ -244,18 +243,20 @@ export default function ChatPage() { server?.hasCapability('lookupSymbol').then(setSupportsOnLookupSymbol) server ?.hasCapability('readWorkspaceGitRepositories') - .then(setSupportsProvideWorkspaceGitRepoInfo) + .then(setSupportsReadWorkspaceGitRepoInfo) } - checkCapabilities() + checkCapabilities().then(() => { + setIsServerLoaded(true) + }) } }, [server]) useLayoutEffect(() => { - if (!chatLoaded) return + if (!isChatComponentLoaded) return if ( width && - isInit && + isServerLoaded && fetcherOptions && !errorMessage && !prevWidthRef.current @@ -263,7 +264,7 @@ export default function ChatPage() { chatRef.current?.focus() } prevWidthRef.current = width - }, [width, chatLoaded]) + }, [width, isChatComponentLoaded]) const clearPendingState = () => { setPendingRelevantContexts([]) @@ -284,14 +285,11 @@ export default function ChatPage() { } if (pendingCommand) { - // FIXME: this delay is a workaround for waiting for the active selection to be updated - setTimeout(() => { - currentChatRef.executeCommand(pendingCommand) - }, 500) + currentChatRef.executeCommand(pendingCommand) } clearPendingState() - setChatLoaded(true) + setIsChatComponentLoaded(true) } const openInEditor = async (fileLocation: FileLocation) => { @@ -302,6 +300,10 @@ export default function ChatPage() { return server?.openExternal(url) } + const getActiveEditorSelection = async () => { + return server?.getActiveEditorSelection() ?? null + } + const refresh = async () => { setIsRefreshLoading(true) await server?.refresh() @@ -376,7 +378,7 @@ export default function ChatPage() { ) } - if (!isInit || !fetcherOptions) { + if (!isServerLoaded || !fetcherOptions) { return ( <> @@ -420,10 +422,11 @@ export default function ChatPage() { openInEditor={openInEditor} openExternal={openExternal} readWorkspaceGitRepositories={ - supportsProvideWorkspaceGitRepoInfo + supportsReadWorkspaceGitRepoInfo ? server?.readWorkspaceGitRepositories : undefined } + getActiveEditorSelection={getActiveEditorSelection} /> ) 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 134de1ce0486..4badc028c50a 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -1,6 +1,11 @@ -import React, { useRef, useState } from 'react' +import React, { useState } from 'react' import { find } from 'lodash-es' -import type { FileLocation, GitRepository } from 'tabby-chat-panel' +import type { + ChatCommand, + EditorFileContext, + FileLocation, + GitRepository +} from 'tabby-chat-panel' import { useClient } from 'tabby-chat-panel/react' import { RepositoryListQuery } from '@/lib/gql/generates/graphql' @@ -12,28 +17,120 @@ import { cn, formatLineHashForLocation } from '@/lib/utils' import { Button } from '@/components/ui/button' import { IconClose } from '@/components/ui/icons' -import { QuickActionEventPayload } from '../lib/event-emitter' +import { emitter } from '../lib/event-emitter' +import { getActiveSelection } from '../lib/selection-extension' import { SourceCodeBrowserContext } from './source-code-browser' import { generateEntryPath, getDefaultRepoRef, resolveRepoRef } from './utils' interface ChatSideBarProps extends Omit, 'children'> { activeRepo: RepositoryListQuery['repositoryList'][0] | undefined + pendingCommand?: ChatCommand } +export const ChatSideBar: React.FC = props => { + const [shouldInitialize, setShouldInitialize] = useState(false) + const { chatSideBarVisible, setChatSideBarVisible } = React.useContext( + SourceCodeBrowserContext + ) + const [pendingCommand, setPendingCommand] = React.useState< + ChatCommand | undefined + >() + + React.useEffect(() => { + if (chatSideBarVisible && !shouldInitialize) { + setShouldInitialize(true) + } + }, [chatSideBarVisible]) + + React.useEffect(() => { + const quickActionCallback = (command: ChatCommand) => { + setChatSideBarVisible(true) + + if (!shouldInitialize) { + setPendingCommand(command) + } + } + + emitter.on('quick_action_command', quickActionCallback) + return () => { + emitter.off('quick_action_command', quickActionCallback) + } + }, []) + + if (!shouldInitialize) return null -export const ChatSideBar: React.FC = ({ + return +} + +function ChatSideBarRenderer({ activeRepo, className, + pendingCommand, ...props -}) => { +}: ChatSideBarProps) { const [{ data }] = useMe() - const [initialized, setInitialized] = useState(false) - const { pendingEvent, setPendingEvent, repoMap, updateActivePath } = + const [isLoaded, setIsLoaded] = useState(false) + const { repoMap, updateActivePath, activeEntryInfo, textEditorViewRef } = React.useContext(SourceCodeBrowserContext) const activeChatId = useChatStore(state => state.activeChatId) const iframeRef = React.useRef(null) - const executedCommand = useRef(false) const repoMapRef = useLatest(repoMap) + + const client = useClient(iframeRef, { + refresh: async () => { + window.location.reload() + + // Ensure the loading effect is maintained + await new Promise(resolve => { + setTimeout(() => resolve(null), 1000) + }) + }, + onApplyInEditor(_content) {}, + onLoaded() { + setIsLoaded(true) + }, + onCopy(_content) {}, + onKeyboardEvent() {}, + openInEditor: async (fileLocation: FileLocation) => { + return openInCodeBrowser(fileLocation) + }, + openExternal: async (url: string) => { + window.open(url, '_blank') + }, + readWorkspaceGitRepositories: async () => { + return readWorkspaceGitRepositories.current() + }, + getActiveEditorSelection: async () => { + return getActiveEditorSelection.current() + } + }) + + React.useEffect(() => { + const quickActionCallback = (command: ChatCommand) => { + client?.executeCommand(command) + } + + emitter.on('quick_action_command', quickActionCallback) + + return () => { + emitter.off('quick_action_command', quickActionCallback) + } + }, [client]) + + React.useEffect(() => { + const notifyActiveEditorSelectionChange = ( + editorFileContext: EditorFileContext | null + ) => { + client?.updateActiveSelection(editorFileContext) + } + + emitter.on('selection_change', notifyActiveEditorSelectionChange) + + return () => { + emitter.off('selection_change', notifyActiveEditorSelectionChange) + } + }, [client]) + const openInCodeBrowser = async (fileLocation: FileLocation) => { const { filepath, location } = fileLocation if (filepath.kind === 'git') { @@ -72,85 +169,48 @@ export const ChatSideBar: React.FC = ({ return list }) - const client = useClient(iframeRef, { - refresh: async () => { - window.location.reload() + const getActiveEditorSelection = useLatest(() => { + if (!textEditorViewRef.current || !activeEntryInfo) return null - // Ensure the loading effect is maintained - await new Promise(resolve => { - setTimeout(() => resolve(null), 1000) - }) - }, - onApplyInEditor(_content) {}, - onLoaded() { - setInitialized(true) - }, - onCopy(_content) {}, - onKeyboardEvent() {}, - openInEditor: async (fileLocation: FileLocation) => { - return openInCodeBrowser(fileLocation) - }, - openExternal: async (url: string) => { - window.open(url, '_blank') - }, - readWorkspaceGitRepositories: async () => { - return readWorkspaceGitRepositories.current?.() - }, - getActiveEditorSelection: async() => { - // FIXME(@jueliang) implement - return null - }, + const context = getActiveSelection(textEditorViewRef.current) + const editorFileContext: EditorFileContext | null = + context && activeEntryInfo.basename && activeRepo + ? { + kind: 'file', + filepath: { + kind: 'git', + filepath: activeEntryInfo.basename, + gitUrl: activeRepo?.gitUrl + }, + range: { + start: context.startLine, + end: context.endLine + }, + content: context.content + } + : null + return editorFileContext }) - const getCommand = ({ action }: QuickActionEventPayload) => { - switch (action) { - case 'explain': - return 'explain' - case 'generate_unittest': - return 'generate-tests' - case 'generate_doc': - return 'generate-docs' - } - } - React.useEffect(() => { - if (iframeRef?.current && data) { - client?.init({ + if (client && data && isLoaded) { + client.init({ fetcherOptions: { authorization: data.me.authToken } }) } - }, [iframeRef?.current, client?.init, data]) + }, [iframeRef?.current, data, isLoaded]) React.useEffect(() => { - if (pendingEvent && client && initialized) { + if (pendingCommand && client && isLoaded) { const execute = async () => { - const { lineFrom, lineTo, code, path, gitUrl } = pendingEvent - client.updateActiveSelection({ - kind: 'file', - content: code, - range: { - start: lineFrom, - end: lineTo ?? lineFrom - }, - filepath: { - kind: 'git', - filepath: path, - gitUrl - } - }) - const command = getCommand(pendingEvent) - // FIXME: this delay is a workaround for waiting for the active selection to be updated - setTimeout(() => { - client.executeCommand(command) - }, 500) - setPendingEvent(undefined) + client.executeCommand(pendingCommand) } execute() } - }, [initialized, pendingEvent]) + }, [isLoaded]) return (
diff --git a/ee/tabby-ui/app/files/components/code-editor-view.tsx b/ee/tabby-ui/app/files/components/code-editor-view.tsx index 39020418cfb8..ae3b66736515 100644 --- a/ee/tabby-ui/app/files/components/code-editor-view.tsx +++ b/ee/tabby-ui/app/files/components/code-editor-view.tsx @@ -16,11 +16,10 @@ import { highlightTagExtension } from '@/components/codemirror/tag-range-highlig import { codeTagHoverTooltip } from '@/components/codemirror/tooltip-extesion' import { emitter, LineMenuActionEventPayload } from '../lib/event-emitter' -import { ActionBarWidgetExtension } from './action-bar-widget/action-bar-widget-extension' import { selectLinesGutter, setSelectedLines -} from './line-menu-extension/line-menu-extension' +} from '../lib/line-menu-extension/line-menu-extension' import { SourceCodeBrowserContext } from './source-code-browser' import { generateEntryPath, @@ -29,13 +28,17 @@ import { viewModelToKind } from './utils' -import './line-menu-extension/line-menu.css' +import '../lib/line-menu-extension/line-menu.css' + +import { EditorFileContext } from 'tabby-chat-panel/index' import { useLatest } from '@/lib/hooks/use-latest' import { filename2prism } from '@/lib/language-utils' -import { search } from './editor-search-extension/search' -import { SearchPanel } from './editor-search-extension/search-panel' +import { ActionBarWidgetExtension } from '../lib/action-bar-widget/action-bar-widget-extension' +import { search } from '../lib/editor-search-extension/search' +import { SearchPanel } from '../lib/editor-search-extension/search-panel' +import { SelectionChangeExtension } from '../lib/selection-extension' interface CodeEditorViewProps { value: string @@ -43,11 +46,7 @@ interface CodeEditorViewProps { className?: string } -const CodeEditorView: React.FC = ({ - value, - language, - className -}) => { +const CodeEditorView: React.FC = ({ value, language }) => { const { theme } = useTheme() const tags: TCodeTag[] = React.useMemo(() => { return [] @@ -63,13 +62,14 @@ const CodeEditorView: React.FC = ({ activePath, activeEntryInfo, activeRepo, - activeRepoRef + activeRepoRef, + textEditorViewRef } = React.useContext(SourceCodeBrowserContext) const { basename } = activeEntryInfo const gitUrl = activeRepo?.gitUrl ?? '' const extensions = React.useMemo(() => { - let result: Extension[] = [ + const result: Extension[] = [ selectLinesGutter({ onSelectLine: range => { if (!range) { @@ -104,6 +104,39 @@ const CodeEditorView: React.FC = ({ createPanel: config => new SearchPanel(config) }) ] + if (isChatEnabled) { + result.push( + SelectionChangeExtension( + ( + context: { + content: string + startLine: number + endLine: number + } | null + ) => { + const editorFileContext: EditorFileContext | null = + context && activeEntryInfo.basename && activeRepo + ? { + kind: 'file', + filepath: { + kind: 'git', + filepath: activeEntryInfo.basename, + gitUrl: activeRepo?.gitUrl + }, + range: { + start: context.startLine, + end: context.endLine + }, + content: context.content + } + : null + + emitter.emit('selection_change', editorFileContext) + } + ) + ) + } + if (isChatEnabled && activePath && basename) { result.push( ActionBarWidgetExtension({ language, path: basename, gitUrl }) @@ -123,7 +156,7 @@ const CodeEditorView: React.FC = ({ React.useEffect(() => { const onClickLineMenu = (data: LineMenuActionEventPayload) => { if (typeof lineNumber !== 'number') return - if (data.action === 'copy_permalink') { + if (data.action === 'copy-permalink') { const _link = generateEntryPath( activeRepo, activeRepoRef?.ref?.commit ?? activeRepoRef?.name, @@ -149,7 +182,7 @@ const CodeEditorView: React.FC = ({ copyToClipboard(link.toString()) return } - if (data.action === 'copy_line') { + if (data.action === 'copy-line') { if (!editorView) return const line = editorView.state.doc.line(lineNumber) let endLine: Line | undefined = undefined @@ -233,6 +266,7 @@ const CodeEditorView: React.FC = ({ } window.addEventListener('keydown', handleKeyDown) + textEditorViewRef.current = editorView return () => { window.removeEventListener('keydown', handleKeyDown) diff --git a/ee/tabby-ui/app/files/components/code-search-result.tsx b/ee/tabby-ui/app/files/components/code-search-result.tsx index 7a7d505de3c9..692cdd82c1e9 100644 --- a/ee/tabby-ui/app/files/components/code-search-result.tsx +++ b/ee/tabby-ui/app/files/components/code-search-result.tsx @@ -14,8 +14,8 @@ import { filename2prism } from '@/lib/language-utils' import CodeEditor from '@/components/codemirror/codemirror' import { lineClickExtension } from '@/components/codemirror/line-click-extension' +import { searchMatchExtension } from '../lib/search-match-extension' import { SourceCodeSearchResult as SourceCodeSearchResultType } from './code-search-result-view' -import { searchMatchExtension } from './search-match-extension' import { SourceCodeBrowserContext } from './source-code-browser' import { generateEntryPath } from './utils' diff --git a/ee/tabby-ui/app/files/components/source-code-browser.tsx b/ee/tabby-ui/app/files/components/source-code-browser.tsx index ef501e77a9b3..a99968ac13dc 100644 --- a/ee/tabby-ui/app/files/components/source-code-browser.tsx +++ b/ee/tabby-ui/app/files/components/source-code-browser.tsx @@ -2,6 +2,7 @@ import React, { PropsWithChildren, useState } from 'react' import { usePathname } from 'next/navigation' +import type { EditorView } from '@codemirror/view' import { createRequest } from '@urql/core' import { compact, isEmpty, isNil, toNumber } from 'lodash-es' import { ImperativePanelHandle } from 'react-resizable-panels' @@ -27,7 +28,6 @@ import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' import { ListSkeleton } from '@/components/skeleton' import { useTopbarProgress } from '@/components/topbar-progress-indicator' -import { emitter, QuickActionEventPayload } from '../lib/event-emitter' import { BlobModeView } from './blob-mode-view' import { ChatSideBar } from './chat-side-bar' import { CodeSearchBar } from './code-search-bar' @@ -128,8 +128,6 @@ type SourceCodeBrowserContextValue = { fileTreeData: TFileTreeNode[] chatSideBarVisible: boolean setChatSideBarVisible: React.Dispatch> - pendingEvent: QuickActionEventPayload | undefined - setPendingEvent: (d: QuickActionEventPayload | undefined) => void isChatEnabled: boolean | undefined activeRepo: RepositoryItem | undefined activeRepoRef: @@ -144,6 +142,7 @@ type SourceCodeBrowserContextValue = { prevActivePath: React.MutableRefObject error: Error | undefined setError: (e: Error | undefined) => void + textEditorViewRef: React.MutableRefObject } const SourceCodeBrowserContext = @@ -171,12 +170,9 @@ const SourceCodeBrowserContextProvider: React.FC = ({ >({}) const [expandedKeys, setExpandedKeys] = React.useState>(new Set()) const [chatSideBarVisible, setChatSideBarVisible] = React.useState(false) - const [pendingEvent, setPendingEvent] = React.useState< - QuickActionEventPayload | undefined - >() const [error, setError] = useState() const prevActivePath = React.useRef() - + const textEditorViewRef = React.useRef(null) const updateActivePath: SourceCodeBrowserContextValue['updateActivePath'] = React.useCallback(async (path, options) => { const replace = options?.replace @@ -326,8 +322,6 @@ const SourceCodeBrowserContextProvider: React.FC = ({ fileTreeData, chatSideBarVisible, setChatSideBarVisible, - pendingEvent, - setPendingEvent, isChatEnabled, repoMap, setRepoMap, @@ -337,7 +331,8 @@ const SourceCodeBrowserContextProvider: React.FC = ({ activeEntryInfo, prevActivePath, error, - setError + setError, + textEditorViewRef }} > {children} @@ -361,7 +356,6 @@ const SourceCodeBrowserRenderer: React.FC = ({ setInitialized, chatSideBarVisible, setChatSideBarVisible, - setPendingEvent, repoMap, setRepoMap, activeRepo, @@ -379,7 +373,6 @@ const SourceCodeBrowserRenderer: React.FC = ({ const { progress, setProgress } = useTopbarProgress() const chatSideBarPanelRef = React.useRef(null) const [chatSideBarPanelSize, setChatSideBarPanelSize] = React.useState(35) - const [chatSidebarInitialized, setChatSidebarInitialized] = useState(false) const searchQuery = searchParams.get('q')?.toString() @@ -621,12 +614,6 @@ const SourceCodeBrowserRenderer: React.FC = ({ }, [fetchingRawFile, fetchingTreeEntries]) React.useEffect(() => { - const initChatSidebar = () => { - if (chatSideBarVisible && !chatSidebarInitialized) { - setChatSidebarInitialized(true) - } - } - const toggleChatSidebarPanel = () => { if (chatSideBarVisible) { chatSideBarPanelRef.current?.expand() @@ -636,7 +623,6 @@ const SourceCodeBrowserRenderer: React.FC = ({ } } - initChatSidebar() toggleChatSidebarPanel() }, [chatSideBarVisible]) @@ -653,18 +639,6 @@ const SourceCodeBrowserRenderer: React.FC = ({ } }, [activeEntryInfo]) - React.useEffect(() => { - const onCallCompletion = (data: QuickActionEventPayload) => { - setChatSideBarVisible(true) - setPendingEvent(data) - } - emitter.on('code_browser_quick_action', onCallCompletion) - - return () => { - emitter.off('code_browser_quick_action', onCallCompletion) - } - }, []) - return ( = ({ ref={chatSideBarPanelRef} onCollapse={() => setChatSideBarVisible(false)} > - {chatSidebarInitialized ? ( - - ) : null} + diff --git a/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget-extension.tsx b/ee/tabby-ui/app/files/lib/action-bar-widget/action-bar-widget-extension.tsx similarity index 100% rename from ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget-extension.tsx rename to ee/tabby-ui/app/files/lib/action-bar-widget/action-bar-widget-extension.tsx diff --git a/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx b/ee/tabby-ui/app/files/lib/action-bar-widget/action-bar-widget.tsx similarity index 78% rename from ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx rename to ee/tabby-ui/app/files/lib/action-bar-widget/action-bar-widget.tsx index 2da1aba69c5d..2f35bf7149e8 100644 --- a/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx +++ b/ee/tabby-ui/app/files/lib/action-bar-widget/action-bar-widget.tsx @@ -1,5 +1,6 @@ import Image from 'next/image' import tabbyLogo from '@/assets/tabby.png' +import { ChatCommand } from 'tabby-chat-panel/index' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' @@ -11,7 +12,7 @@ import { } from '@/components/ui/dropdown-menu' import { IconChevronUpDown } from '@/components/ui/icons' -import { CodeBrowserQuickAction, emitter } from '../../lib/event-emitter' +import { emitter } from '../../lib/event-emitter' interface ActionBarWidgetProps extends React.HTMLAttributes { text: string @@ -32,16 +33,8 @@ export const ActionBarWidget: React.FC = ({ gitUrl, ...props }) => { - const handleAction = (action: CodeBrowserQuickAction) => { - emitter.emit('code_browser_quick_action', { - action, - code: text, - language, - path, - lineFrom, - lineTo, - gitUrl - }) + const onTriggerCommand = (command: ChatCommand) => { + emitter.emit('quick_action_command', command) } return ( @@ -56,7 +49,7 @@ export const ActionBarWidget: React.FC = ({ @@ -70,13 +63,13 @@ export const ActionBarWidget: React.FC = ({ handleAction('generate_unittest')} + onSelect={() => onTriggerCommand('generate-tests')} > Unit Test handleAction('generate_doc')} + onSelect={() => onTriggerCommand('generate-docs')} > Documentation diff --git a/ee/tabby-ui/app/files/components/editor-search-extension/search-panel.tsx b/ee/tabby-ui/app/files/lib/editor-search-extension/search-panel.tsx similarity index 100% rename from ee/tabby-ui/app/files/components/editor-search-extension/search-panel.tsx rename to ee/tabby-ui/app/files/lib/editor-search-extension/search-panel.tsx diff --git a/ee/tabby-ui/app/files/components/editor-search-extension/search.tsx b/ee/tabby-ui/app/files/lib/editor-search-extension/search.tsx similarity index 100% rename from ee/tabby-ui/app/files/components/editor-search-extension/search.tsx rename to ee/tabby-ui/app/files/lib/editor-search-extension/search.tsx diff --git a/ee/tabby-ui/app/files/lib/event-emitter.ts b/ee/tabby-ui/app/files/lib/event-emitter.ts index 7812adec51f1..b30c243bfe1c 100644 --- a/ee/tabby-ui/app/files/lib/event-emitter.ts +++ b/ee/tabby-ui/app/files/lib/event-emitter.ts @@ -1,33 +1,20 @@ import mitt from 'mitt' +import { ChatCommand, EditorFileContext } from 'tabby-chat-panel/index' -type CodeBrowserQuickAction = 'explain' | 'generate_unittest' | 'generate_doc' -type LineMenuAction = 'copy_line' | 'copy_permalink' - -type QuickActionEventPayload = { - action: CodeBrowserQuickAction - code: string - language?: string - path: string - lineFrom: number - lineTo?: number - gitUrl: string -} +type LineMenuAction = 'copy-line' | 'copy-permalink' type LineMenuActionEventPayload = { action: LineMenuAction } -type CodeBrowserQuickActionEvents = { - code_browser_quick_action: QuickActionEventPayload +type SelectionChangeEventPayload = EditorFileContext | null + +type CodeBrowserEvents = { + quick_action_command: ChatCommand line_menu_action: LineMenuActionEventPayload + selection_change: SelectionChangeEventPayload } -const emitter = mitt() +export const emitter = mitt() -export type { - CodeBrowserQuickAction, - QuickActionEventPayload, - LineMenuAction, - LineMenuActionEventPayload -} -export { emitter } +export type { LineMenuAction, LineMenuActionEventPayload } diff --git a/ee/tabby-ui/app/files/components/line-menu-extension/line-menu-extension.tsx b/ee/tabby-ui/app/files/lib/line-menu-extension/line-menu-extension.tsx similarity index 99% rename from ee/tabby-ui/app/files/components/line-menu-extension/line-menu-extension.tsx rename to ee/tabby-ui/app/files/lib/line-menu-extension/line-menu-extension.tsx index 27294e7ecdf8..31e72ea519d4 100644 --- a/ee/tabby-ui/app/files/components/line-menu-extension/line-menu-extension.tsx +++ b/ee/tabby-ui/app/files/lib/line-menu-extension/line-menu-extension.tsx @@ -67,7 +67,7 @@ const selectedLinesField = StateField.define({ const LineMenuButton = ({ isMulti }: { isMulti?: boolean }) => { const onCopyLines = () => { emitter.emit('line_menu_action', { - action: 'copy_line' + action: 'copy-line' }) } @@ -86,7 +86,7 @@ const LineMenuButton = ({ isMulti }: { isMulti?: boolean }) => { className="cursor-pointer" onSelect={e => { emitter.emit('line_menu_action', { - action: 'copy_permalink' + action: 'copy-permalink' }) }} > diff --git a/ee/tabby-ui/app/files/components/line-menu-extension/line-menu.css b/ee/tabby-ui/app/files/lib/line-menu-extension/line-menu.css similarity index 100% rename from ee/tabby-ui/app/files/components/line-menu-extension/line-menu.css rename to ee/tabby-ui/app/files/lib/line-menu-extension/line-menu.css diff --git a/ee/tabby-ui/app/files/components/search-match-extension/index.ts b/ee/tabby-ui/app/files/lib/search-match-extension/index.ts similarity index 100% rename from ee/tabby-ui/app/files/components/search-match-extension/index.ts rename to ee/tabby-ui/app/files/lib/search-match-extension/index.ts diff --git a/ee/tabby-ui/app/files/components/search-match-extension/style.css b/ee/tabby-ui/app/files/lib/search-match-extension/style.css similarity index 100% rename from ee/tabby-ui/app/files/components/search-match-extension/style.css rename to ee/tabby-ui/app/files/lib/search-match-extension/style.css diff --git a/ee/tabby-ui/app/files/lib/selection-extension/index.ts b/ee/tabby-ui/app/files/lib/selection-extension/index.ts new file mode 100644 index 000000000000..0fc0f48e52b6 --- /dev/null +++ b/ee/tabby-ui/app/files/lib/selection-extension/index.ts @@ -0,0 +1,57 @@ +import { Extension } from '@codemirror/state' +import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' + +interface SelectionContext { + content: string + startLine: number + endLine: number +} + +export function SelectionChangeExtension( + onSelectionChange: (fileContext: SelectionContext | null) => void +): Extension { + return [ + ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + this.handleSelectionChange(view) + } + + update(update: ViewUpdate) { + if (update.selectionSet) { + this.handleSelectionChange(update.view) + } else if (update.focusChanged && !update.view.hasFocus) { + // ignore changes if the view has lost focus + return + } else { + onSelectionChange(null) + } + if (update.selectionSet) { + this.handleSelectionChange(update.view) + } else if (!update.focusChanged || update.view.hasFocus) { + onSelectionChange(null) + } + } + + handleSelectionChange(view: EditorView) { + const data = getActiveSelection(view) + onSelectionChange(data) + } + } + ) + ] +} + +export function getActiveSelection(view: EditorView): SelectionContext | null { + const selection = view.state.selection.main + if (selection.empty) return null + + const content = view.state.sliceDoc(selection.from, selection.to) + const startLine = view.state.doc.lineAt(selection.from).number + const endLine = view.state.doc.lineAt(selection.to).number + return { + content, + startLine, + endLine + } +} diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index aa58e314fcb3..210177f48ef8 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -3,6 +3,7 @@ import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es' import type { ChatCommand, EditorContext, + EditorFileContext, FileLocation, GitRepository, LookupSymbolHint, @@ -119,6 +120,7 @@ interface ChatProps extends React.ComponentProps<'div'> { chatInputRef: RefObject supportsOnApplyInEditorV2: boolean readWorkspaceGitRepositories?: () => Promise + getActiveEditorSelection?: () => Promise } function ChatRenderer( @@ -141,10 +143,12 @@ function ChatRenderer( openExternal, chatInputRef, supportsOnApplyInEditorV2, - readWorkspaceGitRepositories + readWorkspaceGitRepositories, + getActiveEditorSelection }: ChatProps, ref: React.ForwardedRef ) { + const [isDataSetup, setIsDataSetup] = React.useState(false) const [initialized, setInitialized] = React.useState(false) const [threadId, setThreadId] = React.useState() const isOnLoadExecuted = React.useRef(false) @@ -532,10 +536,18 @@ function ChatRenderer( } } + const initActiveEditorSelection = async () => { + return getActiveEditorSelection?.() + } + React.useEffect(() => { const init = async () => { - const workspaceGitRepositories = await fetchWorkspaceGitRepo() - // get default repo + const [workspaceGitRepositories, activeEditorSelecition] = + await Promise.all([ + fetchWorkspaceGitRepo(), + initActiveEditorSelection() + ]) + // get default repository if (workspaceGitRepositories?.length && repos?.length) { const defaultGitUrl = workspaceGitRepositories[0].url const repo = findClosestGitRepository( @@ -547,19 +559,26 @@ function ChatRenderer( } } - setInitialized(true) + // update active selection + if (activeEditorSelecition) { + const context = convertEditorContext(activeEditorSelecition) + setActiveSelection(context) + } } - if (!fetchingRepos && !initialized) { - init() + if (!fetchingRepos && !isDataSetup) { + init().finally(() => { + setIsDataSetup(true) + }) } - }, [fetchingRepos]) + }, [fetchingRepos, isDataSetup]) React.useEffect(() => { - if (initialized) { + if (isDataSetup) { onLoaded?.() + setInitialized(true) } - }, [initialized]) + }, [isDataSetup]) React.useImperativeHandle( ref, @@ -609,21 +628,23 @@ function ChatRenderer( className={`w-full px-4 md:pl-10 md:pr-[3.75rem] ${chatMaxWidthClass}`} > {/* FIXME: pb-[200px] might not enough when adding a large number of relevantContext */} -
- {qaPairs?.length ? ( - - ) : ( - - )} - -
+ {initialized && ( +
+ {qaPairs?.length ? ( + + ) : ( + + )} + +
+ )}