diff --git a/packages/gpt-runner-shared/src/common/types/common.ts b/packages/gpt-runner-shared/src/common/types/common.ts index 3dbc2f0..6c579d8 100644 --- a/packages/gpt-runner-shared/src/common/types/common.ts +++ b/packages/gpt-runner-shared/src/common/types/common.ts @@ -5,3 +5,9 @@ export type ReadonlyDeep = { readonly [P in keyof T]: ReadonlyDeep } export type ValueOf = T[keyof T] + +export type DeepRequired = { + [P in keyof T]-?: DeepRequired +} + +export type PartialKeys = Omit & Partial> diff --git a/packages/gpt-runner-shared/src/common/types/enum.ts b/packages/gpt-runner-shared/src/common/types/enum.ts index 0ee756a..d80a3bd 100644 --- a/packages/gpt-runner-shared/src/common/types/enum.ts +++ b/packages/gpt-runner-shared/src/common/types/enum.ts @@ -26,6 +26,7 @@ export enum ClientEventName { UpdateIdeActiveFilePath = 'updateIdeActiveFilePath', UpdateUserSelectedText = 'updateUserSelectedText', OpenFileInIde = 'openFileInIde', + OpenFileInFileEditor = 'openFileInFileEditor', } export enum GptFileTreeItemType { diff --git a/packages/gpt-runner-shared/src/common/types/eventemitter.ts b/packages/gpt-runner-shared/src/common/types/eventemitter.ts index 312085a..c21ec76 100644 --- a/packages/gpt-runner-shared/src/common/types/eventemitter.ts +++ b/packages/gpt-runner-shared/src/common/types/eventemitter.ts @@ -32,6 +32,10 @@ export interface ClientEventData { [ClientEventName.OpenFileInIde]: { filePath: string } + + [ClientEventName.OpenFileInFileEditor]: { + fileFullPath: string + } } export type EventEmitterMap = { diff --git a/packages/gpt-runner-shared/src/common/types/server.ts b/packages/gpt-runner-shared/src/common/types/server.ts index 0529218..a9faa55 100644 --- a/packages/gpt-runner-shared/src/common/types/server.ts +++ b/packages/gpt-runner-shared/src/common/types/server.ts @@ -132,3 +132,39 @@ export interface OpenEditorReqParams { } export type OpenEditorResData = null + +export interface CreateFilePathReqParams { + fileFullPath: string + isDir: boolean +} + +export type CreateFilePathResData = null + +export interface RenameFilePathReqParams { + oldFileFullPath: string + newFileFullPath: string +} + +export type RenameFilePathResData = null + +export interface DeleteFilePathReqParams { + fileFullPath: string +} + +export type DeleteFilePathResData = null + +export interface GetFileInfoReqParams { + fileFullPath: string +} + +export interface GetFileInfoResData { + content: string + isDir: boolean +} + +export interface SaveFileContentReqParams { + fileFullPath: string + content: string +} + +export type SaveFileContentResData = null diff --git a/packages/gpt-runner-shared/src/common/zod/server.zod.ts b/packages/gpt-runner-shared/src/common/zod/server.zod.ts index b82e99f..e8836dd 100644 --- a/packages/gpt-runner-shared/src/common/zod/server.zod.ts +++ b/packages/gpt-runner-shared/src/common/zod/server.zod.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { ChatStreamReqParams, GetCommonFilesReqParams, GetGptFileInfoReqParams, GetGptFilesReqParams, GetUserConfigReqParams, InitGptFilesReqParams, OpenEditorReqParams, StorageClearReqParams, StorageGetItemReqParams, StorageRemoveItemReqParams, StorageSetItemReqParams } from '../types' +import type { ChatStreamReqParams, CreateFilePathReqParams, DeleteFilePathReqParams, GetCommonFilesReqParams, GetFileInfoReqParams, GetGptFileInfoReqParams, GetGptFilesReqParams, GetUserConfigReqParams, InitGptFilesReqParams, OpenEditorReqParams, RenameFilePathReqParams, SaveFileContentReqParams, StorageClearReqParams, StorageGetItemReqParams, StorageRemoveItemReqParams, StorageSetItemReqParams } from '../types' import { PartialChatModelTypeMapSchema, SingleChatMessageSchema, SingleFileConfigSchema } from './config' import { ServerStorageNameSchema } from './enum.zod' @@ -64,3 +64,26 @@ export const OpenEditorReqParamsSchema = z.object({ path: z.string(), matchContent: z.string().optional(), }) satisfies z.ZodType + +export const CreateFilePathReqParamsSchema = z.object({ + fileFullPath: z.string(), + isDir: z.boolean(), +}) satisfies z.ZodType + +export const RenameFilePathReqParamsSchema = z.object({ + oldFileFullPath: z.string(), + newFileFullPath: z.string(), +}) satisfies z.ZodType + +export const DeleteFilePathReqParamsSchema = z.object({ + fileFullPath: z.string(), +}) satisfies z.ZodType + +export const GetFileInfoReqParamsSchema = z.object({ + fileFullPath: z.string(), +}) satisfies z.ZodType + +export const SaveFileContentReqParamsSchema = z.object({ + fileFullPath: z.string(), + content: z.string(), +}) satisfies z.ZodType diff --git a/packages/gpt-runner-shared/src/node/helpers/file-utils.ts b/packages/gpt-runner-shared/src/node/helpers/file-utils.ts index 33991c8..8754850 100644 --- a/packages/gpt-runner-shared/src/node/helpers/file-utils.ts +++ b/packages/gpt-runner-shared/src/node/helpers/file-utils.ts @@ -15,6 +15,15 @@ export interface WriteFileParams { valid?: boolean } +export interface EnsurePathParams { + filePath: string +} + +export interface MovePathParams { + oldPath: string + newPath: string +} + export interface TravelFilesParams { filePath: string isValidPath: (filePath: string) => MaybePromise @@ -59,14 +68,16 @@ export class FileUtils { if (valid) { // check if path is file and is writable - if (PathUtils.isAccessible(filePath, 'W')) + if (!PathUtils.isAccessible(filePath, 'W')) return if (!PathUtils.isFile(filePath)) return } - await fs.mkdir(PathUtils.getDirPath(filePath), { recursive: true }) + const dir = PathUtils.getDirPath(filePath) + if (!PathUtils.isExit(dir)) + await fs.mkdir(dir, { recursive: true }) if (overwrite) await fs.writeFile(filePath, content, { encoding: 'utf8' }) @@ -80,6 +91,20 @@ export class FileUtils { await fs.rm(fullPath, { recursive: true }) } + static async ensurePath(params: EnsurePathParams): Promise { + const { filePath } = params + + if (!PathUtils.isAccessible(filePath, 'W')) + await fs.mkdir(filePath, { recursive: true }) + } + + static async movePath(params: MovePathParams): Promise { + const { oldPath, newPath } = params + + if (PathUtils.isAccessible(oldPath, 'W')) + await fs.rename(oldPath, newPath) + } + static async travelFiles(params: TravelFilesParams): Promise { const { isValidPath, callback } = params const filePath = PathUtils.resolve(params.filePath) diff --git a/packages/gpt-runner-web/client/public/locales/de.json b/packages/gpt-runner-web/client/public/locales/de.json index 86b0725..e9fc9a7 100644 --- a/packages/gpt-runner-web/client/public/locales/de.json +++ b/packages/gpt-runner-web/client/public/locales/de.json @@ -6,6 +6,7 @@ "tab_files": "Dateien", "tab_settings": "Einstellungen", "tab_about": "Über", + "tab_file_editor": "Datei-Editor", "continue_inputting_prompt": "Bitte weitermachen", "copy_btn": "Kopieren", "insert_btn": "Einfügen", @@ -30,6 +31,8 @@ "filter_btn": "Filtern", "pin_up_btn": "Anheften", "close_btn": "Schließen", + "cancel_btn": "Abbrechen", + "ok_btn": "Bestätigen", "close_sidebar_btn": "Seitenleiste schließen", "open_sidebar_btn": "Seitenleiste öffnen", "search_placeholder": "Suchen...", @@ -83,6 +86,8 @@ "toast_create_error": "Erstellen fehlgeschlagen!", "toast_copy_success": "Kopiert!", "toast_selected_files_as_prompt_reopened": "Ausgewählte Dateien als Aufforderung wurden wieder geöffnet!", - "monaco_switch_language_tips": "Sprache wechseln" + "monaco_switch_language_tips": "Sprache wechseln", + "file_editor_forgot_save_tips_title": "Möchten Sie die Änderungen an {{fileName}} speichern?", + "file_editor_forgot_save_tips_content": "Ihre Änderungen gehen verloren, wenn Sie sie nicht speichern." } } \ No newline at end of file diff --git a/packages/gpt-runner-web/client/public/locales/en.json b/packages/gpt-runner-web/client/public/locales/en.json index ebb98d8..361e61e 100644 --- a/packages/gpt-runner-web/client/public/locales/en.json +++ b/packages/gpt-runner-web/client/public/locales/en.json @@ -6,6 +6,7 @@ "tab_files": "Files", "tab_settings": "Settings", "tab_about": "About", + "tab_file_editor": "File Editor", "continue_inputting_prompt": "Please continue", "copy_btn": "Copy", "insert_btn": "Insert", @@ -30,6 +31,8 @@ "filter_btn": "Filter", "pin_up_btn": "Pin Up", "close_btn": "Close", + "cancel_btn": "Cancel", + "ok_btn": "Confirm", "close_sidebar_btn": "Close Sidebar", "open_sidebar_btn": "Open Sidebar", "search_placeholder": "Search...", @@ -83,6 +86,8 @@ "toast_create_error": "Create error!", "toast_copy_success": "Copied!", "toast_selected_files_as_prompt_reopened": "Selected Files As Prompt Has Reopened!", - "monaco_switch_language_tips": "Switch To Language" + "monaco_switch_language_tips": "Switch To Language", + "file_editor_forgot_save_tips_title": "Do you want to save changes to {{fileName}}?", + "file_editor_forgot_save_tips_content": "Your changes will be lost if you don't save them." } } \ No newline at end of file diff --git a/packages/gpt-runner-web/client/public/locales/ja.json b/packages/gpt-runner-web/client/public/locales/ja.json index cb07039..c3be424 100644 --- a/packages/gpt-runner-web/client/public/locales/ja.json +++ b/packages/gpt-runner-web/client/public/locales/ja.json @@ -6,6 +6,7 @@ "tab_files": "ファイル", "tab_settings": "設定", "tab_about": "情報", + "tab_file_editor": "ファイルエディタ", "continue_inputting_prompt": "続けてください", "copy_btn": "コピー", "insert_btn": "挿入", @@ -30,6 +31,8 @@ "filter_btn": "フィルター", "pin_up_btn": "ピンアップ", "close_btn": "閉じる", + "cancel_btn": "キャンセル", + "ok_btn": "確認", "close_sidebar_btn": "サイドバーを閉じる", "open_sidebar_btn": "サイドバーを開く", "search_placeholder": "検索...", @@ -83,6 +86,8 @@ "toast_create_error": "作成できませんでした!", "toast_copy_success": "コピーしました!", "toast_selected_files_as_prompt_reopened": "選択したファイルをプロンプトとして再度開きました!", - "monaco_switch_language_tips": "言語の切り替え" + "monaco_switch_language_tips": "言語の切り替え", + "file_editor_forgot_save_tips_title": "変更を{{fileName}}に保存しますか?", + "file_editor_forgot_save_tips_content": "保存しない場合、変更は失われます。" } } \ No newline at end of file diff --git a/packages/gpt-runner-web/client/public/locales/zh_CN.json b/packages/gpt-runner-web/client/public/locales/zh_CN.json index 7f025bf..f5f862d 100644 --- a/packages/gpt-runner-web/client/public/locales/zh_CN.json +++ b/packages/gpt-runner-web/client/public/locales/zh_CN.json @@ -6,6 +6,7 @@ "tab_files": "文件", "tab_settings": "设置", "tab_about": "关于", + "tab_file_editor": "文件编辑器", "continue_inputting_prompt": "请继续", "copy_btn": "复制", "insert_btn": "插入", @@ -30,6 +31,8 @@ "filter_btn": "筛选", "pin_up_btn": "钉住", "close_btn": "关闭", + "cancel_btn": "取消", + "ok_btn": "确认", "close_sidebar_btn": "关闭侧栏", "open_sidebar_btn": "打开侧栏", "search_placeholder": "搜索...", @@ -83,6 +86,8 @@ "toast_create_error": "创建失败!", "toast_copy_success": "复制成功!", "toast_selected_files_as_prompt_reopened": "已重新打开选定的文件作为提示!", - "monaco_switch_language_tips": "切换语言" + "monaco_switch_language_tips": "切换语言", + "file_editor_forgot_save_tips_title": "你想要保存对{{fileName}}的更改吗?", + "file_editor_forgot_save_tips_content": "如果你不保存,你的改动将会丢失." } } \ No newline at end of file diff --git a/packages/gpt-runner-web/client/public/locales/zh_Hant.json b/packages/gpt-runner-web/client/public/locales/zh_Hant.json index 8eb7ceb..f30d9db 100644 --- a/packages/gpt-runner-web/client/public/locales/zh_Hant.json +++ b/packages/gpt-runner-web/client/public/locales/zh_Hant.json @@ -6,6 +6,7 @@ "tab_files": "文件", "tab_settings": "設定", "tab_about": "關於", + "tab_file_editor": "文件編輯器", "continue_inputting_prompt": "請繼續", "copy_btn": "複製", "insert_btn": "插入", @@ -30,6 +31,8 @@ "filter_btn": "篩選", "pin_up_btn": "钉住", "close_btn": "關閉", + "cancel_btn": "取消", + "ok_btn": "確認", "close_sidebar_btn": "關閉側邊欄", "open_sidebar_btn": "打開側邊欄", "search_placeholder": "搜索...", @@ -83,6 +86,8 @@ "toast_create_error": "创建失败!", "toast_copy_success": "複製成功!", "toast_selected_files_as_prompt_reopened": "已重新開啟選定文件作為提示!", - "monaco_switch_language_tips": "切換語言" + "monaco_switch_language_tips": "切換語言", + "file_editor_forgot_save_tips_title": "你想要保存對{{fileName}}的更改嗎?", + "file_editor_forgot_save_tips_content": "如果你不保存,你的改動將會丟失." } } \ No newline at end of file diff --git a/packages/gpt-runner-web/client/src/app.tsx b/packages/gpt-runner-web/client/src/app.tsx index 119bd35..8695f75 100644 --- a/packages/gpt-runner-web/client/src/app.tsx +++ b/packages/gpt-runner-web/client/src/app.tsx @@ -15,6 +15,7 @@ import { initI18n } from './helpers/i18n' import { Toast } from './components/toast' import { LoadingView } from './components/loading-view' import { ConfettiProvider } from './store/context/confetti-context' +import { ModalProvider } from './store/context/modal-context' const queryClient = new QueryClient({ defaultOptions: { @@ -74,7 +75,9 @@ export const AppProviders: FC = memo(({ children }) => { - {children} + + {children} + diff --git a/packages/gpt-runner-web/client/src/components/chat-message-input/index.tsx b/packages/gpt-runner-web/client/src/components/chat-message-input/index.tsx index 20785c9..44e6a8e 100644 --- a/packages/gpt-runner-web/client/src/components/chat-message-input/index.tsx +++ b/packages/gpt-runner-web/client/src/components/chat-message-input/index.tsx @@ -56,8 +56,6 @@ export const ChatMessageInput: FC = memo((props) => { monacoRef.current.KeyMod.CtrlCmd | monacoRef.current.KeyCode.Enter, monacoRef.current.KeyMod.WinCtrl | monacoRef.current.KeyCode.Enter, ], - contextMenuGroupId: 'navigation', - contextMenuOrder: 1.5, run() { if (!currentValue.current.trim()) return diff --git a/packages/gpt-runner-web/client/src/components/editor/index.tsx b/packages/gpt-runner-web/client/src/components/editor/index.tsx index 306fee3..e2f70e2 100644 --- a/packages/gpt-runner-web/client/src/components/editor/index.tsx +++ b/packages/gpt-runner-web/client/src/components/editor/index.tsx @@ -1,12 +1,14 @@ import type { Monaco, EditorProps as MonacoEditorProps } from '@monaco-editor/react' import MonacoEditor, { loader } from '@monaco-editor/react' import type { FC } from 'react' -import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as monaco from 'monaco-editor' import type { MonacoEditorInstance } from '../../types/monaco-editor' import { useGlobalStore } from '../../store/zustand/global' import { isDarkTheme } from '../../styles/themes' import { initTsLanguageSettings } from './monaco/init-ts-settings' +import { createSwitchLanguageCommand } from './monaco/commands/switch-language' +import { createCtrlSToSaveAction } from './monaco/actions/ctrls-to-save' loader.config({ monaco, @@ -19,11 +21,13 @@ export enum EditorCommand { export interface EditorProps extends MonacoEditorProps { filePath?: string onLanguageChange?: (language: string) => void + onSave?: () => void } export const Editor: FC = memo((props) => { - const { filePath, onLanguageChange, beforeMount, onMount, defaultLanguage: defaultLanguageFromProps, language: languageFromProps, options, ...otherProps } = props + const { filePath, onLanguageChange, onSave, beforeMount, onMount, defaultLanguage: defaultLanguageFromProps, language: languageFromProps, options, ...otherProps } = props const monacoRef = useRef() + const [_, setForceUpdate] = useState(0) const monacoEditorRef = useRef() const fileExt = filePath?.split('.')?.pop() const DEFAULT_LANGUAGE = 'markdown' @@ -45,7 +49,6 @@ export const Editor: FC = memo((props) => { const extMapLanguage = useMemo(() => { const map: Record = {} const languages = monacoRef.current?.languages.getLanguages() || [] - languages.forEach((lang) => { lang.extensions?.forEach((ext) => { map[ext] = lang.id @@ -74,8 +77,10 @@ export const Editor: FC = memo((props) => { // do something before editor is mounted monacoRef.current = monaco - beforeMount?.(monaco) + + // fix monaco editor not update when first render + setForceUpdate(prev => prev + 1) }, []) const handleEditorDidMount = useCallback((editor: MonacoEditorInstance, monaco: Monaco) => { @@ -86,40 +91,33 @@ export const Editor: FC = memo((props) => { monacoEditorRef.current = editor onMount?.(editor, monaco) + + // fix monaco editor not update when first render + setForceUpdate(prev => prev + 1) }, []) // register command useEffect(() => { - if (!monacoRef.current) - return + const disposes: (() => void)[] = [] - const commandDispose = monacoRef.current.editor.registerCommand('switchLanguage', (_get, params: { ext?: string }) => { - const { ext = '' } = params || {} - - if (!monacoRef.current) - return - - const language = extMapLanguage[ext] - - if (!language) - return - - const currentModel = monacoEditorRef.current?.getModel() - - if (!currentModel) - return - - // set language - monacoRef.current.editor.setModelLanguage(currentModel, language) - - onLanguageChange?.(language) - }) + disposes.push(createSwitchLanguageCommand(monacoRef.current, monacoEditorRef.current, extMapLanguage, onLanguageChange)) return () => { - commandDispose?.dispose() + disposes.forEach(dispose => dispose()) } }, [monacoRef.current, monacoEditorRef.current, extMapLanguage, onLanguageChange]) + // add action + useEffect(() => { + const disposes: (() => void)[] = [] + + disposes.push(createCtrlSToSaveAction(monacoRef.current, monacoEditorRef.current, onSave)) + + return () => { + disposes.forEach(dispose => dispose()) + } + }, [monacoRef.current, monacoEditorRef.current, onSave]) + return ( void) { + if (!monaco || !editor) + return () => {} + + const dispose = editor.addAction({ + id: 'save-content', + label: 'Control+S / Command+S to save content', + keybindings: [ + // Ctrl+S / Cmd+S + monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, + monaco.KeyMod.WinCtrl | monaco.KeyCode.KeyS, + ], + run() { + callback?.() + }, + }) + + return () => dispose.dispose() +} diff --git a/packages/gpt-runner-web/client/src/components/editor/monaco/commands/switch-language.ts b/packages/gpt-runner-web/client/src/components/editor/monaco/commands/switch-language.ts new file mode 100644 index 0000000..2624a4e --- /dev/null +++ b/packages/gpt-runner-web/client/src/components/editor/monaco/commands/switch-language.ts @@ -0,0 +1,31 @@ +import type { Monaco } from '@monaco-editor/react' +import type { MonacoEditorInstance } from '../../../../types/monaco-editor' + +export function createSwitchLanguageCommand(monaco: Monaco | undefined, editor: MonacoEditorInstance | undefined, extMapLanguage: Record, callback?: (language: string) => void) { + if (!monaco || !editor) + return () => {} + + const dispose = monaco.editor.registerCommand('switchLanguage', (_get, params: { ext?: string }) => { + const { ext = '' } = params || {} + + if (!monaco) + return + + const language = extMapLanguage[ext] + + if (!language) + return + + const currentModel = editor?.getModel() + + if (!currentModel) + return + + // set language + monaco.editor.setModelLanguage(currentModel, language) + + callback?.(language) + }) + + return () => dispose.dispose() +} diff --git a/packages/gpt-runner-web/client/src/components/icon/index.tsx b/packages/gpt-runner-web/client/src/components/icon/index.tsx index 6bd47b7..e17bc13 100644 --- a/packages/gpt-runner-web/client/src/components/icon/index.tsx +++ b/packages/gpt-runner-web/client/src/components/icon/index.tsx @@ -1,15 +1,17 @@ import clsx from 'clsx' -import type { ComponentProps, FC } from 'react' -import React, { memo } from 'react' +import type { ComponentProps } from 'react' +import React, { forwardRef, memo } from 'react' export interface IconProps extends ComponentProps<'span'> { onClick?: (e: React.MouseEvent) => void } -export const Icon: FC = memo((props) => { +export const Icon = memo(forwardRef((props, ref) => { const { className, style, ...restProps } = props + return ( = memo((props) => { )} {...restProps} />) -}) +})) Icon.displayName = 'Icon' diff --git a/packages/gpt-runner-web/client/src/components/modal/index.tsx b/packages/gpt-runner-web/client/src/components/modal/index.tsx new file mode 100644 index 0000000..c036483 --- /dev/null +++ b/packages/gpt-runner-web/client/src/components/modal/index.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react' +import { memo, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { FlexColumnCenter } from '../../styles/global.styles' +import { CloseButton, ModalContent, ModalContentFooter, ModalContentHeader, ModalContentWrapper, ModalTitle, ModalWrapper, StyledFooterButton } from './modal.styles' + +export interface ModalProps { + open: boolean + title?: ReactNode + zIndex?: number + cancelText?: string + okText?: string + showCancelBtn?: boolean + showOkBtn?: boolean + // centered?: boolean + showCloseIcon?: boolean + contentWidth?: string + children?: ReactNode + onCancel?: () => void + onOk?: () => void +} + +export const Modal = memo(({ + open, + title = '', + zIndex = 10, + cancelText, + okText, + showCancelBtn = true, + showOkBtn = true, + // centered = false, + showCloseIcon = true, + contentWidth, + children, + onCancel, + onOk, +}: ModalProps) => { + const [isOpen, setIsOpen] = useState(open) + const { t } = useTranslation() + + const finalCancelText = cancelText ?? t('chat_page.cancel_btn') + const finalOkText = okText ?? t('chat_page.ok_btn') + + useEffect(() => { + setIsOpen(open) + }, [open]) + + return createPortal( + + + + {title} + {showCloseIcon && + + } + + + + {children} + + + + {showCancelBtn && + {finalCancelText} + } + + {showOkBtn && + {finalOkText} + } + + + + , + document.body, + ) +}) + +Modal.displayName = 'Modal' diff --git a/packages/gpt-runner-web/client/src/components/modal/modal.styles.ts b/packages/gpt-runner-web/client/src/components/modal/modal.styles.ts new file mode 100644 index 0000000..e9ca456 --- /dev/null +++ b/packages/gpt-runner-web/client/src/components/modal/modal.styles.ts @@ -0,0 +1,72 @@ +import { styled } from 'styled-components' +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' +import { Icon } from '../icon' + +export const ModalWrapper = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + font-size: var(--type-ramp-base-font-size); + color: var(--foreground); +` + +export const ModalContentWrapper = styled.div` + display: flex; + flex-direction: column; + max-width: 100%; + max-height: 80vh; + width: min(500px, calc(100vw -1rem)); + overflow: hidden; + background: var(--panel-view-background); + border-radius: 0.5rem; + position: relative; +` + +export const ModalContentHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding: 1rem; + flex-shrink: 0; + width: 100%; +` + +export const ModalContentFooter = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 1rem; + padding: 1rem; + flex-shrink: 0; + width: 100%; +` + +export const ModalContent = styled.div` + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 0 1rem; +` + +export const ModalTitle = styled.div` + width: 100%; + font-size: 1.2rem; +` + +export const CloseButton = styled(Icon)` + margin-left: 1rem; +` + +export const StyledFooterButton = styled(VSCodeButton)` + border-radius: 0.25rem; +` diff --git a/packages/gpt-runner-web/client/src/components/panel-tab/index.tsx b/packages/gpt-runner-web/client/src/components/panel-tab/index.tsx index f703717..e02038a 100644 --- a/packages/gpt-runner-web/client/src/components/panel-tab/index.tsx +++ b/packages/gpt-runner-web/client/src/components/panel-tab/index.tsx @@ -7,21 +7,17 @@ import { PanelTabContent, } from './panel-tab.styles' -export interface PanelTabProps extends Pick, 'defaultActiveId' | 'items' | 'onChange' | 'activeId'> { +export interface PanelTabProps extends Pick, 'defaultActiveId' | 'items' | 'onChange' | 'activeId' | 'tabListStyles' | 'tabItemStyles'> { style?: CSSProperties - tabStyle?: CSSProperties } export function PanelTab_(props: PanelTabProps) { - const { style, tabStyle, ...otherProps } = props + const { style, ...otherProps } = props return ( - + ) diff --git a/packages/gpt-runner-web/client/src/components/tab/index.tsx b/packages/gpt-runner-web/client/src/components/tab/index.tsx index b0fc2d9..bb40037 100644 --- a/packages/gpt-runner-web/client/src/components/tab/index.tsx +++ b/packages/gpt-runner-web/client/src/components/tab/index.tsx @@ -1,16 +1,16 @@ import type { CSSProperties, ReactNode } from 'react' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useMotionValue } from 'framer-motion' import { useElementSizeRealTime } from '../../hooks/use-element-size-real-time.hook' -import { Icon } from '../icon' import { useDebounceFn } from '../../hooks/use-debounce-fn.hook' import { isElementVisible } from '../../helpers/utils' import { ActiveTabIndicator, - MoreIcon, + MoreIconWrapper, MoreList, MoreListItem, MoreWrapper, + StyledMoreIcon, TabContainer, TabItemLabel, TabItemWrapper, @@ -23,14 +23,14 @@ export interface TabItem { label: ReactNode id: T children?: ReactNode - visible?: boolean } export interface TabProps { defaultActiveId?: T activeId?: T items: TabItem[] - style?: CSSProperties + tabListStyles?: CSSProperties + tabItemStyles?: CSSProperties onChange?: (activeTabId: T) => void } @@ -39,16 +39,18 @@ export function Tab_(props: TabProps) { defaultActiveId, activeId: activeIdFromProp, items = [], + tabListStyles, + tabItemStyles, onChange: onChangeFromProp, } = props - const DEFAULT_ACTIVE_ID = items[0].id + const DEFAULT_ACTIVE_ID = items?.[0]?.id const TAB_ID_ATTR = 'data-tab-id' const [activeIdFromPrivate, setActiveIdFromPrivate] = useState() - const [showMore, setShowMore] = useState(false) const [moreList, setMoreList] = useState[]>([]) const [moreListVisible, setMoreListVisible] = useState(false) const [tabRef, tabSize] = useElementSizeRealTime() + const showMore = moreList.length > 0 // motion const indicatorWidth = useMotionValue(0) @@ -58,6 +60,8 @@ export function Tab_(props: TabProps) { return activeIdFromProp ?? activeIdFromPrivate ?? defaultActiveId ?? DEFAULT_ACTIVE_ID }, [activeIdFromProp, activeIdFromPrivate, defaultActiveId]) + const activeIdHistory = useRef([]) + const setActiveId = useCallback((id: T) => { onChangeFromProp ? onChangeFromProp(id) : setActiveIdFromPrivate(id) }, [onChangeFromProp]) @@ -71,6 +75,20 @@ export function Tab_(props: TabProps) { return map }, [items]) + useEffect(() => { + // if items change and the activeId is not in the new items, set the activeId to the previous activeId + if (!tabIdTabItemMap[activeId]) { + const validActiveId = activeIdHistory.current.reverse().find(id => Boolean(tabIdTabItemMap[id])) || items?.[0]?.id + setActiveId(validActiveId) + } + }, [items, activeId, setActiveId, tabIdTabItemMap]) + + useEffect(() => { + // update activeIdHistory + if (activeIdHistory.current[activeIdHistory.current.length - 1] !== activeId) + activeIdHistory.current.push(activeId) + }, [activeId]) + const getTabItemById = useCallback((id: T): TabItem | undefined => { return tabIdTabItemMap[id] }, [tabIdTabItemMap]) @@ -88,31 +106,19 @@ export function Tab_(props: TabProps) { setMoreListVisible(false) }, [setActiveId, setMoreListVisible]) - const calcTabChildrenWidth = useCallback(() => { - const labelDoms = getLabelDoms() - - if (!labelDoms.length) - return 0 - - const tabsWidth = labelDoms.reduce((acc, cur) => { - return acc + cur.offsetWidth - }, 0) - - return tabsWidth - }, [getLabelDoms]) - useEffect(() => { const activeLabelDom = getActiveLabelDom() - if (!activeLabelDom) + if (!activeLabelDom || !tabRef.current) return - activeLabelDom.scrollIntoView({ + const scrollLeft = activeLabelDom.offsetLeft - (tabRef.current.offsetWidth / 2 - activeLabelDom.offsetWidth / 2) + + tabRef.current?.scrollTo({ + left: scrollLeft, behavior: 'smooth', - block: 'nearest', - inline: 'center', }) - }, [getActiveLabelDom, tabSize.width]) + }, [getActiveLabelDom, tabSize.width, tabRef.current, activeId]) // let it scroll when mouse wheel on tab list const handleTabListScroll = useCallback((event: React.WheelEvent) => { @@ -131,12 +137,11 @@ export function Tab_(props: TabProps) { const tabItem = getTabItemById(tabId) const isVisible = isElementVisible(item, item.parentElement!, 0.6) - if (!tabItem) + if (!tabItem || isVisible) return _moreList.push({ ...tabItem, - visible: isVisible, }) }) @@ -163,15 +168,18 @@ export function Tab_(props: TabProps) { }, [updateIndicatorPosition, tabSize.width]) useEffect(() => { - const tabsTotalWidth = calcTabChildrenWidth() - setShowMore(tabsTotalWidth > tabSize.width) updateMoreList() - }, [calcTabChildrenWidth, setShowMore, updateMoreList, tabSize.width]) + }, [updateMoreList, activeId, tabSize.width]) return ( - + {items.map(item => (
(props: TabProps) { {item.label} @@ -198,18 +207,17 @@ export function Tab_(props: TabProps) { {showMore && ( - setMoreListVisible(!moreListVisible)}> - - - {moreListVisible && ( - - {moreList.map(item => ( - !item.visible && handleTabItemClick(item)}> - {item.label} - - ))} - - )} + setMoreListVisible(!moreListVisible)}> + + + {moreListVisible && + {moreList.map(item => ( + handleTabItemClick(item)}> + {item.label} + + ))} + + } )} diff --git a/packages/gpt-runner-web/client/src/components/tab/tab.styles.ts b/packages/gpt-runner-web/client/src/components/tab/tab.styles.ts index 59e6c91..b439d5a 100644 --- a/packages/gpt-runner-web/client/src/components/tab/tab.styles.ts +++ b/packages/gpt-runner-web/client/src/components/tab/tab.styles.ts @@ -1,5 +1,6 @@ import { motion } from 'framer-motion' import styled from 'styled-components' +import { Icon } from '../icon' export const TabContainer = styled.div` display: flex; @@ -13,7 +14,7 @@ export const TabListHeader = styled.div` padding: calc(var(--border-width) * 3px) 1rem; &[data-show-more=true] { - padding-right: calc(var(--type-ramp-minus1-font-size) * 2 + 1rem); + padding-right: 2rem; } ` @@ -67,7 +68,6 @@ export const ActiveTabIndicator = styled(motion.div)` export const MoreWrapper = styled.div` height: 100%; - padding: 0 var(--type-ramp-minus1-font-size); background-color: var(--panel-view-background); position: absolute; right: 0; @@ -78,14 +78,31 @@ export const MoreWrapper = styled.div` align-items: center; ` -export const MoreIcon = styled.div` - display: inline-block; +export const MoreIconWrapper = styled.div` + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 1.2rem; + width: 1.5rem; + height: 1.5rem; + margin: 0 0.25rem; + border-radius: 0.25rem; + overflow: hidden; + cursor: pointer; + + &:hover { + background: var(--panel-view-border); + } +` + +export const StyledMoreIcon = styled(Icon)` + ` export const MoreList = styled.div` min-width: var(--input-min-width); - padding: 1rem var(--type-ramp-minus1-font-size); - padding-bottom: 0; + padding: 0.5rem; + border: 1px solid var(--panel-view-border); position: absolute; z-index: 3; /* top: 33px; */ @@ -100,7 +117,12 @@ export const MoreListItem = styled.div` text-align: center; color: var(--panel-tab-foreground); font-size: var(--type-ramp-base-font-size); - margin-bottom: 1rem; + padding-bottom: 0.5rem; + cursor: pointer; + + &:last-child { + padding-bottom: 0; + } ` export const TabView = styled.div` diff --git a/packages/gpt-runner-web/client/src/components/tree-item/index.tsx b/packages/gpt-runner-web/client/src/components/tree-item/index.tsx index 76dfdd8..f6ae322 100644 --- a/packages/gpt-runner-web/client/src/components/tree-item/index.tsx +++ b/packages/gpt-runner-web/client/src/components/tree-item/index.tsx @@ -25,7 +25,7 @@ export interface TreeItemProps) => React.ReactNode onExpand?: (props: TreeItemState) => void onCollapse?: (props: TreeItemState) => void - onClick?: (props: TreeItemState) => void + onClick?: (props: TreeItemState) => void | boolean onContextMenu?: (props: TreeItemState) => void } @@ -56,6 +56,11 @@ export function TreeItem_ { + const isStop = onClick?.(stateProps) + + if (isStop === false) + return + if (!isLeaf) { if (isExpanded) onCollapse?.({ ...stateProps, isExpanded: false }) @@ -63,7 +68,6 @@ export function TreeItem_>() { const [isHover, setIsHover] = useState(false) - const isHoverRef = useRef(isHover) const ref = useRef(null) as Ref const handleMouseEnter = () => { - isHoverRef.current = true setIsHover(true) } const handleMouseLeave = async () => { - isHoverRef.current = false setIsHover(false) } @@ -30,7 +27,7 @@ export function useHover>() { } }, [ref.current]) - return [ref, isHover, isHoverRef] as const + return [ref, isHover] as const } export function useHoverByMouseLocation>() { diff --git a/packages/gpt-runner-web/client/src/hooks/use-keyboard.hook.ts b/packages/gpt-runner-web/client/src/hooks/use-keyboard.hook.ts index 5e0d3d5..3c34a19 100644 --- a/packages/gpt-runner-web/client/src/hooks/use-keyboard.hook.ts +++ b/packages/gpt-runner-web/client/src/hooks/use-keyboard.hook.ts @@ -1,9 +1,9 @@ // useKeyboard.ts -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import type { Callback } from 'keyboardjs' import keyboardjs from 'keyboardjs' -export function useKeyboard(key: string, onPress: Callback, onRelease?: Callback): void { +export function useKeyboard(key: T, onPress: Callback, onRelease?: Callback): void { useEffect(() => { keyboardjs.bind( key, @@ -16,3 +16,15 @@ export function useKeyboard(key: string, onPress: Callback, onRelease?: Callback } }, [key, onPress, onRelease]) } + +export function useKeyIsPressed(key: T): boolean { + const [isPressed, setIsPressed] = useState(false) + + useKeyboard( + key, + () => setIsPressed(true), + () => setIsPressed(false), + ) + + return isPressed +} diff --git a/packages/gpt-runner-web/client/src/hooks/use-modal.hook.ts b/packages/gpt-runner-web/client/src/hooks/use-modal.hook.ts new file mode 100644 index 0000000..2157292 --- /dev/null +++ b/packages/gpt-runner-web/client/src/hooks/use-modal.hook.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' +import { ModalContext } from '../store/context/modal-context' + +export function useModal() { + const context = useContext(ModalContext) + + if (!context) + throw new Error('useModal must be used within a ModalProvider') + + return context +} diff --git a/packages/gpt-runner-web/client/src/networks/editor.ts b/packages/gpt-runner-web/client/src/networks/editor.ts index 5cd22d2..0b1782f 100644 --- a/packages/gpt-runner-web/client/src/networks/editor.ts +++ b/packages/gpt-runner-web/client/src/networks/editor.ts @@ -1,4 +1,4 @@ -import type { BaseResponse, OpenEditorReqParams, OpenEditorResData } from '@nicepkg/gpt-runner-shared/common' +import { type BaseResponse, type CreateFilePathReqParams, type CreateFilePathResData, type DeleteFilePathReqParams, type DeleteFilePathResData, type GetFileInfoReqParams, type GetFileInfoResData, type OpenEditorReqParams, type OpenEditorResData, type RenameFilePathReqParams, type RenameFilePathResData, type SaveFileContentReqParams, type SaveFileContentResData, objectToQueryString } from '@nicepkg/gpt-runner-shared/common' import { getGlobalConfig } from '../helpers/global-config' import { myFetch } from '../helpers/fetch' @@ -14,3 +14,54 @@ export async function openEditor(params: OpenEditorReqParams): Promise> { + return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/create-file-path`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) +} + +export async function renameFilePath(params: RenameFilePathReqParams): Promise> { + return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/rename-file-path`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) +} + +export async function deleteFilePath(params: DeleteFilePathReqParams): Promise> { + return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/delete-file-path`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) +} + +export async function getFileInfo(params: GetFileInfoReqParams): Promise> { + return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/get-file-info?${objectToQueryString({ + ...params, + })}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) +} + +export async function saveFileContent(params: SaveFileContentReqParams): Promise> { + return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/save-file-content`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) +} diff --git a/packages/gpt-runner-web/client/src/pages/chat/chat.styles.ts b/packages/gpt-runner-web/client/src/pages/chat/chat.styles.ts index 053a0b5..00ea151 100644 --- a/packages/gpt-runner-web/client/src/pages/chat/chat.styles.ts +++ b/packages/gpt-runner-web/client/src/pages/chat/chat.styles.ts @@ -2,7 +2,7 @@ import { css, styled } from 'styled-components' import { VSCodePanels } from '@vscode/webview-ui-toolkit/react' import { withBreakpoint } from '../../helpers/with-breakpoint' -export const ContentWrapper = styled.div<{ $isPopoverContent: boolean; $isTopToolbarPopover?: boolean }>` +export const ContentWrapper = styled.div<{ $isPopoverContent?: boolean; $isTopToolbarPopover?: boolean }>` width: 100%; height: 100%; flex-shrink: 0; diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/chat-panel/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/components/chat-panel/index.tsx index 2ab0638..03bf609 100644 --- a/packages/gpt-runner-web/client/src/pages/chat/components/chat-panel/index.tsx +++ b/packages/gpt-runner-web/client/src/pages/chat/components/chat-panel/index.tsx @@ -127,7 +127,7 @@ export const ChatPanel: FC = memo((props) => { catch (error) { toast.error(getErrorMsg(error)) } - }, []) + }, [t]) // insert codes const handleInsertCodes = useCallback((value: string) => { @@ -203,7 +203,7 @@ export const ChatPanel: FC = memo((props) => { inputtingPrompt: t('chat_page.continue_inputting_prompt'), }, false) generateCurrentChatAnswer() - }, [chatInstance, updateCurrentChatInstance, generateCurrentChatAnswer]) + }, [chatInstance, updateCurrentChatInstance, generateCurrentChatAnswer, t]) // stop const handleStopGenerateAnswer = useCallback(() => { diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/chat-sidebar/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/components/chat-sidebar/index.tsx index 34e99cc..b7210ec 100644 --- a/packages/gpt-runner-web/client/src/pages/chat/components/chat-sidebar/index.tsx +++ b/packages/gpt-runner-web/client/src/pages/chat/components/chat-sidebar/index.tsx @@ -15,7 +15,10 @@ import { useChatInstance } from '../../../../hooks/use-chat-instance.hook' import { useOn } from '../../../../hooks/use-on.hook' import { getGlobalConfig } from '../../../../helpers/global-config' import { emitter } from '../../../../helpers/emitter' -import { MAYBE_IDE } from '../../../../helpers/constant' +import { IS_SAFE } from '../../../../helpers/constant' +import { useFileEditorStore } from '../../../../store/zustand/file-editor' +import { useKeyIsPressed } from '../../../../hooks/use-keyboard.hook' +import { openEditor } from '../../../../networks/editor' import { StyledIcon } from './chat-sidebar.styles' export interface ChatSidebarProps { @@ -39,11 +42,12 @@ export const ChatSidebar: FC = memo((props) => { updateSidebarTreeFromRemote, } = useGlobalStore() + const { addFileEditorItem } = useFileEditorStore() const [isLoading, setIsLoading] = useState(false) - const { removeChatInstance } = useChatInstance({ chatId, }) + const isPressedCtrl = useKeyIsPressed(['command', 'ctrl']) useEffect(() => { expandChatTreeItem(chatId) @@ -81,9 +85,21 @@ export const ChatSidebar: FC = memo((props) => { const handleClickTreeItem = useCallback((props: TreeItemState) => { const { otherInfo } = props + if (otherInfo?.type === GptFileTreeItemType.File + && otherInfo?.path + && isPressedCtrl + && IS_SAFE + ) { + // pressed ctrl + click then open file in editor + openEditor({ + path: otherInfo.path, + }) + return false + } + if (otherInfo?.type === GptFileTreeItemType.Chat) updateActiveChatId(otherInfo.id) - }, [updateActiveChatId]) + }, [updateActiveChatId, isPressedCtrl]) const renderTreeItemLeftSlot = useCallback((props: TreeItemState) => { const { isLeaf, isExpanded, otherInfo } = props @@ -118,8 +134,16 @@ export const ChatSidebar: FC = memo((props) => { if (!path) return - if (getGlobalConfig().editFileInIde) + if (getGlobalConfig().editFileInIde) { emitter.emit(ClientEventName.OpenFileInIde, { filePath: path }) + return + } + + if (IS_SAFE) { + addFileEditorItem({ + fullPath: path, + }) + } } if (otherInfo?.type === GptFileTreeItemType.Chat && isLeaf) { @@ -135,7 +159,7 @@ export const ChatSidebar: FC = memo((props) => { if (otherInfo?.type === GptFileTreeItemType.File) { return <> {/* TODO: implement edit file in web */} - {isHovering && MAYBE_IDE && { + fileItemRenderInfo: FileItemRenderInfo + onItemUpdate?: (item: FileEditorItem) => void + onSave?: (item: FileEditorItem) => void +} + +export const FileContentEditor: FC = memo((props) => { + const { fileItemRenderInfo, onItemUpdate, onSave, onChange, ...editorProps } = props + + const { item } = fileItemRenderInfo + + if (!fileItemRenderInfo) + return null + + return + { + onChange?.(value || '', e) + onItemUpdate?.({ + ...item, + fixed: true, + editingContent: value || '', + }) + }} + onSave={() => { + if (item.editingContent === item.sourceContent) + return + onSave?.(item) + }} + + {...editorProps} + > + +}) + +FileContentEditor.displayName = 'FileContentEditor' diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/file-editor-tab-label.styles.ts b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/file-editor-tab-label.styles.ts new file mode 100644 index 0000000..1c10dea --- /dev/null +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/file-editor-tab-label.styles.ts @@ -0,0 +1,49 @@ +import { styled } from 'styled-components' +import { Icon } from '../../../../../../components/icon' + +export const TabLabelWrapper = styled.div` + display: flex; + flex-shrink: 0; + align-items: center; + height: 100%; + font-size: var(--type-ramp-base-font-size); + color: var(--foreground); + background: var(--panel-view-background); + cursor: pointer; + user-select: none; + padding: 0 0.25rem; + min-width: max-content; + width: 100%; + + &:hover { + background: var(--panel-view-border); + } +` + +export const TabLabelLeft = styled.div` + display: flex; + align-items: center; + height: 100%; + flex-shrink: 0; + margin-left: 0.25rem; +` + +export const TabLabelCenter = styled(TabLabelLeft)<{ $fixed: boolean }>` + flex-shrink: 1; + flex: 1; + font-style: ${({ $fixed }) => $fixed ? 'normal' : 'italic'}; +` + +export const TabLabelRight = styled(TabLabelLeft)` + margin-left: 0; +` + +export const StyledRightIcon = styled(Icon)` + padding: 0.25rem; + border-radius: 0.25rem; + margin-left: 0.1rem; + + &:hover { + background: var(--panel-view-border); + } +` diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/index.tsx new file mode 100644 index 0000000..8929def --- /dev/null +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/components/file-editor-tab-label/index.tsx @@ -0,0 +1,119 @@ +import { memo, useCallback } from 'react' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import type { FileItemRenderInfo } from '../../shared' +import { getIconComponent } from '../../../../../../helpers/file-icons/utils' +import { useHover } from '../../../../../../hooks/use-hover.hook' +import type { FileEditorItem } from '../../../../../../store/zustand/file-editor/file-editor-item' +import { useModal } from '../../../../../../hooks/use-modal.hook' +import { useKeyIsPressed } from '../../../../../../hooks/use-keyboard.hook' +import { openEditor } from '../../../../../../networks/editor' +import { StyledRightIcon, TabLabelCenter, TabLabelLeft, TabLabelRight, TabLabelWrapper } from './file-editor-tab-label.styles' + +export interface FileEditorTabLabelProps { + fileItemRenderInfo: FileItemRenderInfo + onItemUpdate?: (item: FileEditorItem) => void + onRemoveItem?: (item: FileEditorItem) => void + onSave?: (item: FileEditorItem) => void +} + +export const FileEditorTabLabel: FC = memo((props) => { + const { fileItemRenderInfo, onItemUpdate, onRemoveItem, onSave } = props + + const { t } = useTranslation() + const { setModalProps } = useModal() + const [rightIconRef, isRightIconHovering] = useHover() + const isPressedCtrl = useKeyIsPressed(['ctrl', 'command']) + + const renderMaterialIconComponent = useCallback((fileItemRenderInfo: FileItemRenderInfo | undefined) => { + if (!fileItemRenderInfo) + return null + + const { fileName } = fileItemRenderInfo + + const MaterialSvgComponent = getIconComponent({ + isFolder: false, + name: fileName, + }) + + if (!MaterialSvgComponent) + return null + + return + }, []) + + const { displayTitle, item, fileName } = fileItemRenderInfo + + if (!fileItemRenderInfo) + return null + + return { + if (isPressedCtrl) { + openEditor({ + path: item.fullPath, + }) + } + }}> + + {/* left */} + + {renderMaterialIconComponent(fileItemRenderInfo)} + + + {/* center */} + { + if (!item.fixed) { + onItemUpdate?.({ + ...item, + fixed: true, + }) + } + }} + > + {displayTitle} + + + {/* right */} + + { + if (item.editingContent === item.sourceContent) { + onRemoveItem?.(item) + return + } + + setModalProps({ + open: true, + title: t('chat_page.file_editor_forgot_save_tips_title', { + fileName, + }), + children: t('chat_page.file_editor_forgot_save_tips_content'), + onCancel() { + setModalProps({ open: false }) + }, + async onOk() { + await onSave?.(item) + setModalProps({ open: false }) + onRemoveItem?.(item) + }, + }) + }} + > + + +}) + +FileEditorTabLabel.displayName = 'FileEditorTabLabel' diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/file-editor.styles.ts b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/file-editor.styles.ts new file mode 100644 index 0000000..dc3d013 --- /dev/null +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/file-editor.styles.ts @@ -0,0 +1,8 @@ +import { styled } from 'styled-components' + +export const FileEditorWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +` diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/index.tsx new file mode 100644 index 0000000..18e0411 --- /dev/null +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/index.tsx @@ -0,0 +1,173 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import type { FC } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import type { SaveFileContentReqParams } from '@nicepkg/gpt-runner-shared/common' +import { LoadingView } from '../../../../components/loading-view' +import { useFileEditorStore } from '../../../../store/zustand/file-editor' +import type { TabItem } from '../../../../components/tab' +import { PanelTab } from '../../../../components/panel-tab' +import type { FileEditorItem } from '../../../../store/zustand/file-editor/file-editor-item' +import { useElementSizeRealTime } from '../../../../hooks/use-element-size-real-time.hook' +import { saveFileContent } from '../../../../networks/editor' +import { FileEditorWrapper } from './file-editor.styles' +import { FileEditorTabLabel } from './components/file-editor-tab-label' +import { FileContentEditor } from './components/file-content-editor' + +interface FileItemRenderInfo { + item: FileEditorItem + maybeEndings: string + displayTitle: string + fileName: string + dirPath: string +} + +export interface FileEditorProps { + rootPath?: string + activeFileFullPath: string + onActiveFileChange: (item: FileEditorItem) => void +} + +export const FileEditor: FC = memo((props) => { + const { activeFileFullPath, onActiveFileChange } = props + const [fileEditorRef, { width: fileEditorWidth }] = useElementSizeRealTime() + + const { t } = useTranslation() + const { + fileEditorItems, + updateFileEditorItem, + removeFileEditorItem, + } = useFileEditorStore() + const [isLoading, setIsLoading] = useState(false) + const { mutate: saveFileToRemote, isLoading: saveFileLoading } = useMutation({ + mutationFn: (params: SaveFileContentReqParams) => saveFileContent(params), + }) + + const saveFile = useCallback(async (item: FileEditorItem) => { + await saveFileToRemote({ + fileFullPath: item.fullPath, + content: item.editingContent, + }) + + updateFileEditorItem(item.fullPath, { + ...item, + sourceContent: item.editingContent, + }) + }, [saveFileToRemote]) + + const fullPathMapRenderInfo = useMemo>(() => { + const map: Record = {} + const fileNameMap = new Map() + const repeatedFileNames: Set = new Set() + + fileEditorItems.forEach((item) => { + const { fullPath } = item + const splitPaths = fullPath.split('/') + const fileName = splitPaths[splitPaths.length - 1] || '' + const dirPath = splitPaths.slice(0, splitPaths.length - 1).join('/') + const parentPathItem = splitPaths[splitPaths.length - 2] || '' + const maybeEndings = parentPathItem ? `.../${parentPathItem}` : '' + + map[fullPath] = { + item, + maybeEndings, + displayTitle: fileName, + fileName, + dirPath, + } + + if (fileName) { + const count = fileNameMap.get(fileName) || 0 + fileNameMap.set(fileName, count + 1) + + if (count > 0) + repeatedFileNames.add(fileName) + } + }) + + fileEditorItems.forEach((item) => { + const { fullPath } = item + const { fileName, maybeEndings } = map[fullPath] + + // if repeated, show parent path + if (repeatedFileNames.has(fileName)) + map[fullPath].displayTitle = `${fileName} ${maybeEndings}` + }) + + return map + }, [fileEditorItems]) + + const showEditorMinimap = useMemo(() => { + return fileEditorWidth > 800 + }, [fileEditorWidth]) + + const fileTabItems = useMemo[]>(() => { + return fileEditorItems.map((item) => { + const itemRenderInfo = fullPathMapRenderInfo[item.fullPath] + const onItemUpdate = (item: FileEditorItem) => { + updateFileEditorItem(item.fullPath, item) + } + + const onRemoveItem = (item: FileEditorItem) => { + removeFileEditorItem(item.fullPath) + } + + return { + id: item.fullPath, + + label: , + + children: , + } satisfies TabItem + }) + }, [ + fileEditorItems, + fullPathMapRenderInfo, + showEditorMinimap, + saveFile, + ]) + + useEffect(() => { + setIsLoading(saveFileLoading) + }, [saveFileLoading]) + + return + {isLoading && } + + { + const item = fullPathMapRenderInfo[id]?.item + if (item) + onActiveFileChange(item) + }} + style={{ + flex: 1, + }} + tabListStyles={{ + justifyContent: 'flex-start', + }} + tabItemStyles={{ + padding: 0, + }} + /> + +}) + +FileEditor.displayName = 'FileEditor' diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/shared.ts b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/shared.ts new file mode 100644 index 0000000..84ceb7f --- /dev/null +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-editor/shared.ts @@ -0,0 +1,9 @@ +import type { FileEditorItem } from '../../../../store/zustand/file-editor/file-editor-item' + +export interface FileItemRenderInfo { + item: FileEditorItem + maybeEndings: string + displayTitle: string + fileName: string + dirPath: string +} diff --git a/packages/gpt-runner-web/client/src/pages/chat/components/file-tree/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/components/file-tree/index.tsx index 89946b0..9b5383c 100644 --- a/packages/gpt-runner-web/client/src/pages/chat/components/file-tree/index.tsx +++ b/packages/gpt-runner-web/client/src/pages/chat/components/file-tree/index.tsx @@ -23,6 +23,10 @@ import { getIconComponent } from '../../../../helpers/file-icons/utils' import type { SvgComponent } from '../../../../types/common' import { getGlobalConfig } from '../../../../helpers/global-config' import { emitter } from '../../../../helpers/emitter' +import { IS_SAFE } from '../../../../helpers/constant' +import { useFileEditorStore } from '../../../../store/zustand/file-editor' +import { useKeyIsPressed } from '../../../../hooks/use-keyboard.hook' +import { openEditor } from '../../../../networks/editor' import { FileTreeItemRightWrapper, FileTreeSidebarHighlight, FileTreeSidebarUnderSearchWrapper, FilterWrapper } from './file-tree.styles' export interface FileTreeProps { @@ -50,6 +54,12 @@ export const FileTree: FC = memo((props: FileTreeProps) => { updateFilesTree, } = useTempStore() + const { + addFileEditorItem, + } = useFileEditorStore() + + const isPressedCtrl = useKeyIsPressed(['command', 'ctrl']) + const updateFileItem = useCallback((fileItemOrFullPath: FileSidebarTreeItem | string, updater: (fileItem: FileSidebarTreeItem) => void) => { const fullPath = typeof fileItemOrFullPath === 'string' ? fileItemOrFullPath : fileItemOrFullPath.otherInfo?.fullPath if (!fullPath) @@ -273,8 +283,24 @@ export const FileTree: FC = memo((props: FileTreeProps) => { emitter.emit(ClientEventName.OpenFileInIde, { filePath: fullPath, }) + return } - }, []) + + if (!IS_SAFE) + return + + if (isPressedCtrl) { + openEditor({ + path: fullPath, + }) + return + } + + addFileEditorItem({ + fullPath, + relativePath: otherInfo?.projectRelativePath, + }) + }, [isPressedCtrl]) const buildSearchRightSlot = useCallback(() => { const { allFileExts = [] } = fetchCommonFilesTreeRes?.data || {} diff --git a/packages/gpt-runner-web/client/src/pages/chat/index.tsx b/packages/gpt-runner-web/client/src/pages/chat/index.tsx index c3a5f0b..f1b3af7 100644 --- a/packages/gpt-runner-web/client/src/pages/chat/index.tsx +++ b/packages/gpt-runner-web/client/src/pages/chat/index.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import React, { memo, useCallback, useEffect, useState } from 'react' -import { ChatMessageStatus } from '@nicepkg/gpt-runner-shared/common' +import { ChatMessageStatus, ClientEventName } from '@nicepkg/gpt-runner-shared/common' import { useWindowSize } from 'react-use' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' @@ -19,6 +19,9 @@ import { useSize } from '../../hooks/use-size.hook' import { useGetCommonFilesTree } from '../../hooks/use-get-common-files-tree.hook' import type { TabItem } from '../../components/tab' import { IconButton } from '../../components/icon-button' +import { IS_SAFE } from '../../helpers/constant' +import { useOn } from '../../hooks/use-on.hook' +import { useFileEditorStore } from '../../store/zustand/file-editor' import { ContentWrapper } from './chat.styles' import { ChatSidebar } from './components/chat-sidebar' import { ChatPanel } from './components/chat-panel' @@ -26,13 +29,18 @@ import { FileTree } from './components/file-tree' import { Settings, SettingsTabId } from './components/settings' import { InitGptFiles } from './components/init-gpt-files' import { TopToolbar } from './components/top-toolbar' +import { FileEditor } from './components/file-editor' -enum TabId { +enum MobileTabId { Presets = 'presets', Chat = 'chat', Files = 'files', - Settings = 'settings', - About = 'about', + FileEditor = 'file-editor', +} + +enum PcTabId { + Chat = 'chat', + FileEditor = 'file-editor', } const Chat: FC = memo(() => { @@ -43,10 +51,12 @@ const Chat: FC = memo(() => { const { activeChatId, sidebarTree, updateActiveChatId, updateSidebarTreeFromRemote } = useGlobalStore() const { chatInstance } = useChatInstance({ chatId: activeChatId }) const [scrollDownRef, scrollDown, getScrollBottom] = useScrollDown() - const [tabActiveId, setTabActiveId] = useState(TabId.Presets) + const [mobileTabActiveId, setMobileTabActiveId] = useState(MobileTabId.Presets) + const [pcTabActiveId, setPcTabActiveId] = useState(PcTabId.Chat) const showFileTreeOnRightSide = windowWidth >= 1000 const chatPanelHeight = windowHeight - toolbarHeight const [isOpenTreeDrawer, setIsOpenTreeDrawer] = useState(true) + const { activeFileFullPath, updateActiveFileFullPath } = useFileEditorStore() const { data: fetchProjectInfoRes } = useQuery({ queryKey: ['fetchProjectInfo'], @@ -65,7 +75,9 @@ const Chat: FC = memo(() => { // when active chat id change, change tab active id useEffect(() => { - setTabActiveId(activeChatId ? TabId.Chat : TabId.Presets) + setMobileTabActiveId(activeChatId ? MobileTabId.Chat : MobileTabId.Presets) + if (activeChatId) + setPcTabActiveId(PcTabId.Chat) }, [activeChatId, isMobile]) // any status will scroll down @@ -87,6 +99,15 @@ const Chat: FC = memo(() => { }, 0) }, [scrollDownRef.current]) + useOn({ + eventName: ClientEventName.OpenFileInFileEditor, + listener: ({ fileFullPath }) => { + setPcTabActiveId(PcTabId.FileEditor) + setMobileTabActiveId(MobileTabId.FileEditor) + updateActiveFileFullPath(fileFullPath) + }, + }) + const renderSidebar = useCallback((isPopover = false, reverseTreeUi?: boolean) => { if (!rootPath) return null @@ -114,6 +135,21 @@ const Chat: FC = memo(() => { }, [activeChatId]) + const renderFileEditor = useCallback(() => { + if (!rootPath) + return null + + return + { + updateActiveFileFullPath(item.fullPath) + }} + > + + }, [activeFileFullPath, rootPath]) + const renderChatPanel = useCallback(() => { return { return const renderChat = () => { + // mobile if (isMobile) { - const tabIdViewMap: TabItem[] = [ + const mobileTabItems: TabItem[] = [ { - id: TabId.Presets, + id: MobileTabId.Presets, label: t('chat_page.tab_presets'), children: renderSidebar(), }, { - id: TabId.Chat, + id: MobileTabId.Chat, label: t('chat_page.tab_chat'), children: renderChatPanel(), }, { - id: TabId.Files, + id: MobileTabId.Files, label: t('chat_page.tab_files'), children: renderFileTree(), }, ] - return + if (IS_SAFE) { + mobileTabItems.push( + { + id: MobileTabId.FileEditor, + label: t('chat_page.tab_file_editor'), + children: renderFileEditor(), + }) + } + + return } + // pc + const pcTabItems: TabItem[] = [ + { + id: PcTabId.Chat, + label: t('chat_page.tab_chat'), + children: renderChatPanel(), + }, + { + id: PcTabId.FileEditor, + label: t('chat_page.tab_file_editor'), + children: renderFileEditor(), + }, + ] + return { {renderSidebar()} - {renderChatPanel()} + {IS_SAFE + ? + : renderChatPanel()} {showFileTreeOnRightSide ? void +} + +export const ModalContext = createContext(undefined) + +interface ModalProviderProps { + children: ReactNode +} + +export const ModalProvider: React.FC = ({ children }) => { + const [modalProps, setModalProps] = useState({ + open: false, + title: '', + }) + + return ( + + + + {children} + + ) +} diff --git a/packages/gpt-runner-web/client/src/store/zustand/file-editor/file-editor-item.ts b/packages/gpt-runner-web/client/src/store/zustand/file-editor/file-editor-item.ts new file mode 100644 index 0000000..09f26fe --- /dev/null +++ b/packages/gpt-runner-web/client/src/store/zustand/file-editor/file-editor-item.ts @@ -0,0 +1,43 @@ +import type { PartialKeys } from '@nicepkg/gpt-runner-shared/common' + +export enum FileEditorPermission { + CanEdit = 'canEdit', + CanPreview = 'canPreview', + CanSave = 'canSave', +} + +export interface IFileEditorItem { + relativePath?: string + fullPath: string + sourceContent: string + editingContent: string + fixed: boolean + permissions: FileEditorPermission[] +} + +export type FileEditorItemOptions = PartialKeys + +export class FileEditorItem implements IFileEditorItem { + relativePath?: string + fullPath: string + sourceContent: string + editingContent: string + fixed: boolean + permissions: FileEditorPermission[] + + constructor({ + relativePath, + fullPath, + sourceContent, + editingContent, + fixed, + permissions, + }: FileEditorItemOptions) { + this.relativePath = relativePath + this.fullPath = fullPath + this.sourceContent = sourceContent || '' + this.editingContent = editingContent || '' + this.fixed = fixed || false + this.permissions = permissions || Object.values(FileEditorPermission) + } +} diff --git a/packages/gpt-runner-web/client/src/store/zustand/file-editor/index.ts b/packages/gpt-runner-web/client/src/store/zustand/file-editor/index.ts new file mode 100644 index 0000000..71b3bf5 --- /dev/null +++ b/packages/gpt-runner-web/client/src/store/zustand/file-editor/index.ts @@ -0,0 +1,130 @@ +import type { StateCreator } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import { ClientEventName, getErrorMsg } from '@nicepkg/gpt-runner-shared/common' +import { toast } from 'react-hot-toast' +import type { GetState } from '../types' +import { createStore } from '../utils' +import { CustomStorage } from '../storage' +import { emitter } from '../../../helpers/emitter' +import { IS_SAFE } from '../../../helpers/constant' +import { getFileInfo } from '../../../networks/editor' +import type { FileEditorItemOptions } from './file-editor-item' +import { FileEditorItem } from './file-editor-item' + +export interface FileEditorSlice { + activeFileFullPath?: string + fileEditorItems: FileEditorItem[] + updateActiveFileFullPath: (fullPath?: string) => void + updateFileEditorItems: (items: FileEditorItem[]) => void + addFileEditorItem: (item: FileEditorItem | FileEditorItemOptions) => Promise + removeFileEditorItem: (fullPath: string) => void + updateFileEditorItem: (fullPath: string, item: Partial) => void + getFileEditorItem: (fullPath: string) => FileEditorItem | undefined +} + +export type FileEditorState = GetState + +function getInitialState() { + return { + activeFileFullPath: '', + fileEditorItems: [], + } satisfies FileEditorState +} + +export const createFileEditorSlice: StateCreator< + FileEditorSlice, + [], + [], + FileEditorSlice +> = (set, get) => ({ + ...getInitialState(), + updateActiveFileFullPath(fullPath) { + set({ + activeFileFullPath: fullPath, + }) + }, + updateFileEditorItems(items) { + set({ + fileEditorItems: items, + }) + }, + async addFileEditorItem(item) { + const state = get() + const existingItem = state.getFileEditorItem(item.fullPath) + + if (!IS_SAFE) + return + + try { + if (!existingItem) { + // remove all (fixed === false) item + const oldItems = state.fileEditorItems.filter(item => item.fixed) + const newItem = item instanceof FileEditorItem ? item : new FileEditorItem(item) + + // update sourceContent for new item + const newItemFileInfoRes = await getFileInfo({ + fileFullPath: item.fullPath, + }) + + newItem.sourceContent = newItemFileInfoRes?.data?.content ?? '' + newItem.editingContent = newItem.sourceContent + + set({ + fileEditorItems: [...oldItems, newItem], + }) + } + + emitter.emit(ClientEventName.OpenFileInFileEditor, { + fileFullPath: item.fullPath, + }) + } + catch (err: any) { + const errMsg = getErrorMsg(err) + toast.error(errMsg) + } + }, + removeFileEditorItem(fullPath) { + const state = get() + + set({ + fileEditorItems: state.fileEditorItems.filter(item => item.fullPath !== fullPath), + }) + }, + updateFileEditorItem(fullPath, item) { + const state = get() + + set({ + fileEditorItems: state.fileEditorItems.map((oldItem) => { + if (oldItem.fullPath === fullPath) { + return { + ...oldItem, + ...item, + } + } + + return oldItem + }), + }) + }, + getFileEditorItem(fullPath) { + const state = get() + + return state.fileEditorItems.find(item => item.fullPath === fullPath) + }, +}) + +export const useFileEditorStore = createStore('FileEditorStore', false)( + persist( + (...args) => ({ + ...createFileEditorSlice(...args), + }), + + { + name: 'file-editor-slice', // name of the item in the storage (must be unique) + storage: createJSONStorage(() => new CustomStorage({ + storage: localStorage, + syncToServer: false, + })), + }, + ), +) diff --git a/packages/gpt-runner-web/client/src/store/zustand/global/index.ts b/packages/gpt-runner-web/client/src/store/zustand/global/index.ts index 3593002..5a4a2ee 100644 --- a/packages/gpt-runner-web/client/src/store/zustand/global/index.ts +++ b/packages/gpt-runner-web/client/src/store/zustand/global/index.ts @@ -25,7 +25,10 @@ export const useGlobalStore = createStore('GlobalStore')( { name: 'global-slice', // name of the item in the storage (must be unique) - storage: createJSONStorage(() => new CustomStorage(localStorage)), // (optional) by default, 'localStorage' is used + storage: createJSONStorage(() => new CustomStorage({ + storage: localStorage, + syncToServer: true, + })), }, ), ) diff --git a/packages/gpt-runner-web/client/src/store/zustand/storage.ts b/packages/gpt-runner-web/client/src/store/zustand/storage.ts index fb08f16..a021794 100644 --- a/packages/gpt-runner-web/client/src/store/zustand/storage.ts +++ b/packages/gpt-runner-web/client/src/store/zustand/storage.ts @@ -38,20 +38,37 @@ function debounceSaveStateToServer(key: string, value: ServerStorageValue) { debounceSaveStateToServerFn(key, value) } +export interface CustomStorageOptions { + storage: Storage + namespace?: string + syncToServer?: boolean +} + export class CustomStorage implements StateStorage { #storage: Storage + #prefixKey: string + #syncToServer: boolean - static get prefixKey() { + static get basePrefixKey() { const rootPath = getGlobalConfig().rootPath return `gpt-runner:${rootPath}:` } - constructor(storage: Storage) { + constructor(options: CustomStorageOptions) { + const { storage, namespace, syncToServer } = options + const finalNamespace = namespace ? `${namespace}:` : '' + this.#storage = storage + this.#prefixKey = `${CustomStorage.basePrefixKey + finalNamespace}` + this.#syncToServer = syncToServer ?? false } getItem = async (key: string) => { - const finalKey = CustomStorage.prefixKey + key + const finalKey = this.#prefixKey + key + + if (!this.#syncToServer) + return this.#storage.getItem(finalKey) + const remoteSourceValue = await getStateFromServerOnce(finalKey) if (remoteSourceValue !== undefined) { @@ -65,19 +82,23 @@ export class CustomStorage implements StateStorage { } setItem = (key: string, value: string) => { - const finalKey = CustomStorage.prefixKey + key + const finalKey = this.#prefixKey + key - // save to server - debounceSaveStateToServer(finalKey, tryParseJson(value)) + if (this.#syncToServer) { + // save to server + debounceSaveStateToServer(finalKey, tryParseJson(value)) + } return this.#storage.setItem(finalKey, value) } removeItem = (key: string) => { - const finalKey = CustomStorage.prefixKey + key + const finalKey = this.#prefixKey + key + if (!this.#syncToServer) { // save to server - debounceSaveStateToServer(finalKey, null) + debounceSaveStateToServer(finalKey, null) + } return this.#storage.removeItem(finalKey) } diff --git a/packages/gpt-runner-web/client/src/styles/theme.styles.ts b/packages/gpt-runner-web/client/src/styles/theme.styles.ts index 207aa5f..6193cf2 100644 --- a/packages/gpt-runner-web/client/src/styles/theme.styles.ts +++ b/packages/gpt-runner-web/client/src/styles/theme.styles.ts @@ -12,8 +12,6 @@ function buildThemeCssString(themes: typeof themeMap) { finalThemeString += ` body[data-theme="${themeName}"] > * { ${themeString} - color: var(--foreground); - background: var(--background); } ${theme['--background'] diff --git a/packages/gpt-runner-web/server/index.ts b/packages/gpt-runner-web/server/index.ts index 8ef13e1..772cbe5 100644 --- a/packages/gpt-runner-web/server/index.ts +++ b/packages/gpt-runner-web/server/index.ts @@ -6,7 +6,7 @@ import history from 'connect-history-api-fallback' import { PathUtils, getPort } from '@nicepkg/gpt-runner-shared/node' import { setProxyUrl } from './src/proxy' import { processControllers } from './src/controllers' -import { errorHandlerMiddleware } from './src/middleware' +import { errorHandlerMiddleware, safeCheckMiddleware } from './src/middleware' const dirname = PathUtils.getCurrentDirName(import.meta.url, () => __dirname) @@ -37,6 +37,8 @@ export async function startServer(props: StartServerProps): Promise { app.use(cors()) + app.use(safeCheckMiddleware) + processControllers(router) app.use(express.json({ limit: '25mb' })) diff --git a/packages/gpt-runner-web/server/src/controllers/editor.controller.ts b/packages/gpt-runner-web/server/src/controllers/editor.controller.ts index 45c5642..512da0a 100644 --- a/packages/gpt-runner-web/server/src/controllers/editor.controller.ts +++ b/packages/gpt-runner-web/server/src/controllers/editor.controller.ts @@ -1,6 +1,6 @@ -import { launchEditorByPathAndContent, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node' -import type { OpenEditorReqParams, OpenEditorResData } from '@nicepkg/gpt-runner-shared/common' -import { OpenEditorReqParamsSchema } from '@nicepkg/gpt-runner-shared/common' +import { FileUtils, PathUtils, launchEditorByPathAndContent, sendSuccessResponse, verifyParamsByZod } from '@nicepkg/gpt-runner-shared/node' +import type { CreateFilePathReqParams, CreateFilePathResData, DeleteFilePathReqParams, DeleteFilePathResData, GetFileInfoReqParams, GetFileInfoResData, OpenEditorReqParams, OpenEditorResData, RenameFilePathReqParams, RenameFilePathResData, SaveFileContentReqParams, SaveFileContentResData } from '@nicepkg/gpt-runner-shared/common' +import { CreateFilePathReqParamsSchema, DeleteFilePathReqParamsSchema, GetFileInfoReqParamsSchema, OpenEditorReqParamsSchema, RenameFilePathReqParamsSchema, SaveFileContentReqParamsSchema } from '@nicepkg/gpt-runner-shared/common' import type { ControllerConfig } from '../types' import { getValidFinalPath } from '../services/valid-path' @@ -10,6 +10,7 @@ export const editorControllers: ControllerConfig = { { url: '/open-editor', method: 'post', + requireSafe: true, handler: async (req, res) => { const body = req.body as OpenEditorReqParams @@ -33,5 +34,114 @@ export const editorControllers: ControllerConfig = { }) }, }, + + { + url: '/create-file-path', + method: 'post', + requireSafe: true, + handler: async (req, res) => { + const body = req.body as CreateFilePathReqParams + + verifyParamsByZod(body, CreateFilePathReqParamsSchema) + + const { fileFullPath, isDir } = body + + if (isDir) { + await FileUtils.ensurePath({ + filePath: fileFullPath, + }) + } + else { + await FileUtils.writeFile({ + filePath: fileFullPath, + content: '', + valid: false, + }) + } + + sendSuccessResponse(res, { + data: null satisfies CreateFilePathResData, + }) + }, + }, + { + url: '/rename-file-path', + method: 'post', + requireSafe: true, + handler: async (req, res) => { + const body = req.body as RenameFilePathReqParams + + verifyParamsByZod(body, RenameFilePathReqParamsSchema) + + const { oldFileFullPath, newFileFullPath } = body + + await FileUtils.movePath({ + oldPath: oldFileFullPath, + newPath: newFileFullPath, + }) + + sendSuccessResponse(res, { + data: null satisfies RenameFilePathResData, + }) + }, + }, + { + url: '/delete-file-path', + method: 'post', + requireSafe: true, + handler: async (req, res) => { + const body = req.body as DeleteFilePathReqParams + + verifyParamsByZod(body, DeleteFilePathReqParamsSchema) + + const { fileFullPath } = body + + await FileUtils.deletePath(fileFullPath) + + sendSuccessResponse(res, { + data: null satisfies DeleteFilePathResData, + }) + }, + }, + { + url: '/get-file-info', + method: 'get', + requireSafe: true, + handler: async (req, res) => { + const query = req.query as GetFileInfoReqParams + + verifyParamsByZod(query, GetFileInfoReqParamsSchema) + + const { fileFullPath } = query + + const content = await FileUtils.readFile({ filePath: fileFullPath }) + const isDir = PathUtils.isDirectory(fileFullPath) + + sendSuccessResponse(res, { + data: { + content, + isDir, + } satisfies GetFileInfoResData, + }) + }, + }, + { + url: '/save-file-content', + method: 'post', + requireSafe: true, + handler: async (req, res) => { + const body = req.body as SaveFileContentReqParams + + verifyParamsByZod(body, SaveFileContentReqParamsSchema) + + const { fileFullPath, content } = body + + await FileUtils.writeFile({ filePath: fileFullPath, content }) + + sendSuccessResponse(res, { + data: null satisfies SaveFileContentResData, + }) + }, + }, ], } diff --git a/packages/gpt-runner-web/server/src/controllers/gpt-files.controller.ts b/packages/gpt-runner-web/server/src/controllers/gpt-files.controller.ts index 44518a9..21986f9 100644 --- a/packages/gpt-runner-web/server/src/controllers/gpt-files.controller.ts +++ b/packages/gpt-runner-web/server/src/controllers/gpt-files.controller.ts @@ -45,6 +45,7 @@ export const gptFilesControllers: ControllerConfig = { { url: '/init-gpt-files', method: 'post', + requireSafe: true, handler: async (req, res) => { const body = req.body as InitGptFilesReqParams diff --git a/packages/gpt-runner-web/server/src/controllers/index.ts b/packages/gpt-runner-web/server/src/controllers/index.ts index 845592c..bbb3e49 100644 --- a/packages/gpt-runner-web/server/src/controllers/index.ts +++ b/packages/gpt-runner-web/server/src/controllers/index.ts @@ -1,6 +1,7 @@ -import type { NextFunction, Router } from 'express' +import type { NextFunction, Response, Router } from 'express' import { WssActionName, WssUtils, buildFailResponse } from '@nicepkg/gpt-runner-shared/common' -import type { Controller, ControllerConfig } from '../types' +import { sendFailResponse } from '@nicepkg/gpt-runner-shared/node' +import type { Controller, ControllerConfig, MyRequest } from '../types' import { llmControllers } from './llm.controller' import { commonFilesControllers } from './common-files.controller' import { configControllers } from './config.controller' @@ -23,10 +24,19 @@ export function processControllers(router: Router) { const { namespacePath, controllers } = controllerConfig controllers.forEach((controller) => { - const { url, method, handler } = controller + const { url, method, requireSafe, handler } = controller const withCatchHandler: Controller['handler'] = async function (this: any, ...args) { try { + const req = args[0] as MyRequest + const res = args[1] as Response + if (requireSafe && !req.isSafe) { + sendFailResponse(res, { + message: 'This request is not allowed without localhost access!', + }) + return + } + return await handler.apply(this, args) } catch (error) { @@ -35,7 +45,7 @@ export function processControllers(router: Router) { } } - router[method](`${namespacePath}${url}`, withCatchHandler) + router[method](`${namespacePath}${url}`, withCatchHandler as any) }) }) } diff --git a/packages/gpt-runner-web/server/src/controllers/storage.controller.ts b/packages/gpt-runner-web/server/src/controllers/storage.controller.ts index 80d550f..e5cc2ed 100644 --- a/packages/gpt-runner-web/server/src/controllers/storage.controller.ts +++ b/packages/gpt-runner-web/server/src/controllers/storage.controller.ts @@ -16,15 +16,12 @@ export const storageControllers: ControllerConfig = { verifyParamsByZod(query, StorageGetItemReqParamsSchema) const { key, storageName } = query - const { storage, cacheDir } = await getStorage(storageName) let value = await storage.get(key) - - const reqHostName = req.hostname - const isLocal = ['localhost', '127.0.0.1'].includes(reqHostName) + const isSafe = req.isSafe // Don't send secrets config to client when not open in localhost - if (storageName === ServerStorageName.SecretsConfig && !isLocal) { + if (storageName === ServerStorageName.SecretsConfig && !isSafe) { value = typeof value === 'object' ? Object.fromEntries(Object.entries(value || {}) .map(([k, v]) => [k, v ? '*'.repeat(v.length) : null])) @@ -50,11 +47,7 @@ export const storageControllers: ControllerConfig = { const { storageName, key, value } = body const { storage } = await getStorage(storageName) - const reqHostName = req.hostname - const isLocal = ['localhost', '127.0.0.1'].includes(reqHostName) - - // Don't send secrets config to client when not open in localhost - if (storageName === ServerStorageName.SecretsConfig && !isLocal) + if (storageName === ServerStorageName.SecretsConfig && !req.isSafe) throw new Error('Cannot set secrets config when not open in localhost') await storage.set(key, value) @@ -68,6 +61,7 @@ export const storageControllers: ControllerConfig = { { url: '/', method: 'delete', + requireSafe: true, handler: async (req, res) => { const body = req.body as StorageRemoveItemReqParams @@ -75,7 +69,6 @@ export const storageControllers: ControllerConfig = { const { key, storageName } = body const { storage } = await getStorage(storageName) - await storage.delete(key) sendSuccessResponse(res, { @@ -86,6 +79,7 @@ export const storageControllers: ControllerConfig = { { url: '/clear', method: 'post', + requireSafe: true, handler: async (req, res) => { const body = req.body as StorageClearReqParams @@ -93,7 +87,6 @@ export const storageControllers: ControllerConfig = { const { storageName } = body const { storage } = await getStorage(storageName) - await storage.clear() sendSuccessResponse(res, { diff --git a/packages/gpt-runner-web/server/src/middleware.ts b/packages/gpt-runner-web/server/src/middleware.ts index e80d947..a0b8022 100644 --- a/packages/gpt-runner-web/server/src/middleware.ts +++ b/packages/gpt-runner-web/server/src/middleware.ts @@ -13,3 +13,9 @@ export function errorHandlerMiddleware(err: Error, req: Request, res: Response, }) res.end() } + +export function safeCheckMiddleware(req: Request, res: Response, next: NextFunction) { + const reqHostName = req.hostname; + (req as any).isSafe = ['localhost', '127.0.0.1'].includes(reqHostName) + next() +} diff --git a/packages/gpt-runner-web/server/src/types.ts b/packages/gpt-runner-web/server/src/types.ts index 963776e..3272766 100644 --- a/packages/gpt-runner-web/server/src/types.ts +++ b/packages/gpt-runner-web/server/src/types.ts @@ -1,10 +1,15 @@ import type { WssActionName } from '@nicepkg/gpt-runner-shared/common' import type { NextFunction, Request, Response } from 'express' +export interface MyRequest extends Request, any, any, Record> { + isSafe: boolean +} + export interface Controller { url: string + requireSafe?: boolean // only allow localhost access method: 'all' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head' - handler: (req: Request, any, any, Record>, res: Response, next: NextFunction) => Promise + handler: (req: MyRequest, res: Response, next: NextFunction) => Promise } export interface ControllerConfig { namespacePath: string