From 60024ea276c08ff3d9ff10135702939ca5eb3781 Mon Sep 17 00:00:00 2001 From: JinmingYang <2214962083@qq.com> Date: Tue, 10 Dec 2024 16:40:29 +0800 Subject: [PATCH] feat: add doc setting for mention --- .../base/client/client-plugin-context.ts | 75 -------- .../base/client/client-plugin-context.tsx | 117 +++++++++++++ .../base/client/client-plugin-registry.ts | 143 ---------------- .../base/client/client-plugin-types.ts | 30 ++++ .../base/client/create-client-plugins.ts | 12 +- .../base/client/create-provider-manager.ts | 27 --- .../plugins/base/client/use-client-plugin.ts | 53 ++++++ .../plugins/base/deep-merge-providers.ts | 61 +++++-- src/shared/plugins/base/provider-manager.ts | 38 +++-- .../doc-plugin/client/doc-client-plugin.tsx | 64 +++---- .../doc-plugin/client/doc-log-preview.tsx | 6 +- .../fs-plugin/client/fs-client-plugin.tsx | 160 +++++++----------- .../fs-plugin/client/fs-log-preview.tsx | 6 +- .../fs-plugin/client/mention-file-preview.tsx | 11 +- .../client/mention-folder-preview.tsx | 11 +- .../fs-plugin/client/mention-tree-preview.tsx | 8 +- .../git-plugin/client/git-client-plugin.tsx | 55 +++--- .../client/mention-terminal-preview.tsx | 6 +- .../client/terminal-client-plugin.tsx | 55 +++--- .../web-plugin/client/web-client-plugin.tsx | 52 +++--- .../web-plugin/client/web-log-preview.tsx | 6 +- .../components/chat/editor/chat-input.tsx | 24 ++- .../chat/messages/roles/chat-ai-message.tsx | 4 +- .../chat/selectors/context-selector.tsx | 4 +- .../mention-selector/mention-selector.tsx | 8 +- .../chat/selectors/model-selector.tsx | 2 +- src/webview/contexts/plugin-context.tsx | 42 +++++ .../contexts/plugin-registry-context.tsx | 119 ------------- .../hooks/chat/use-files-tree-items.ts | 1 + .../hooks/chat/use-mention-options.tsx | 19 --- .../hooks/chat/use-plugin-providers.tsx | 97 +++++------ src/webview/hooks/use-shiki-highlighter.ts | 1 + src/webview/lexical/nodes/mention-node.tsx | 4 +- .../lexical/plugins/mention-plugin.tsx | 6 +- 34 files changed, 566 insertions(+), 761 deletions(-) delete mode 100644 src/shared/plugins/base/client/client-plugin-context.ts create mode 100644 src/shared/plugins/base/client/client-plugin-context.tsx delete mode 100644 src/shared/plugins/base/client/client-plugin-registry.ts create mode 100644 src/shared/plugins/base/client/client-plugin-types.ts delete mode 100644 src/shared/plugins/base/client/create-provider-manager.ts create mode 100644 src/shared/plugins/base/client/use-client-plugin.ts create mode 100644 src/webview/contexts/plugin-context.tsx delete mode 100644 src/webview/contexts/plugin-registry-context.tsx delete mode 100644 src/webview/hooks/chat/use-mention-options.tsx diff --git a/src/shared/plugins/base/client/client-plugin-context.ts b/src/shared/plugins/base/client/client-plugin-context.ts deleted file mode 100644 index 711e3e7..0000000 --- a/src/shared/plugins/base/client/client-plugin-context.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { PluginId, PluginState, ValidRecipeReturnType } from '../types' -import type { ClientPluginRegistry } from './client-plugin-registry' - -export interface ClientPlugin { - id: PluginId - version: string - dependencies?: PluginId[] - getInitState(): State - activate(context: ClientPluginContext): Promise - deactivate?(): void - migrate?(oldState: any): State // TODO -} - -interface ClientPluginContextOptions { - pluginId: PluginId - registry: ClientPluginRegistry -} - -export class ClientPluginContext { - private pluginId: PluginId - - private registry: ClientPluginRegistry - - constructor(options: ClientPluginContextOptions) { - const { pluginId, registry } = options - this.pluginId = pluginId - this.registry = registry - - if (!this.registry.isInitialized) - throw new Error('ClientPluginRegistry is not initialized') - } - - get state(): Readonly { - return this.registry.getState(this.pluginId) as Readonly - } - - getState(): Readonly { - return this.registry.getState(this.pluginId) as Readonly - } - - setState( - updater: State | ((draft: State) => ValidRecipeReturnType) - ): void { - this.registry.setState(this.pluginId, updater) - } - - resetState(): void { - const plugin = this.registry.getPlugin(this.pluginId) - - if (!plugin) return - - this.registry.setState(this.pluginId, plugin.getInitState()) - } - - getQueryClient() { - return this?.registry?.queryClient - } - - registerCommand(command: string, callback: (...args: any[]) => void): void { - this.registry.registerCommand(command, callback) - } - - executeCommand(command: string, ...args: any[]): void { - this.registry.executeCommand(command, ...args) - } - - registerProvider( - key: K, - provider: Parameters< - ClientPluginRegistry['providerManagers'][K]['register'] - >[1] - ): void { - this.registry.providerManagers[key].register(this.pluginId, provider as any) - } -} diff --git a/src/shared/plugins/base/client/client-plugin-context.tsx b/src/shared/plugins/base/client/client-plugin-context.tsx new file mode 100644 index 0000000..7281bee --- /dev/null +++ b/src/shared/plugins/base/client/client-plugin-context.tsx @@ -0,0 +1,117 @@ +import React, { createContext, useContext, useRef } from 'react' +import { useCallbackRef } from '@webview/hooks/use-callback-ref' +import { useImmer } from 'use-immer' + +import { ProviderUtils } from '../provider-manager' +import { PluginId, PluginState } from '../types' +import type { ClientPluginProviderMap } from './client-plugin-types' + +type ProviderKey = keyof ClientPluginProviderMap + +type IdProviderMap = Record< + PluginId, + () => ClientPluginProviderMap[ProviderKey] +> + +export interface ClientPluginContextValue { + state: Record + getState: () => Record + setState: ( + pluginId: PluginId, + updater: PluginState | ((draft: PluginState) => void) + ) => void + registerProvider: ( + pluginId: PluginId, + providerKey: K, + provider: () => ClientPluginProviderMap[K] + ) => void + getProviders: ( + providerKey: K + ) => ClientPluginProviderMap[K][] + mergeProviders: ( + providerKey: K + ) => ClientPluginProviderMap[K] | undefined +} + +const ClientPluginContext = createContext(null) + +export const ClientPluginProvider: React.FC = ({ + children +}) => { + const [state, setState] = useImmer>( + {} as Record + ) + const providerKeyInfoMapRef = useRef({} as Record) + + const setPluginState = ( + pluginId: PluginId, + updater: PluginState | ((draft: PluginState) => void) + ) => { + setState(draft => { + if (!draft[pluginId]) { + draft[pluginId] = {} + } + if (typeof updater === 'function') { + updater(draft[pluginId]) + } else { + draft[pluginId] = updater + } + }) + } + + const registerProvider = ( + pluginId: PluginId, + providerKey: K, + provider: () => ClientPluginProviderMap[K] + ) => { + if (!providerKeyInfoMapRef.current[providerKey]) { + providerKeyInfoMapRef.current[providerKey] = {} as IdProviderMap + } + providerKeyInfoMapRef.current[providerKey]![pluginId] = provider + } + + const getProviders = ( + providerKey: K + ): ClientPluginProviderMap[K][] => { + const idProviderMap = (providerKeyInfoMapRef.current[providerKey] || + {}) as Record ClientPluginProviderMap[K]> + + return ProviderUtils.getValues(idProviderMap) + } + + const mergeProviders = ( + providerKey: K + ): ClientPluginProviderMap[K] | undefined => { + const idProviderMap = (providerKeyInfoMapRef.current[providerKey] || + {}) as Record ClientPluginProviderMap[K]> + + const result = ProviderUtils.mergeAll(idProviderMap) + + return result + } + + const getState = useCallbackRef(() => state) + + return ( + + {children} + + ) +} + +export const usePlugin = () => { + const context = useContext(ClientPluginContext) + if (!context) { + throw new Error('usePlugin must be used within ClientPluginProvider') + } + return context +} diff --git a/src/shared/plugins/base/client/client-plugin-registry.ts b/src/shared/plugins/base/client/client-plugin-registry.ts deleted file mode 100644 index 690ac16..0000000 --- a/src/shared/plugins/base/client/client-plugin-registry.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { QueryClient } from '@tanstack/react-query' -import { logger } from '@webview/utils/logger' -import type { DraftFunction, Updater } from 'use-immer' - -import type { PluginId, PluginState } from '../types' -import { ClientPluginContext, type ClientPlugin } from './client-plugin-context' -import { createProviderManagers } from './create-provider-manager' - -interface ClientPluginRegistryInitOptions { - queryClient: QueryClient - // getState: () => Record - state: Record - setState: Updater> -} - -export class ClientPluginRegistry { - private plugins: Map = new Map() - - private pluginContexts: Map = new Map() - - private commands: Map void> = new Map() - - queryClient!: QueryClient - - providerManagers = createProviderManagers() - - isInitialized: boolean = false - - private state!: Record - - getState!: (pluginId: PluginId) => State - - setState!: ( - pluginId: PluginId, - updater: State | DraftFunction - ) => void - - init(options: ClientPluginRegistryInitOptions): void { - this.queryClient = options.queryClient - this.state = options.state - - this.getState = (pluginId: PluginId) => - (this.state[pluginId] || {}) as State - - this.setState = ( - pluginId: PluginId, - updater: PluginState | DraftFunction - ) => { - options.setState(draft => { - if (!draft[pluginId]) { - draft[pluginId] = {} - } - - if (typeof updater === 'function') { - updater(draft[pluginId]) - } else { - draft[pluginId] = updater - } - }) - } - this.isInitialized = true - } - - private checkDependencies(plugin: ClientPlugin): boolean { - return ( - !plugin.dependencies || - plugin.dependencies.every(depId => this.plugins.has(depId)) - ) - } - - private handleError(error: Error, pluginId: PluginId | null): void { - logger.error(`Error in plugin ${pluginId}:`, error) - } - - usePluginState(pluginId: PluginId) { - const state = this.getState(pluginId) - const setState = (updater: DraftFunction) => - this.setState(pluginId, updater) - - const resetState = () => { - const plugin = this.plugins.get(pluginId) - if (plugin) this.setState(pluginId, plugin.getInitState()) - } - - return [state, setState, resetState] as const - } - - registerCommand(command: string, callback: (...args: any[]) => void): void { - this.commands.set(command, callback) - } - - executeCommand(command: string, ...args: any[]): void { - const callback = this.commands.get(command) - if (callback) callback(...args) - } - - getPlugin(pluginId: PluginId): T | undefined { - return this.plugins.get(pluginId) as T - } - - async loadPlugin( - _plugin: ClientPlugin - ): Promise { - let currentPluginId: PluginId | null = null - - try { - const plugin = _plugin as ClientPlugin - currentPluginId = plugin.id - - if (!this.checkDependencies(plugin)) - throw new Error(`Dependencies not met for plugin ${currentPluginId}`) - - this.plugins.set(currentPluginId, plugin) - const context = new ClientPluginContext({ - registry: this, - pluginId: currentPluginId - }) - this.pluginContexts.set(currentPluginId, context) - await plugin.activate(context) - } catch (error: any) { - this.handleError(error, currentPluginId) - } finally { - currentPluginId = null - } - } - - async unloadPlugin(pluginId: PluginId): Promise { - const plugin = this.plugins.get(pluginId) - if (plugin?.deactivate) { - await plugin.deactivate() - } - this.plugins.delete(pluginId) - Object.values(this.providerManagers).forEach(manager => - manager.unregister(pluginId) - ) - } - - async unloadAllPlugins(): Promise { - for (const pluginId of this.plugins.keys()) { - await this.unloadPlugin(pluginId) - } - } -} diff --git a/src/shared/plugins/base/client/client-plugin-types.ts b/src/shared/plugins/base/client/client-plugin-types.ts new file mode 100644 index 0000000..9d3f634 --- /dev/null +++ b/src/shared/plugins/base/client/client-plugin-types.ts @@ -0,0 +1,30 @@ +import type { FC } from 'react' +import type { BaseConversationLog, ConversationLog } from '@shared/entities' +import type { ImageInfo } from '@shared/plugins/fs-plugin/types' +import type { FileInfo, MentionOption } from '@webview/types/chat' + +export type UseMentionOptionsReturns = MentionOption[] + +export type UseSelectedFilesReturns = { + selectedFiles: FileInfo[] + setSelectedFiles: (files: FileInfo[]) => void +} + +export type UseSelectedImagesReturns = { + selectedImages: ImageInfo[] + addSelectedImage: (image: ImageInfo) => void + removeSelectedImage: (image: ImageInfo) => void +} + +export type CustomRenderLogPreviewProps< + T extends BaseConversationLog = ConversationLog +> = { + log: T +} + +export type ClientPluginProviderMap = { + useMentionOptions: () => UseMentionOptionsReturns + useSelectedFiles: () => UseSelectedFilesReturns + useSelectedImages: () => UseSelectedImagesReturns + CustomRenderLogPreview: FC +} diff --git a/src/shared/plugins/base/client/create-client-plugins.ts b/src/shared/plugins/base/client/create-client-plugins.ts index 3075d39..3ec5613 100644 --- a/src/shared/plugins/base/client/create-client-plugins.ts +++ b/src/shared/plugins/base/client/create-client-plugins.ts @@ -4,15 +4,15 @@ import { GitClientPlugin } from '@shared/plugins/git-plugin/client/git-client-pl import { TerminalClientPlugin } from '@shared/plugins/terminal-plugin/client/terminal-client-plugin' import { WebClientPlugin } from '@shared/plugins/web-plugin/client/web-client-plugin' -import type { ClientPlugin } from './client-plugin-context' +import type { ClientPlugin } from './use-client-plugin' export const createClientPlugins = (): ClientPlugin[] => { const plugins: ClientPlugin[] = [ - new FsClientPlugin(), - new DocClientPlugin(), - new WebClientPlugin(), - new GitClientPlugin(), - new TerminalClientPlugin() + FsClientPlugin, + DocClientPlugin, + WebClientPlugin, + GitClientPlugin, + TerminalClientPlugin ] return plugins diff --git a/src/shared/plugins/base/client/create-provider-manager.ts b/src/shared/plugins/base/client/create-provider-manager.ts deleted file mode 100644 index ca14524..0000000 --- a/src/shared/plugins/base/client/create-provider-manager.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FC } from 'react' -import type { ConversationLog } from '@shared/entities' -import type { ImageInfo } from '@shared/plugins/fs-plugin/types' -import type { FileInfo, MentionOption } from '@webview/types/chat' - -import { ProviderManager } from '../provider-manager' -import type { PluginState } from '../types' - -export const createProviderManagers = () => - ({ - state: new ProviderManager(), - editor: new ProviderManager<{ - getMentionOptions: () => Promise - }>(), - filesSelector: new ProviderManager<{ - getSelectedFiles: () => FileInfo[] - setSelectedFiles: (files: FileInfo[]) => void - }>(), - imagesSelector: new ProviderManager<{ - getSelectedImages: () => ImageInfo[] - addSelectedImage: (image: ImageInfo) => void - removeSelectedImage: (image: ImageInfo) => void - }>(), - message: new ProviderManager<{ - customRenderLogPreview: FC<{ log: ConversationLog }> - }>() - }) as const satisfies Record> diff --git a/src/shared/plugins/base/client/use-client-plugin.ts b/src/shared/plugins/base/client/use-client-plugin.ts new file mode 100644 index 0000000..8b1b612 --- /dev/null +++ b/src/shared/plugins/base/client/use-client-plugin.ts @@ -0,0 +1,53 @@ +import { useEffect } from 'react' + +import { PluginId, PluginState } from '../types' +import { usePlugin } from './client-plugin-context' +import type { ClientPluginProviderMap } from './client-plugin-types' + +export interface ClientPlugin { + id: PluginId + version: string + getInitialState: () => State + usePlugin: () => void +} + +export type SetupProps = { + state: State + setState: (updater: (draft: State) => void) => void + registerProvider: ( + providerKey: K, + provider: () => ClientPluginProviderMap[K] + ) => void +} + +export const createClientPlugin = (options: { + id: PluginId + version: string + getInitialState: () => State + setup: (context: SetupProps) => void +}): ClientPlugin => ({ + id: options.id, + version: options.version, + getInitialState: options.getInitialState, + usePlugin() { + const { state, setState, registerProvider } = usePlugin() + + useEffect(() => { + setState(options.id, options.getInitialState()) + }, []) + + const pluginState = (state[options.id] || + options.getInitialState()) as State + + options.setup({ + state: pluginState, + setState: updater => setState(options.id, updater), + registerProvider: (key, provider) => + registerProvider( + options.id, + key as keyof ClientPluginProviderMap, + provider as () => ClientPluginProviderMap[keyof ClientPluginProviderMap] + ) + }) + } +}) diff --git a/src/shared/plugins/base/deep-merge-providers.ts b/src/shared/plugins/base/deep-merge-providers.ts index 34a1bfc..aaa50bf 100644 --- a/src/shared/plugins/base/deep-merge-providers.ts +++ b/src/shared/plugins/base/deep-merge-providers.ts @@ -8,7 +8,11 @@ const isPlainObject = (item: any): item is object => const isAsyncFunction = (func: Function): boolean => func.constructor.name === 'AsyncFunction' -const mergeFunctions = (func1: Function, func2: Function): Function => { +const mergeFunctions = ( + func1: Function, + func2: Function, + disableMergeChildFunction = true +): Function => { const isAsync1 = isAsyncFunction(func1) const isAsync2 = isAsyncFunction(func2) @@ -20,13 +24,13 @@ const mergeFunctions = (func1: Function, func2: Function): Function => { const result2 = isAsync2 ? await func2.apply(this, args) : func2.apply(this, args) - return deepMergeProviders([result1, result2]) + return deepMergeProviders([result1, result2], disableMergeChildFunction) } } return function (this: any, ...args: any[]) { const result1 = func1.apply(this, args) const result2 = func2.apply(this, args) - return deepMergeProviders([result1, result2]) + return deepMergeProviders([result1, result2], disableMergeChildFunction) } } @@ -52,9 +56,12 @@ const getAllProperties = (obj: any): string[] => { return Object.keys(obj) } -export const deepMergeProviders = (objects: T[]): T => { +export const deepMergeProviders = ( + objects: T[], + disableMergeFunction = false +): T | undefined => { if (objects.length === 0) { - return {} as T + return undefined } if (objects.length === 1) { @@ -62,11 +69,44 @@ export const deepMergeProviders = (objects: T[]): T => { } return objects.reduce((result, obj) => { + if (result === undefined) { + if (isPlainObject(obj)) { + result = {} as T + } else if (Array.isArray(obj)) { + result = [] as T + } else if (typeof obj === 'string') { + result = '' as T + } else if (typeof obj === 'function') { + result = (() => {}) as T + } else { + result = {} as T + } + } + + if ( + typeof result === 'function' && + typeof obj === 'function' && + !disableMergeFunction + ) { + return mergeFunctions(result, obj) as T + } + + if (Array.isArray(result) && Array.isArray(obj)) { + return [...result, ...obj] as unknown as T + } + if (typeof result === 'string' && typeof obj === 'string') { + return (result + obj) as unknown as T + } + if (isPlainObject(result) && isPlainObject(obj)) { getAllProperties(obj).forEach(key => { const value = (obj as any)[key] if (typeof value === 'function') { - if (key in result && typeof (result as any)[key] === 'function') { + if ( + key in result && + typeof (result as any)[key] === 'function' && + !disableMergeFunction + ) { ;(result as any)[key] = mergeFunctions((result as any)[key], value) } else { ;(result as any)[key] = value @@ -98,12 +138,7 @@ export const deepMergeProviders = (objects: T[]): T => { }) return result } - if (Array.isArray(result) && Array.isArray(obj)) { - return [...result, ...obj] as unknown as T - } - if (typeof result === 'string' && typeof obj === 'string') { - return (result + obj) as unknown as T - } + return obj - }, {} as T) + }, undefined as T) } diff --git a/src/shared/plugins/base/provider-manager.ts b/src/shared/plugins/base/provider-manager.ts index 46cc7ed..79c45d6 100644 --- a/src/shared/plugins/base/provider-manager.ts +++ b/src/shared/plugins/base/provider-manager.ts @@ -1,32 +1,34 @@ import { deepMergeProviders } from './deep-merge-providers' -import type { PluginId } from './types' +import { PluginId } from './types' + +export class ProviderUtils { + static getValues = (idProvidersMap: Record T>): T[] => + Object.values(idProvidersMap).map(provider => provider?.()) + + static mergeAll = ( + idProvidersMap: Record T> + ): T | undefined => { + const allValues = ProviderUtils.getValues(idProvidersMap) + return deepMergeProviders(allValues) + } +} export class ProviderManager { - protected providers: Map T> = new Map() + protected idProvidersMap = {} as Record T> register(pluginId: PluginId, provider: () => T): void { - this.providers.set(pluginId, provider) + this.idProvidersMap[pluginId] = provider } unregister(pluginId: PluginId): void { - this.providers.delete(pluginId) - } - - getValues(key: keyof T): T[keyof T][] { - return Array.from(this.providers.values()).map(provider => provider()[key]) + delete this.idProvidersMap[pluginId] } - getAll(): Record> { - const entries = Array.from(this.providers.entries()) - const results = entries.map(([pluginId, provider]) => { - const value = provider() - return [pluginId, value] as [PluginId, T] - }) - return Object.fromEntries(results) as Record + getValues(): T[] { + return ProviderUtils.getValues(this.idProvidersMap) } - mergeAll(): Partial { - const allValues = this.getAll() - return deepMergeProviders(Object.values(allValues)) + mergeAll(): T | undefined { + return ProviderUtils.mergeAll(this.idProvidersMap) } } diff --git a/src/shared/plugins/doc-plugin/client/doc-client-plugin.tsx b/src/shared/plugins/doc-plugin/client/doc-client-plugin.tsx index ecf9276..469e212 100644 --- a/src/shared/plugins/doc-plugin/client/doc-client-plugin.tsx +++ b/src/shared/plugins/doc-plugin/client/doc-client-plugin.tsx @@ -1,54 +1,44 @@ import { GearIcon, IdCardIcon } from '@radix-ui/react-icons' -import type { - ClientPlugin, - ClientPluginContext -} from '@shared/plugins/base/client/client-plugin-context' +import type { UseMentionOptionsReturns } from '@shared/plugins/base/client/client-plugin-types' +import { + createClientPlugin, + type SetupProps +} from '@shared/plugins/base/client/use-client-plugin' import { PluginId } from '@shared/plugins/base/types' import { pkg } from '@shared/utils/pkg' +import { useQuery } from '@tanstack/react-query' import { api } from '@webview/services/api-client' import { type MentionOption } from '@webview/types/chat' +import { useNavigate } from 'react-router' import type { DocPluginState } from '../types' import { DocLogPreview } from './doc-log-preview' -export class DocClientPlugin implements ClientPlugin { - id = PluginId.Doc +export const DocClientPlugin = createClientPlugin({ + id: PluginId.Doc, + version: pkg.version, - version: string = pkg.version - - private context: ClientPluginContext | null = null - - getInitState() { + getInitialState() { return { allowSearchDocSiteNamesFromEditor: [], relevantDocsFromAgent: [] } - } - - async activate(context: ClientPluginContext): Promise { - this.context = context + }, - this.context.registerProvider('state', () => this.context!.state) - this.context.registerProvider('editor', () => ({ - getMentionOptions: this.getMentionOptions.bind(this) - })) + setup(props) { + const { registerProvider } = props - this.context.registerProvider('message', () => ({ - customRenderLogPreview: DocLogPreview - })) + registerProvider('useMentionOptions', () => createUseMentionOptions(props)) + registerProvider('CustomRenderLogPreview', () => DocLogPreview) } +}) - deactivate(): void { - this.context?.resetState() - this.context = null - } - - private async getMentionOptions(): Promise { - const queryClient = this?.context?.getQueryClient?.() +const createUseMentionOptions = + (props: SetupProps) => (): UseMentionOptionsReturns => { + const { setState } = props + const navigate = useNavigate() - if (!queryClient) return [] - - const docSites = await queryClient.fetchQuery({ + const { data: docSites = [] } = useQuery({ queryKey: ['realtime', 'docSites'], queryFn: () => api.doc.getDocSites({}) }) @@ -58,13 +48,13 @@ export class DocClientPlugin implements ClientPlugin { type: `${PluginId.Doc}#doc`, label: 'docs setting', disableAddToEditor: true, - onSelect: data => { - console.log('onSelect', data) + onSelect: () => { + navigate(`/settings?pageId=chatDoc`) }, searchKeywords: ['setting', 'docsetting'], itemLayoutProps: { icon: , - label: 'docs setting', + label: 'Docs setting', details: '' } } @@ -77,11 +67,10 @@ export class DocClientPlugin implements ClientPlugin { label: site.name, data: site.name, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.allowSearchDocSiteNamesFromEditor = dataArr }) }, - searchKeywords: [site.name, site.url], itemLayoutProps: { icon: , @@ -109,4 +98,3 @@ export class DocClientPlugin implements ClientPlugin { } ] } -} diff --git a/src/shared/plugins/doc-plugin/client/doc-log-preview.tsx b/src/shared/plugins/doc-plugin/client/doc-log-preview.tsx index b966549..a7945a4 100644 --- a/src/shared/plugins/doc-plugin/client/doc-log-preview.tsx +++ b/src/shared/plugins/doc-plugin/client/doc-log-preview.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { FileTextIcon } from '@radix-ui/react-icons' -import type { ConversationLog } from '@shared/entities' +import type { CustomRenderLogPreviewProps } from '@shared/plugins/base/client/client-plugin-types' import { PluginId } from '@shared/plugins/base/types' import { ChatLogPreview } from '@webview/components/chat/messages/roles/chat-log-preview' import type { PreviewContent } from '@webview/components/content-preview' @@ -10,9 +10,7 @@ import { cn } from '@webview/utils/common' import type { DocInfo, DocPluginLog } from '../types' -export const DocLogPreview: FC<{ - log: ConversationLog -}> = props => { +export const DocLogPreview: FC = props => { if (props.log.pluginId !== PluginId.Doc) return null const log = props.log as DocPluginLog diff --git a/src/shared/plugins/fs-plugin/client/fs-client-plugin.tsx b/src/shared/plugins/fs-plugin/client/fs-client-plugin.tsx index 32d13e1..1fe85e0 100644 --- a/src/shared/plugins/fs-plugin/client/fs-client-plugin.tsx +++ b/src/shared/plugins/fs-plugin/client/fs-client-plugin.tsx @@ -1,41 +1,38 @@ +import type { FileInfo, FolderInfo } from '@extension/file-utils/traverse-fs' import { CardStackIcon, - ChevronRightIcon, CubeIcon, - ExclamationTriangleIcon, - FileIcon + ExclamationTriangleIcon } from '@radix-ui/react-icons' import type { - ClientPlugin, - ClientPluginContext -} from '@shared/plugins/base/client/client-plugin-context' + UseMentionOptionsReturns, + UseSelectedFilesReturns, + UseSelectedImagesReturns +} from '@shared/plugins/base/client/client-plugin-types' +import { + createClientPlugin, + type SetupProps +} from '@shared/plugins/base/client/use-client-plugin' import { PluginId } from '@shared/plugins/base/types' import { pkg } from '@shared/utils/pkg' +import { useQuery } from '@tanstack/react-query' import { FileIcon as FileIcon2 } from '@webview/components/file-icon' import { api } from '@webview/services/api-client' -import { - SearchSortStrategy, - type FileInfo, - type FolderInfo, - type MentionOption -} from '@webview/types/chat' +import { SearchSortStrategy, type MentionOption } from '@webview/types/chat' import { getFileNameFromPath } from '@webview/utils/path' -import { FolderTreeIcon } from 'lucide-react' +import { ChevronRightIcon, FileIcon, FolderTreeIcon } from 'lucide-react' -import type { FsPluginState, ImageInfo, TreeInfo } from '../types' +import type { FsPluginState, TreeInfo } from '../types' import { FsLogPreview } from './fs-log-preview' import { MentionFilePreview } from './mention-file-preview' import { MentionFolderPreview } from './mention-folder-preview' import { MentionTreePreview } from './mention-tree-preview' -export class FsClientPlugin implements ClientPlugin { - id = PluginId.Fs - - version: string = pkg.version - - private context: ClientPluginContext | null = null +export const FsClientPlugin = createClientPlugin({ + id: PluginId.Fs, + version: pkg.version, - getInitState() { + getInitialState() { return { selectedFilesFromFileSelector: [], selectedFilesFromEditor: [], @@ -49,92 +46,62 @@ export class FsClientPlugin implements ClientPlugin { editorErrors: [], selectedTreesFromEditor: [] } - } - - async activate(context: ClientPluginContext): Promise { - this.context = context - - this.context.registerProvider('state', () => this.context!.state) - this.context.registerProvider('editor', () => ({ - getMentionOptions: this.getMentionOptions.bind(this) - })) - this.context.registerProvider('filesSelector', () => ({ - getSelectedFiles: this.getSelectedFiles.bind(this), - setSelectedFiles: this.setSelectedFiles.bind(this) - })) - this.context.registerProvider('imagesSelector', () => ({ - getSelectedImages: this.getSelectedImages.bind(this), - addSelectedImage: this.addSelectedImage.bind(this), - removeSelectedImage: this.removeSelectedImage.bind(this) - })) - this.context.registerProvider('message', () => ({ - customRenderLogPreview: FsLogPreview - })) - } - - deactivate(): void { - this.context?.resetState() - this.context = null - } - - private getSelectedFiles(): FileInfo[] { - if (!this.context) return [] - - return this.context.state.selectedFilesFromFileSelector - } - - private setSelectedFiles(files: FileInfo[]): void { - if (!this.context) return + }, - this.context.setState(draft => { - draft.selectedFilesFromFileSelector = files - }) - } - - private getSelectedImages(): ImageInfo[] { - if (!this.context) return [] - - return this.context.state.selectedImagesFromOutsideUrl - } - - private addSelectedImage(image: ImageInfo): void { - if (!this.context) return - - this.context.setState(draft => { - draft.selectedImagesFromOutsideUrl.push(image) - }) - } + setup(props) { + const { registerProvider } = props - private removeSelectedImage(image: ImageInfo): void { - if (!this.context) return - - this.context.setState(draft => { - draft.selectedImagesFromOutsideUrl = - draft.selectedImagesFromOutsideUrl.filter(i => i.url !== image.url) - }) + registerProvider('useMentionOptions', () => createUseMentionOptions(props)) + registerProvider('useSelectedFiles', () => createUseSelectedFiles(props)) + registerProvider('useSelectedImages', () => createUseSelectedImages(props)) + registerProvider('CustomRenderLogPreview', () => FsLogPreview) } +}) + +const createUseSelectedFiles = + (props: SetupProps) => (): UseSelectedFilesReturns => ({ + selectedFiles: props.state.selectedFilesFromFileSelector || [], + setSelectedFiles: files => + props.setState(draft => { + draft.selectedFilesFromFileSelector = files + }) + }) + +const createUseSelectedImages = + (props: SetupProps) => (): UseSelectedImagesReturns => ({ + selectedImages: props.state.selectedImagesFromOutsideUrl || [], + addSelectedImage: image => { + props.setState(draft => { + draft.selectedImagesFromOutsideUrl.push(image) + }) + }, + removeSelectedImage: image => { + props.setState(draft => { + draft.selectedImagesFromOutsideUrl = + draft.selectedImagesFromOutsideUrl.filter(i => i.url !== image.url) + }) + } + }) - private async getMentionOptions(): Promise { - const queryClient = this?.context?.getQueryClient?.() - - if (!queryClient) return [] - - const files = await queryClient.fetchQuery({ +const createUseMentionOptions = + (props: SetupProps) => (): UseMentionOptionsReturns => { + const { setState } = props + const { data: files = [] } = useQuery({ queryKey: ['realtime', 'files'], queryFn: () => api.file.traverseWorkspaceFiles({ filesOrFolders: ['./'] }) }) - const folders = await queryClient.fetchQuery({ + const { data: folders = [] } = useQuery({ queryKey: ['realtime', 'folders'], queryFn: () => api.file.traverseWorkspaceFolders({ folders: ['./'] }) }) - const editorErrors = await queryClient.fetchQuery({ + const { data: editorErrors = [] } = useQuery({ queryKey: ['realtime', 'editorErrors'], queryFn: () => api.file.getCurrentEditorErrors({}) }) - const treesInfo = await queryClient.fetchQuery({ + const { data: treesInfo = [] } = useQuery({ queryKey: ['realtime', 'treesInfo'], queryFn: () => api.file.getWorkspaceTreesInfo({ depth: 5 }) }) @@ -148,7 +115,7 @@ export class FsClientPlugin implements ClientPlugin { label, data: file, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.selectedFilesFromEditor = dataArr }) }, @@ -175,7 +142,7 @@ export class FsClientPlugin implements ClientPlugin { label, data: folder, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.selectedFoldersFromEditor = dataArr }) }, @@ -210,7 +177,7 @@ export class FsClientPlugin implements ClientPlugin { label, data: treeInfo, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.selectedTreesFromEditor = dataArr }) }, @@ -279,7 +246,7 @@ export class FsClientPlugin implements ClientPlugin { label: 'Codebase', data: true, onUpdatePluginState: (dataArr: true[]) => { - this.context?.setState(draft => { + setState(draft => { draft.enableCodebaseAgent = dataArr.length > 0 draft.codeSnippetFromAgent = [] }) @@ -297,7 +264,7 @@ export class FsClientPlugin implements ClientPlugin { label: 'Errors', data: editorErrors, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.editorErrors = dataArr?.[0] ?? [] }) }, @@ -317,4 +284,3 @@ export class FsClientPlugin implements ClientPlugin { } ] } -} diff --git a/src/shared/plugins/fs-plugin/client/fs-log-preview.tsx b/src/shared/plugins/fs-plugin/client/fs-log-preview.tsx index 50a477b..03f5937 100644 --- a/src/shared/plugins/fs-plugin/client/fs-log-preview.tsx +++ b/src/shared/plugins/fs-plugin/client/fs-log-preview.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import type { FileInfo } from '@extension/file-utils/traverse-fs' -import type { ConversationLog } from '@shared/entities' +import type { CustomRenderLogPreviewProps } from '@shared/plugins/base/client/client-plugin-types' import { PluginId } from '@shared/plugins/base/types' import { ChatLogPreview } from '@webview/components/chat/messages/roles/chat-log-preview' import { FileIcon } from '@webview/components/file-icon' @@ -11,9 +11,7 @@ import { getFileNameFromPath } from '@webview/utils/path' import type { CodeSnippet, FsPluginLog } from '../types' -export const FsLogPreview: FC<{ - log: ConversationLog -}> = props => { +export const FsLogPreview: FC = props => { if (props.log.pluginId !== PluginId.Fs) return null const log = props.log as FsPluginLog diff --git a/src/shared/plugins/fs-plugin/client/mention-file-preview.tsx b/src/shared/plugins/fs-plugin/client/mention-file-preview.tsx index d12ecab..1bda082 100644 --- a/src/shared/plugins/fs-plugin/client/mention-file-preview.tsx +++ b/src/shared/plugins/fs-plugin/client/mention-file-preview.tsx @@ -5,10 +5,13 @@ import { Tree, type TreeNodeRenderProps } from '@webview/components/tree' import { useFilesTreeItems } from '@webview/hooks/chat/use-files-tree-items' import type { FileInfo, MentionOption } from '@webview/types/chat' -export const MentionFilePreview: React.FC = mentionOption => { - const fileInfo = mentionOption.data as FileInfo +export const MentionFilePreview: React.FC< + MentionOption +> = mentionOption => { + const fileInfo = mentionOption.data + const { treeItems, getAllChildrenIds } = useFilesTreeItems({ - files: [fileInfo] + files: fileInfo ? [fileInfo] : [] }) const allExpandedIds = getAllChildrenIds(treeItems[0]!) @@ -37,6 +40,8 @@ export const MentionFilePreview: React.FC = mentionOption => { ) + if (!fileInfo) return null + return (
diff --git a/src/shared/plugins/fs-plugin/client/mention-folder-preview.tsx b/src/shared/plugins/fs-plugin/client/mention-folder-preview.tsx index 57e3c5e..3e649b7 100644 --- a/src/shared/plugins/fs-plugin/client/mention-folder-preview.tsx +++ b/src/shared/plugins/fs-plugin/client/mention-folder-preview.tsx @@ -5,10 +5,13 @@ import { Tree, type TreeNodeRenderProps } from '@webview/components/tree' import { useFilesTreeItems } from '@webview/hooks/chat/use-files-tree-items' import type { FolderInfo, MentionOption } from '@webview/types/chat' -export const MentionFolderPreview: React.FC = mentionOption => { - const folderInfo = mentionOption.data as FolderInfo +export const MentionFolderPreview: React.FC< + MentionOption +> = mentionOption => { + const folderInfo = mentionOption.data + const { treeItems, traverseTree } = useFilesTreeItems({ - files: [folderInfo] + files: folderInfo ? [folderInfo] : [] }) const allExpandedIds = useMemo(() => { @@ -51,6 +54,8 @@ export const MentionFolderPreview: React.FC = mentionOption => { ) } + if (!folderInfo) return null + return (
diff --git a/src/shared/plugins/fs-plugin/client/mention-tree-preview.tsx b/src/shared/plugins/fs-plugin/client/mention-tree-preview.tsx index 60c2546..c4d6dfa 100644 --- a/src/shared/plugins/fs-plugin/client/mention-tree-preview.tsx +++ b/src/shared/plugins/fs-plugin/client/mention-tree-preview.tsx @@ -4,8 +4,12 @@ import type { MentionOption } from '@webview/types/chat' import type { TreeInfo } from '../types' -export const MentionTreePreview: FC = mentionOption => { - const treeInfo = mentionOption.data as TreeInfo +export const MentionTreePreview: FC< + MentionOption +> = mentionOption => { + const treeInfo = mentionOption.data + + if (!treeInfo) return null return (
diff --git a/src/shared/plugins/git-plugin/client/git-client-plugin.tsx b/src/shared/plugins/git-plugin/client/git-client-plugin.tsx index 8bcd43d..fbbca80 100644 --- a/src/shared/plugins/git-plugin/client/git-client-plugin.tsx +++ b/src/shared/plugins/git-plugin/client/git-client-plugin.tsx @@ -1,50 +1,40 @@ import { CommitIcon, MaskOffIcon, TransformIcon } from '@radix-ui/react-icons' -import type { - ClientPlugin, - ClientPluginContext -} from '@shared/plugins/base/client/client-plugin-context' +import type { UseMentionOptionsReturns } from '@shared/plugins/base/client/client-plugin-types' +import { + createClientPlugin, + type SetupProps +} from '@shared/plugins/base/client/use-client-plugin' import { PluginId } from '@shared/plugins/base/types' import { pkg } from '@shared/utils/pkg' +import { useQuery } from '@tanstack/react-query' import { api } from '@webview/services/api-client' import { type MentionOption } from '@webview/types/chat' import type { GitCommit, GitPluginState } from '../types' -export class GitClientPlugin implements ClientPlugin { - id = PluginId.Git +export const GitClientPlugin = createClientPlugin({ + id: PluginId.Git, + version: pkg.version, - version: string = pkg.version - - private context: ClientPluginContext | null = null - - getInitState() { + getInitialState() { return { gitCommitsFromEditor: [], gitDiffWithMainBranchFromEditor: null, gitDiffOfWorkingStateFromEditor: null } - } + }, - async activate(context: ClientPluginContext): Promise { - this.context = context + setup(props) { + const { registerProvider } = props - this.context.registerProvider('state', () => this.context!.state) - this.context.registerProvider('editor', () => ({ - getMentionOptions: this.getMentionOptions.bind(this) - })) + registerProvider('useMentionOptions', () => createUseMentionOptions(props)) } +}) - deactivate(): void { - this.context?.resetState() - this.context = null - } - - private async getMentionOptions(): Promise { - const queryClient = this?.context?.getQueryClient?.() - - if (!queryClient) return [] - - const gitCommits = await queryClient.fetchQuery({ +const createUseMentionOptions = + (props: SetupProps) => (): UseMentionOptionsReturns => { + const { setState } = props + const { data: gitCommits = [] } = useQuery({ queryKey: ['realtime', 'git-commits'], queryFn: () => api.git.getHistoryCommits({ @@ -60,7 +50,7 @@ export class GitClientPlugin implements ClientPlugin { label: commit.message, data: commit, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.gitCommitsFromEditor = dataArr }) }, @@ -91,7 +81,7 @@ export class GitClientPlugin implements ClientPlugin { type: `${PluginId.Git}#git-diff`, label: 'Diff (Diff of Working State)', onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.gitDiffOfWorkingStateFromEditor = dataArr.at(-1) }) }, @@ -106,7 +96,7 @@ export class GitClientPlugin implements ClientPlugin { type: `${PluginId.Git}#git-pr`, label: 'PR (Diff with Main Branch)', onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.gitDiffWithMainBranchFromEditor = dataArr.at(-1) }) }, @@ -121,4 +111,3 @@ export class GitClientPlugin implements ClientPlugin { } ] } -} diff --git a/src/shared/plugins/terminal-plugin/client/mention-terminal-preview.tsx b/src/shared/plugins/terminal-plugin/client/mention-terminal-preview.tsx index 68d85fd..4cc7714 100644 --- a/src/shared/plugins/terminal-plugin/client/mention-terminal-preview.tsx +++ b/src/shared/plugins/terminal-plugin/client/mention-terminal-preview.tsx @@ -7,9 +7,11 @@ import { ChevronRightIcon, SquareTerminalIcon } from 'lucide-react' import type { TerminalInfo } from '../types' export const MentionTerminalPreview: React.FC< - MentionOption + MentionOption > = mentionOption => { - const terminalInfo = mentionOption.data as TerminalInfo + const terminalInfo = mentionOption.data + + if (!terminalInfo) return null return (
diff --git a/src/shared/plugins/terminal-plugin/client/terminal-client-plugin.tsx b/src/shared/plugins/terminal-plugin/client/terminal-client-plugin.tsx index d8d44ff..9a8cf1d 100644 --- a/src/shared/plugins/terminal-plugin/client/terminal-client-plugin.tsx +++ b/src/shared/plugins/terminal-plugin/client/terminal-client-plugin.tsx @@ -1,9 +1,11 @@ -import type { - ClientPlugin, - ClientPluginContext -} from '@shared/plugins/base/client/client-plugin-context' +import type { UseMentionOptionsReturns } from '@shared/plugins/base/client/client-plugin-types' +import { + createClientPlugin, + type SetupProps +} from '@shared/plugins/base/client/use-client-plugin' import { PluginId } from '@shared/plugins/base/types' import { pkg } from '@shared/utils/pkg' +import { useQuery } from '@tanstack/react-query' import { api } from '@webview/services/api-client' import { type MentionOption } from '@webview/types/chat' import { SquareTerminalIcon } from 'lucide-react' @@ -11,44 +13,28 @@ import { SquareTerminalIcon } from 'lucide-react' import type { TerminalInfo, TerminalPluginState } from '../types' import { MentionTerminalPreview } from './mention-terminal-preview' -export class TerminalClientPlugin implements ClientPlugin { - id = PluginId.Terminal +export const TerminalClientPlugin = createClientPlugin({ + id: PluginId.Terminal, + version: pkg.version, - version: string = pkg.version - - private context: ClientPluginContext | null = null - - getInitState() { + getInitialState() { return { selectedTerminalsFromEditor: [], terminalLogsFromAgent: [] } - } - - async activate( - context: ClientPluginContext - ): Promise { - this.context = context + }, - this.context.registerProvider('state', () => this.context!.state) - this.context.registerProvider('editor', () => ({ - getMentionOptions: this.getMentionOptions.bind(this) - })) - } + setup(props) { + const { registerProvider } = props - deactivate(): void { - this.context?.resetState() - this.context = null + registerProvider('useMentionOptions', () => createUseMentionOptions(props)) } +}) - private async getMentionOptions(): Promise { - if (!this.context) return [] - - const queryClient = this?.context?.getQueryClient?.() - - if (!queryClient) return [] - - const terminals = await queryClient.fetchQuery({ +const createUseMentionOptions = + (props: SetupProps) => (): UseMentionOptionsReturns => { + const { setState } = props + const { data: terminals = [] } = useQuery({ queryKey: ['realtime', 'terminals'], queryFn: () => api.terminal.getTerminalsForMention({}) }) @@ -59,7 +45,7 @@ export class TerminalClientPlugin implements ClientPlugin { label: terminal.name, data: terminal, onUpdatePluginState: dataArr => { - this.context?.setState(draft => { + setState(draft => { draft.selectedTerminalsFromEditor = dataArr }) }, @@ -87,4 +73,3 @@ export class TerminalClientPlugin implements ClientPlugin { } ] } -} diff --git a/src/shared/plugins/web-plugin/client/web-client-plugin.tsx b/src/shared/plugins/web-plugin/client/web-client-plugin.tsx index a838539..59a3945 100644 --- a/src/shared/plugins/web-plugin/client/web-client-plugin.tsx +++ b/src/shared/plugins/web-plugin/client/web-client-plugin.tsx @@ -1,23 +1,20 @@ import { GlobeIcon } from '@radix-ui/react-icons' -import type { - ClientPlugin, - ClientPluginContext -} from '@shared/plugins/base/client/client-plugin-context' +import type { UseMentionOptionsReturns } from '@shared/plugins/base/client/client-plugin-types' +import { + createClientPlugin, + type SetupProps +} from '@shared/plugins/base/client/use-client-plugin' import { PluginId } from '@shared/plugins/base/types' import { pkg } from '@shared/utils/pkg' -import { type MentionOption } from '@webview/types/chat' import type { WebPluginState } from '../types' import { WebLogPreview } from './web-log-preview' -export class WebClientPlugin implements ClientPlugin { - id = PluginId.Web +export const WebClientPlugin = createClientPlugin({ + id: PluginId.Web, + version: pkg.version, - version: string = pkg.version - - private context: ClientPluginContext | null = null - - getInitState() { + getInitialState() { return { enableWebSearchAgent: false, webSearchResultsFromAgent: [], @@ -25,29 +22,19 @@ export class WebClientPlugin implements ClientPlugin { enableWebVisitAgent: false, webVisitResultsFromAgent: [] } - } + }, - async activate(context: ClientPluginContext): Promise { - this.context = context + setup(props) { + const { registerProvider } = props - this.context.registerProvider('state', () => this.context!.state) - this.context.registerProvider('editor', () => ({ - getMentionOptions: this.getMentionOptions.bind(this) - })) - this.context.registerProvider('message', () => ({ - customRenderLogPreview: WebLogPreview - })) + registerProvider('useMentionOptions', () => createUseMentionOptions(props)) + registerProvider('CustomRenderLogPreview', () => WebLogPreview) } +}) - deactivate(): void { - this.context?.resetState() - this.context = null - } - - private async getMentionOptions(): Promise { - const queryClient = this?.context?.getQueryClient?.() - - if (!queryClient) return [] +const createUseMentionOptions = + (props: SetupProps) => (): UseMentionOptionsReturns => { + const { setState } = props return [ { @@ -56,7 +43,7 @@ export class WebClientPlugin implements ClientPlugin { label: 'Web', data: true, onUpdatePluginState: (dataArr: true[]) => { - this.context?.setState(draft => { + setState(draft => { draft.enableWebVisitAgent = dataArr.length > 0 draft.enableWebSearchAgent = dataArr.length > 0 }) @@ -70,4 +57,3 @@ export class WebClientPlugin implements ClientPlugin { } ] } -} diff --git a/src/shared/plugins/web-plugin/client/web-log-preview.tsx b/src/shared/plugins/web-plugin/client/web-log-preview.tsx index b5adbc2..56ecb6b 100644 --- a/src/shared/plugins/web-plugin/client/web-log-preview.tsx +++ b/src/shared/plugins/web-plugin/client/web-log-preview.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { GlobeIcon } from '@radix-ui/react-icons' -import type { ConversationLog } from '@shared/entities' +import type { CustomRenderLogPreviewProps } from '@shared/plugins/base/client/client-plugin-types' import { PluginId } from '@shared/plugins/base/types' import { ChatLogPreview } from '@webview/components/chat/messages/roles/chat-log-preview' import type { PreviewContent } from '@webview/components/content-preview' @@ -9,9 +9,7 @@ import { cn } from '@webview/utils/common' import type { WebDocInfo, WebPluginLog } from '../types' -export const WebLogPreview: FC<{ - log: ConversationLog -}> = props => { +export const WebLogPreview: FC = props => { if (props.log.pluginId !== PluginId.Web) return null const log = props.log as WebPluginLog diff --git a/src/webview/components/chat/editor/chat-input.tsx b/src/webview/components/chat/editor/chat-input.tsx index fc7f81e..06b8f8f 100644 --- a/src/webview/components/chat/editor/chat-input.tsx +++ b/src/webview/components/chat/editor/chat-input.tsx @@ -7,12 +7,11 @@ import { getAllTextFromLangchainMessageContents } from '@shared/utils/get-all-te import { mergeLangchainMessageContents } from '@shared/utils/merge-langchain-message-contents' import { ButtonWithTooltip } from '@webview/components/button-with-tooltip' import { BorderBeam } from '@webview/components/ui/border-beam' +import { usePlugin, WithPluginProvider } from '@webview/contexts/plugin-context' import { - usePluginRegistry, - WithPluginRegistryProvider -} from '@webview/contexts/plugin-registry-context' -import { useMentionOptions } from '@webview/hooks/chat/use-mention-options' -import { usePluginFilesSelectorProviders } from '@webview/hooks/chat/use-plugin-providers' + usePluginMentionOptions, + usePluginSelectedFilesProviders +} from '@webview/hooks/chat/use-plugin-providers' import { useCallbackRef } from '@webview/hooks/use-callback-ref' import { type FileInfo } from '@webview/types/chat' import { cn } from '@webview/utils/common' @@ -74,16 +73,16 @@ const _ChatInput: FC = ({ onSend }) => { const editorRef = useRef(null) - const { getPluginRegistry } = usePluginRegistry() - const { selectedFiles, setSelectedFiles } = usePluginFilesSelectorProviders() - const mentionOptions = useMentionOptions() + const { setState: setPluginState, getState: getPluginState } = usePlugin() + const { selectedFiles, setSelectedFiles } = usePluginSelectedFilesProviders() + const mentionOptions = usePluginMentionOptions() // sync conversation plugin states with plugin registry useEffect(() => { Object.entries(conversation.pluginStates).forEach(([pluginId, state]) => { - getPluginRegistry()?.setState(pluginId as PluginId, state) + setPluginState(pluginId as PluginId, state) }) - }, [getPluginRegistry, conversation.pluginStates]) + }, [setPluginState, conversation.pluginStates]) const handleEditorChange = async (editorState: EditorState) => { const newRichText = tryStringifyJSON(editorState.toJSON()) || '' @@ -101,8 +100,7 @@ const _ChatInput: FC = ({ updatePluginStatesFromEditorState(editorState, mentionOptions) setConversation(draft => { - draft.pluginStates = - getPluginRegistry()?.providerManagers.state.getAll() || {} + draft.pluginStates = getPluginState() }) } @@ -311,4 +309,4 @@ const _ChatInput: FC = ({ ) } -export const ChatInput = WithPluginRegistryProvider(_ChatInput) +export const ChatInput = WithPluginProvider(_ChatInput) diff --git a/src/webview/components/chat/messages/roles/chat-ai-message.tsx b/src/webview/components/chat/messages/roles/chat-ai-message.tsx index 4c783c4..164dad9 100644 --- a/src/webview/components/chat/messages/roles/chat-ai-message.tsx +++ b/src/webview/components/chat/messages/roles/chat-ai-message.tsx @@ -1,7 +1,7 @@ import type { CSSProperties, FC, Ref } from 'react' import type { Conversation } from '@shared/entities' import { getAllTextFromLangchainMessageContents } from '@shared/utils/get-all-text-from-langchain-message-contents' -import { WithPluginRegistryProvider } from '@webview/contexts/plugin-registry-context' +import { WithPluginProvider } from '@webview/contexts/plugin-context' import type { ConversationUIState } from '@webview/types/chat' import { cn } from '@webview/utils/common' @@ -60,4 +60,4 @@ const _ChatAIMessage: FC = props => { ) } -export const ChatAIMessage = WithPluginRegistryProvider(_ChatAIMessage) +export const ChatAIMessage = WithPluginProvider(_ChatAIMessage) diff --git a/src/webview/components/chat/selectors/context-selector.tsx b/src/webview/components/chat/selectors/context-selector.tsx index dd431b4..f5db429 100644 --- a/src/webview/components/chat/selectors/context-selector.tsx +++ b/src/webview/components/chat/selectors/context-selector.tsx @@ -7,7 +7,7 @@ import { type Conversation } from '@shared/entities' import { ButtonWithTooltip } from '@webview/components/button-with-tooltip' -import { usePluginImagesSelectorProviders } from '@webview/hooks/chat/use-plugin-providers' +import { usePluginSelectedImagesProviders } from '@webview/hooks/chat/use-plugin-providers' import type { Updater } from 'use-immer' import { ModelSelector } from './model-selector' @@ -33,7 +33,7 @@ export const ContextSelector: React.FC = ({ showExitEditModeButton, onExitEditMode }) => { - const { addSelectedImage } = usePluginImagesSelectorProviders() + const { addSelectedImage } = usePluginSelectedImagesProviders() const handleSelectImage = () => { const input = document.createElement('input') diff --git a/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx index 39e2ffc..7669265 100644 --- a/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx +++ b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx @@ -88,9 +88,7 @@ export const MentionSelector: React.FC = ({ const handleSelect = (option: MentionOption) => { if (isFlattened) { - if (option.data) { - onSelect(option) - } + onSelect(option) setIsFlattened(false) setIsOpen(false) return @@ -101,9 +99,7 @@ export const MentionSelector: React.FC = ({ setOptionsStack(prevStack => [...prevStack, option.children || []]) onCloseWithoutSelect?.() } else { - if (option.data) { - onSelect(option) - } + onSelect(option) setIsOpen(false) } } diff --git a/src/webview/components/chat/selectors/model-selector.tsx b/src/webview/components/chat/selectors/model-selector.tsx index edaf0d2..b114e5b 100644 --- a/src/webview/components/chat/selectors/model-selector.tsx +++ b/src/webview/components/chat/selectors/model-selector.tsx @@ -219,7 +219,7 @@ export const ModelSelector: React.FC = ({ } const handleOpenProvidersManagement = () => { - navigate(`/settings?category=chatModel`) + navigate(`/settings?pageId=chatModel`) } const renderSidebarFooter = () => ( diff --git a/src/webview/contexts/plugin-context.tsx b/src/webview/contexts/plugin-context.tsx new file mode 100644 index 0000000..3caa402 --- /dev/null +++ b/src/webview/contexts/plugin-context.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { ClientPluginProvider } from '@shared/plugins/base/client/client-plugin-context' +import { createClientPlugins } from '@shared/plugins/base/client/create-client-plugins' +import type { ClientPlugin } from '@shared/plugins/base/client/use-client-plugin' + +export { usePlugin } from '@shared/plugins/base/client/client-plugin-context' + +const Plugin: React.FC<{ plugin: ClientPlugin }> = ({ plugin }) => { + plugin.usePlugin() + return null +} + +export const PluginProvider: React.FC = ({ + children +}) => ( + + {createClientPlugins().map(plugin => ( + + ))} + {children} + +) + +export const WithPluginProvider =

( + WrappedComponent: React.ComponentType

+) => { + const WithPluginProvider: React.FC

= props => ( + + + + ) + + WithPluginProvider.displayName = `WithPluginProvider(${getDisplayName( + WrappedComponent + )})` + + return WithPluginProvider +} + +function getDisplayName

(WrappedComponent: React.ComponentType

): string { + return WrappedComponent.displayName || WrappedComponent.name || 'Component' +} diff --git a/src/webview/contexts/plugin-registry-context.tsx b/src/webview/contexts/plugin-registry-context.tsx deleted file mode 100644 index d6dc9da..0000000 --- a/src/webview/contexts/plugin-registry-context.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { - createContext, - useContext, - useEffect, - useId, - useRef, - type FC -} from 'react' -import { ClientPluginRegistry } from '@shared/plugins/base/client/client-plugin-registry' -import { createClientPlugins } from '@shared/plugins/base/client/create-client-plugins' -import { type PluginId, type PluginState } from '@shared/plugins/base/types' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useCallbackRef } from '@webview/hooks/use-callback-ref' -import { useImmer } from 'use-immer' - -const PluginRegistryContext = createContext<{ - pluginRegistry: ClientPluginRegistry | null - getPluginRegistry: () => ClientPluginRegistry | undefined -} | null>(null) - -export const PluginRegistryProvider: FC = ({ - children -}) => { - const queryClient = useQueryClient() - const [pluginStates, setPluginStates] = useImmer< - Record - >({} as Record) - const pluginRegistryRef = useRef(null) - const id = useId() - - const { data: pluginRegistry } = useQuery({ - queryKey: [id, pluginStates, queryClient], - queryFn: async () => { - // if the pluginRegistry is already initialized, only update the state - if (pluginRegistryRef.current?.isInitialized) { - pluginRegistryRef.current.init({ - queryClient, - state: pluginStates, - setState: setPluginStates - }) - return pluginRegistryRef.current - } - - // the full initialization process for the first creation or uninitialized - const pluginRegistry = new ClientPluginRegistry() - pluginRegistry.init({ - queryClient, - state: pluginStates, - setState: setPluginStates - }) - - const plugins = createClientPlugins() - await Promise.allSettled( - plugins.map(plugin => pluginRegistry.loadPlugin(plugin)) - ) - - pluginRegistryRef.current = pluginRegistry - return pluginRegistry - }, - // when the component is unmounted, clean up the plugin - gcTime: 0, - staleTime: Infinity - }) - - // when the component is unmounted, clean up the plugin - useEffect( - () => () => { - pluginRegistryRef.current?.unloadAllPlugins().then(() => { - pluginRegistryRef.current = null - }) - }, - [] - ) - - const getPluginRegistry = useCallbackRef(() => pluginRegistry) - - return ( - - {children} - - ) -} - -export const usePluginRegistry = () => { - const context = useContext(PluginRegistryContext) - - if (context === null) { - throw new Error( - 'usePluginRegistry must be used within a PluginRegistryProvider' - ) - } - - return context -} - -export const WithPluginRegistryProvider =

( - WrappedComponent: React.ComponentType

-) => { - const WithPluginRegistryProvider: React.FC

= props => ( - - - - ) - - WithPluginRegistryProvider.displayName = `WithPluginRegistryProvider(${getDisplayName( - WrappedComponent - )})` - - return WithPluginRegistryProvider -} - -function getDisplayName

(WrappedComponent: React.ComponentType

): string { - return WrappedComponent.displayName || WrappedComponent.name || 'Component' -} diff --git a/src/webview/hooks/chat/use-files-tree-items.ts b/src/webview/hooks/chat/use-files-tree-items.ts index 89f68d5..ca7a9c2 100644 --- a/src/webview/hooks/chat/use-files-tree-items.ts +++ b/src/webview/hooks/chat/use-files-tree-items.ts @@ -8,6 +8,7 @@ export interface UseFilesTreeItemsOptions { export const useFilesTreeItems = (options: UseFilesTreeItemsOptions) => { const { files } = options + const treeItems = convertFilesToTreeItems(files) const flattenedItems = flattenTreeItems(treeItems) diff --git a/src/webview/hooks/chat/use-mention-options.tsx b/src/webview/hooks/chat/use-mention-options.tsx deleted file mode 100644 index ef948ee..0000000 --- a/src/webview/hooks/chat/use-mention-options.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { usePluginRegistry } from '@webview/contexts/plugin-registry-context' - -export const useMentionOptions = () => { - const { pluginRegistry } = usePluginRegistry() - - const { data: mentionOptions = [] } = useQuery({ - queryKey: ['realtime', 'useMentionOptions', pluginRegistry], - queryFn: async () => { - const editorProvider = pluginRegistry?.providerManagers.editor.mergeAll() - const result = (await editorProvider?.getMentionOptions?.()) || [] - - return result - }, - enabled: !!pluginRegistry - }) - - return mentionOptions -} diff --git a/src/webview/hooks/chat/use-plugin-providers.tsx b/src/webview/hooks/chat/use-plugin-providers.tsx index ec1851a..38c5af0 100644 --- a/src/webview/hooks/chat/use-plugin-providers.tsx +++ b/src/webview/hooks/chat/use-plugin-providers.tsx @@ -1,67 +1,54 @@ -import { Fragment, useMemo, type FC } from 'react' -import type { ConversationLog } from '@shared/entities' -import { usePluginRegistry } from '@webview/contexts/plugin-registry-context' - -export const usePluginEditorProviders = () => { - const { pluginRegistry } = usePluginRegistry() - const merged = useMemo( - () => pluginRegistry?.providerManagers.editor.mergeAll() || {}, - [pluginRegistry] - ) - - return merged -} - -export const usePluginMessageProviders = () => { - const { pluginRegistry } = usePluginRegistry() - const merged = useMemo( - () => pluginRegistry?.providerManagers.message.mergeAll() || {}, - [pluginRegistry] - ) - - return merged -} +import { Fragment, type FC } from 'react' +import type { + CustomRenderLogPreviewProps, + UseMentionOptionsReturns, + UseSelectedFilesReturns, + UseSelectedImagesReturns +} from '@shared/plugins/base/client/client-plugin-types' +import { usePlugin } from '@webview/contexts/plugin-context' export const usePluginCustomRenderLogPreview = () => { - const { pluginRegistry } = usePluginRegistry() - const renders = pluginRegistry?.providerManagers.message.getValues( - 'customRenderLogPreview' - ) + const { getProviders } = usePlugin() + const renders = getProviders('CustomRenderLogPreview') - const customRenderLogPreview: FC<{ log: ConversationLog }> = ({ log }) => + const CustomRenderLogPreview: FC = ({ log }) => renders?.map((render, i) => {render({ log })}) - return customRenderLogPreview + return CustomRenderLogPreview } -export const usePluginFilesSelectorProviders = () => { - const { pluginRegistry } = usePluginRegistry() - const merged = useMemo( - () => pluginRegistry?.providerManagers.filesSelector.mergeAll() || {}, - [pluginRegistry] - ) - - const selectedFiles = merged.getSelectedFiles?.() || [] - - return { ...merged, selectedFiles } -} +export const usePluginSelectedFilesProviders = (): UseSelectedFilesReturns => { + const { mergeProviders } = usePlugin() + const useSelectedFiles = mergeProviders('useSelectedFiles') -export const usePluginImagesSelectorProviders = () => { - const { pluginRegistry } = usePluginRegistry() - const merged = useMemo( - () => pluginRegistry?.providerManagers.imagesSelector.mergeAll() || {}, - [pluginRegistry] + return ( + // eslint-disable-next-line react-compiler/react-compiler + useSelectedFiles?.() || { + selectedFiles: [], + setSelectedFiles: () => {} + } ) - - return merged } -export const usePluginStates = () => { - const { pluginRegistry } = usePluginRegistry() - const states = useMemo( - () => pluginRegistry?.providerManagers.state.getAll() || {}, - [pluginRegistry] - ) - - return states +export const usePluginSelectedImagesProviders = + (): UseSelectedImagesReturns => { + const { mergeProviders } = usePlugin() + const useSelectedImages = mergeProviders('useSelectedImages') + + return ( + // eslint-disable-next-line react-compiler/react-compiler + useSelectedImages?.() || { + selectedImages: [], + addSelectedImage: () => {}, + removeSelectedImage: () => {} + } + ) + } + +export const usePluginMentionOptions = (): UseMentionOptionsReturns => { + const { mergeProviders } = usePlugin() + const useMentionOptions = mergeProviders('useMentionOptions') + + // eslint-disable-next-line react-compiler/react-compiler + return useMentionOptions!() } diff --git a/src/webview/hooks/use-shiki-highlighter.ts b/src/webview/hooks/use-shiki-highlighter.ts index bcf1f61..633ed0d 100644 --- a/src/webview/hooks/use-shiki-highlighter.ts +++ b/src/webview/hooks/use-shiki-highlighter.ts @@ -19,6 +19,7 @@ export const useShikiHighlighter = (props: UseShikiHighlighterProps) => { if (!enabled) return const highlightCode = async () => { try { + if (!code) return const html = await codeToHtml(code, { lang: language, theme: isDarkTheme ? 'dark-plus' : 'light-plus' diff --git a/src/webview/lexical/nodes/mention-node.tsx b/src/webview/lexical/nodes/mention-node.tsx index bc12528..99395a5 100644 --- a/src/webview/lexical/nodes/mention-node.tsx +++ b/src/webview/lexical/nodes/mention-node.tsx @@ -5,7 +5,7 @@ import { PopoverContent, PopoverTrigger } from '@webview/components/ui/popover' -import { useMentionOptions } from '@webview/hooks/chat/use-mention-options' +import { usePluginMentionOptions } from '@webview/hooks/chat/use-plugin-providers' import type { MentionOption } from '@webview/types/chat' import { findMentionOptionByMentionType } from '@webview/utils/plugin-states' import { @@ -230,7 +230,7 @@ const MentionPreview: FC<{ mentionData: any children: React.ReactNode }> = ({ mentionType, mentionData, children }) => { - const mentionOptions = useMentionOptions() + const mentionOptions = usePluginMentionOptions() const option = findMentionOptionByMentionType(mentionOptions, mentionType) const currentOption = { diff --git a/src/webview/lexical/plugins/mention-plugin.tsx b/src/webview/lexical/plugins/mention-plugin.tsx index 8020463..c24926f 100644 --- a/src/webview/lexical/plugins/mention-plugin.tsx +++ b/src/webview/lexical/plugins/mention-plugin.tsx @@ -1,7 +1,7 @@ import React, { useState, type FC } from 'react' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { MentionSelector } from '@webview/components/chat/selectors/mention-selector/mention-selector' -import { useMentionOptions } from '@webview/hooks/chat/use-mention-options' +import { usePluginMentionOptions } from '@webview/hooks/chat/use-plugin-providers' import type { MentionOption } from '@webview/types/chat' import { $createTextNode, @@ -21,7 +21,7 @@ export const MentionPlugin: FC = props => { const [editor] = useLexicalComposerContext() const [isOpen, setIsOpen] = useState(false) const mentionPosition = useNearestMentionPosition(editor) - const mentionOptions = useMentionOptions() + const mentionOptions = usePluginMentionOptions() const { searchQuery, setSearchQuery, clearMentionInput } = useMentionSearch( editor, @@ -34,7 +34,9 @@ export const MentionPlugin: FC = props => { setIsOpen(false) setSearchQuery('') + option.onSelect?.(option.data) if (option.disableAddToEditor) return + editor.update(() => { const selection = $getSelection() if ($isRangeSelection(selection)) {