diff --git a/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget-extension.tsx b/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget-extension.tsx new file mode 100644 index 000000000000..aa7140cb4319 --- /dev/null +++ b/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget-extension.tsx @@ -0,0 +1,73 @@ +import type { EditorState, Extension, Transaction } from '@codemirror/state' +import { StateField } from '@codemirror/state' +import type { Tooltip } from '@codemirror/view' +import { showTooltip } from '@codemirror/view' +import ReactDOM from 'react-dom/client' + +import { ActionBarWidget } from './action-bar-widget' + +let delayTimer: number + +function ActionBarWidgetExtension(): Extension { + return StateField.define({ + create() { + return null + }, + update(value, transaction) { + if (transaction.newSelection.main.empty) { + clearTimeout(delayTimer) + return null + } + if (transaction.selection) { + if (shouldShowActionBarWidget(transaction)) { + const tooltip = createActionBarWidget(transaction.state) + // avoid flickering + // return tooltip?.pos !== value?.pos ? tooltip : value + return tooltip + } + + clearTimeout(delayTimer) + return null + } + return value + }, + provide: field => showTooltip.compute([field], state => state.field(field)) + }) +} + +function createActionBarWidget(state: EditorState): Tooltip { + const { selection } = state + const lineFrom = state.doc.lineAt(selection.main.from) + const lineTo = state.doc.lineAt(selection.main.to) + const isMultiline = lineFrom.number !== lineTo.number + const pos = isMultiline ? lineTo.from : selection.main.from + + return { + pos, + above: false, + strictSide: true, + arrow: false, + create() { + const dom = document.createElement('div') + dom.style.background = 'transparent' + dom.style.border = 'none' + const root = ReactDOM.createRoot(dom) + dom.onclick = e => e.stopImmediatePropagation() + // delay popup + if (delayTimer) clearTimeout(delayTimer) + delayTimer = window.setTimeout(() => { + root.render() + }, 1000) + + return { dom } + } + } +} + +function shouldShowActionBarWidget(transaction: Transaction): boolean { + const isTextSelected = + !!transaction.selection && !transaction.selection.main.empty + return isTextSelected && transaction.isUserEvent('select') +} + +export { ActionBarWidgetExtension } diff --git a/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx b/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx new file mode 100644 index 000000000000..ddffc3d65939 --- /dev/null +++ b/ee/tabby-ui/app/files/components/action-bar-widget/action-bar-widget.tsx @@ -0,0 +1,66 @@ +import Image from 'next/image' +import tabbyLogo from '@/assets/tabby.png' + +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { IconChevronUpDown } from '@/components/ui/icons' + +import { CodeBrowserQuickAction, emitter } from '../../lib/event-emitter' + +interface ActionBarWidgetProps extends React.HTMLAttributes {} + +export const ActionBarWidget: React.FC = ({ + className, + ...props +}) => { + const handleAction = (action: CodeBrowserQuickAction) => { + emitter.emit('code_browser_quick_action', action) + } + + return ( +
+ logo + + + + + + + handleAction('generate_unittest')} + > + Unit Test + + handleAction('generate_doc')} + > + Documentation + + + +
+ ) +} 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 20d4f3f267ac..854a8b512204 100644 --- a/ee/tabby-ui/app/files/components/code-editor-view.tsx +++ b/ee/tabby-ui/app/files/components/code-editor-view.tsx @@ -1,14 +1,21 @@ import React from 'react' import { Extension } from '@codemirror/state' -import { EditorView } from '@codemirror/view' +import { drawSelection, EditorView } from '@codemirror/view' import { useTheme } from 'next-themes' +import { EXP_enable_code_browser_quick_action_bar } from '@/lib/experiment-flags' +import { useIsChatEnabled } from '@/lib/hooks/use-server-info' import { TFileMeta } from '@/lib/types' -import CodeEditor from '@/components/codemirror/codemirror' +import CodeEditor, { + CodeMirrorEditorRef +} from '@/components/codemirror/codemirror' import { markTagNameExtension } from '@/components/codemirror/name-tag-extension' import { highlightTagExtension } from '@/components/codemirror/tag-range-highlight-extension' import { codeTagHoverTooltip } from '@/components/codemirror/tooltip-extesion' +import { CodeBrowserQuickAction, emitter } from '../lib/event-emitter' +import { ActionBarWidgetExtension } from './action-bar-widget/action-bar-widget-extension' + interface CodeEditorViewProps { value: string meta?: TFileMeta @@ -22,6 +29,9 @@ const CodeEditorView: React.FC = ({ }) => { const { theme } = useTheme() const tags = meta?.tags + const editorRef = React.useRef(null) + const isChatEnabled = useIsChatEnabled() + const extensions = React.useMemo(() => { let result: Extension[] = [ EditorView.baseTheme({ @@ -36,8 +46,12 @@ const CodeEditorView: React.FC = ({ backgroundColor: 'transparent', borderRight: 'none' } - }) + }), + drawSelection() ] + if (EXP_enable_code_browser_quick_action_bar.value && isChatEnabled) { + result.push(ActionBarWidgetExtension()) + } if (value && tags) { result.push( markTagNameExtension(tags), @@ -46,7 +60,47 @@ const CodeEditorView: React.FC = ({ ) } return result - }, [value, tags]) + }, [value, tags, editorRef.current]) + + React.useEffect(() => { + const quickActionBarCallback = (action: CodeBrowserQuickAction) => { + let builtInPrompt = '' + switch (action) { + case 'explain': + builtInPrompt = 'Explain the following code:' + break + case 'generate_unittest': + builtInPrompt = 'Generate a unit test for the following code:' + break + case 'generate_doc': + builtInPrompt = 'Generate documentation for the following code:' + break + default: + break + } + const view = editorRef.current?.editorView + const text = + view?.state.doc.sliceString( + view?.state.selection.main.from, + view?.state.selection.main.to + ) || '' + + const initialMessage = `${builtInPrompt}\n${'```'}${ + language ?? '' + }\n${text}\n${'```'}\n` + if (initialMessage) { + window.open( + `/playground?initialMessage=${encodeURIComponent(initialMessage)}` + ) + } + } + + emitter.on('code_browser_quick_action', quickActionBarCallback) + + return () => { + emitter.off('code_browser_quick_action', quickActionBarCallback) + } + }, []) return ( = ({ language={language} readonly extensions={extensions} + ref={editorRef} /> ) } diff --git a/ee/tabby-ui/app/files/components/text-file-view.tsx b/ee/tabby-ui/app/files/components/text-file-view.tsx index 3befd9fa5f3e..385d85957937 100644 --- a/ee/tabby-ui/app/files/components/text-file-view.tsx +++ b/ee/tabby-ui/app/files/components/text-file-view.tsx @@ -86,7 +86,7 @@ export const TextFileView: React.FC = ({ {showMarkdown ? ( ) : ( - + )} diff --git a/ee/tabby-ui/app/files/lib/event-emitter.ts b/ee/tabby-ui/app/files/lib/event-emitter.ts new file mode 100644 index 000000000000..be7fde5bc1ca --- /dev/null +++ b/ee/tabby-ui/app/files/lib/event-emitter.ts @@ -0,0 +1,12 @@ +import mitt from 'mitt' + +type CodeBrowserQuickAction = 'explain' | 'generate_unittest' | 'generate_doc' + +type CodeBrowserQuickActionEvents = { + code_browser_quick_action: CodeBrowserQuickAction +} + +const emitter = mitt() + +export type { CodeBrowserQuickAction } +export { emitter } diff --git a/ee/tabby-ui/assets/tabby.png b/ee/tabby-ui/assets/tabby.png index 4ee13a44421e..450b6618082a 100644 Binary files a/ee/tabby-ui/assets/tabby.png and b/ee/tabby-ui/assets/tabby.png differ diff --git a/ee/tabby-ui/components/chat-message.tsx b/ee/tabby-ui/components/chat-message.tsx index 25ae7f0ee7fa..dad65fd580c0 100644 --- a/ee/tabby-ui/components/chat-message.tsx +++ b/ee/tabby-ui/components/chat-message.tsx @@ -93,7 +93,7 @@ export function ChatMessage({ function IconTabby() { return ( tabby() -const CodeMirrorEditor: React.FC = ({ - value, - theme, - language, - readonly = true, - extensions: propsExtensions, - height = null, - width = null -}) => { - const ref = React.useRef(null) - const editor = React.useRef(null) +const CodeMirrorEditor = React.forwardRef< + CodeMirrorEditorRef, + CodeMirrorEditorProps +>((props, ref) => { + const { + value, + theme, + language, + readonly = true, + extensions: propsExtensions, + height = null, + width = null + } = props + + const initialized = React.useRef(false) + const containerRef = React.useRef(null) + const [editorView, setEditorView] = React.useState(null) const defaultThemeOption = EditorView.theme({ '&': { @@ -54,8 +64,15 @@ const CodeMirrorEditor: React.FC = ({ }, '& .cm-gutters': { background: 'hsl(var(--background))' + }, + '&.cm-focused .cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'hsl(var(--cm-selection-bg)) !important' + }, + '.cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'hsl(var(--cm-selection-bg)) !important' } }) + const extensions = [ defaultThemeOption, basicSetup, @@ -80,32 +97,30 @@ const CodeMirrorEditor: React.FC = ({ React.useEffect(() => { const initEditor = () => { - if (ref.current) { + if (initialized.current) return + + if (containerRef.current) { + initialized.current = true let startState = EditorState.create({ doc: value, - extensions + extensions: getExtensions() }) - editor.current = new EditorView({ + const _view = new EditorView({ state: startState, - parent: ref.current + parent: containerRef.current }) + setEditorView(_view) } } initEditor() - - return () => { - if (editor.current) { - editor.current.destroy() - } - } }, []) // refresh extension React.useEffect(() => { - if (editor.current) { - editor.current.dispatch({ + if (editorView) { + editorView.dispatch({ effects: StateEffect.reconfigure.of(getExtensions()) }) } @@ -113,14 +128,11 @@ const CodeMirrorEditor: React.FC = ({ React.useEffect(() => { const resetValue = () => { - if (value === undefined || !editor.current) { - return - } - const currentValue = editor.current - ? editor.current.state.doc.toString() - : '' - if (editor.current && value !== currentValue) { - editor.current.dispatch({ + if (value === undefined || !editorView) return + + const currentValue = editorView ? editorView.state.doc.toString() : '' + if (editorView && value !== currentValue) { + editorView.dispatch({ changes: { from: 0, to: currentValue.length, insert: value || '' }, annotations: [External.of(true)] }) @@ -130,8 +142,28 @@ const CodeMirrorEditor: React.FC = ({ resetValue() }, [value]) - return
-} + React.useEffect( + () => () => { + if (editorView) { + editorView.destroy() + setEditorView(null) + } + }, + [editorView] + ) + + React.useImperativeHandle( + ref, + () => { + return { editorView } + }, + [editorView] + ) + + return
+}) + +CodeMirrorEditor.displayName = 'CodeMirrorEditor' function getLanguage(lang: LanguageName | string, ext?: string) { switch (lang) { @@ -145,10 +177,4 @@ function getLanguage(lang: LanguageName | string, ext?: string) { } } -function SpaceDisplay({ spaceLength }: { spaceLength: number }) { - const spaces = Array(spaceLength).fill(' ').join('') - - return

-} - export default CodeMirrorEditor diff --git a/ee/tabby-ui/components/codemirror/style.css b/ee/tabby-ui/components/codemirror/style.css index 4795bec04d3f..e44a20d68f4f 100644 --- a/ee/tabby-ui/components/codemirror/style.css +++ b/ee/tabby-ui/components/codemirror/style.css @@ -9,6 +9,7 @@ --tag-blue-border: 211.7, 96.36%, 78.43%; /* --tag-blue-text: 29 78 216; */ --tag-blue-text: 224.28, 76.33%, 48.04%; + --cm-selection-bg: 214, 81%, 85%; } @@ -19,5 +20,6 @@ --tag-blue-border: 217.22, 91.22%, 59.8%; /* --tag-blue-text: 96 165 250; */ --tag-blue-text: 213.12, 93.9%, 67.84%; + --cm-selection-bg: 216, 29%, 35%; } } \ No newline at end of file diff --git a/ee/tabby-ui/package.json b/ee/tabby-ui/package.json index ad594ee0bb5d..7b115dae7fb1 100644 --- a/ee/tabby-ui/package.json +++ b/ee/tabby-ui/package.json @@ -60,6 +60,7 @@ "humanize-duration": "^3.31.0", "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", + "mitt": "^3.0.1", "moment": "^2.29.4", "nanoid": "^4.0.2", "next": "^13.4.7", diff --git a/ee/tabby-ui/yarn.lock b/ee/tabby-ui/yarn.lock index 0f2e6ac5b026..ae6c7aeaaa6a 100644 --- a/ee/tabby-ui/yarn.lock +++ b/ee/tabby-ui/yarn.lock @@ -6141,6 +6141,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + moment@^2.29.4: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"