feat(gpt-runner-web): add file editor
This commit is contained in:
@@ -5,3 +5,9 @@ export type ReadonlyDeep<T> = {
|
||||
readonly [P in keyof T]: ReadonlyDeep<T[P]>
|
||||
}
|
||||
export type ValueOf<T> = T[keyof T]
|
||||
|
||||
export type DeepRequired<T> = {
|
||||
[P in keyof T]-?: DeepRequired<T[P]>
|
||||
}
|
||||
|
||||
export type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum ClientEventName {
|
||||
UpdateIdeActiveFilePath = 'updateIdeActiveFilePath',
|
||||
UpdateUserSelectedText = 'updateUserSelectedText',
|
||||
OpenFileInIde = 'openFileInIde',
|
||||
OpenFileInFileEditor = 'openFileInFileEditor',
|
||||
}
|
||||
|
||||
export enum GptFileTreeItemType {
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface ClientEventData {
|
||||
[ClientEventName.OpenFileInIde]: {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
[ClientEventName.OpenFileInFileEditor]: {
|
||||
fileFullPath: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventEmitterMap = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OpenEditorReqParams>
|
||||
|
||||
export const CreateFilePathReqParamsSchema = z.object({
|
||||
fileFullPath: z.string(),
|
||||
isDir: z.boolean(),
|
||||
}) satisfies z.ZodType<CreateFilePathReqParams>
|
||||
|
||||
export const RenameFilePathReqParamsSchema = z.object({
|
||||
oldFileFullPath: z.string(),
|
||||
newFileFullPath: z.string(),
|
||||
}) satisfies z.ZodType<RenameFilePathReqParams>
|
||||
|
||||
export const DeleteFilePathReqParamsSchema = z.object({
|
||||
fileFullPath: z.string(),
|
||||
}) satisfies z.ZodType<DeleteFilePathReqParams>
|
||||
|
||||
export const GetFileInfoReqParamsSchema = z.object({
|
||||
fileFullPath: z.string(),
|
||||
}) satisfies z.ZodType<GetFileInfoReqParams>
|
||||
|
||||
export const SaveFileContentReqParamsSchema = z.object({
|
||||
fileFullPath: z.string(),
|
||||
content: z.string(),
|
||||
}) satisfies z.ZodType<SaveFileContentReqParams>
|
||||
|
||||
@@ -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<boolean>
|
||||
@@ -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<void> {
|
||||
const { filePath } = params
|
||||
|
||||
if (!PathUtils.isAccessible(filePath, 'W'))
|
||||
await fs.mkdir(filePath, { recursive: true })
|
||||
}
|
||||
|
||||
static async movePath(params: MovePathParams): Promise<void> {
|
||||
const { oldPath, newPath } = params
|
||||
|
||||
if (PathUtils.isAccessible(oldPath, 'W'))
|
||||
await fs.rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
static async travelFiles(params: TravelFilesParams): Promise<void> {
|
||||
const { isValidPath, callback } = params
|
||||
const filePath = PathUtils.resolve(params.filePath)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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": "保存しない場合、変更は失われます。"
|
||||
}
|
||||
}
|
||||
@@ -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": "如果你不保存,你的改动将会丢失."
|
||||
}
|
||||
}
|
||||
@@ -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": "如果你不保存,你的改動將會丟失."
|
||||
}
|
||||
}
|
||||
@@ -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<PropsWithChildren> = memo(({ children }) => {
|
||||
<Toast></Toast>
|
||||
<ConfettiProvider>
|
||||
<LoadingProvider>
|
||||
{children}
|
||||
<ModalProvider>
|
||||
{children}
|
||||
</ModalProvider>
|
||||
</LoadingProvider>
|
||||
</ConfettiProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -56,8 +56,6 @@ export const ChatMessageInput: FC<ChatMessageInputProps> = 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
|
||||
|
||||
@@ -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<EditorProps> = 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<Monaco>()
|
||||
const [_, setForceUpdate] = useState(0)
|
||||
const monacoEditorRef = useRef<MonacoEditorInstance>()
|
||||
const fileExt = filePath?.split('.')?.pop()
|
||||
const DEFAULT_LANGUAGE = 'markdown'
|
||||
@@ -45,7 +49,6 @@ export const Editor: FC<EditorProps> = memo((props) => {
|
||||
const extMapLanguage = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
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<EditorProps> = 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<EditorProps> = 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 (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Monaco } from '@monaco-editor/react'
|
||||
import type { MonacoEditorInstance } from '../../../../types/monaco-editor'
|
||||
|
||||
export function createCtrlSToSaveAction(monaco: Monaco | undefined, editor: MonacoEditorInstance | undefined, callback?: () => 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()
|
||||
}
|
||||
@@ -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<string, string >, 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()
|
||||
}
|
||||
@@ -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<IconProps> = memo((props) => {
|
||||
export const Icon = memo(forwardRef<HTMLElement, IconProps>((props, ref) => {
|
||||
const { className, style, ...restProps } = props
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
@@ -22,6 +24,6 @@ export const Icon: FC<IconProps> = memo((props) => {
|
||||
)}
|
||||
{...restProps}
|
||||
/>)
|
||||
})
|
||||
}))
|
||||
|
||||
Icon.displayName = 'Icon'
|
||||
|
||||
@@ -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(
|
||||
<ModalWrapper
|
||||
style={{
|
||||
zIndex,
|
||||
display: isOpen ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
<ModalContentWrapper
|
||||
style={{
|
||||
width: contentWidth,
|
||||
}}
|
||||
>
|
||||
<ModalContentHeader>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
{showCloseIcon && <FlexColumnCenter style={{ fontSize: '1.2rem' }}>
|
||||
<CloseButton className='codicon-close' onClick={onCancel}></CloseButton>
|
||||
</FlexColumnCenter>}
|
||||
</ModalContentHeader>
|
||||
|
||||
<ModalContent>
|
||||
{children}
|
||||
</ModalContent>
|
||||
|
||||
<ModalContentFooter>
|
||||
{showCancelBtn && <StyledFooterButton
|
||||
onClick={onCancel}>
|
||||
{finalCancelText}
|
||||
</StyledFooterButton>}
|
||||
|
||||
{showOkBtn && <StyledFooterButton
|
||||
style={{
|
||||
marginLeft: '1rem',
|
||||
}}
|
||||
onClick={onOk}>
|
||||
{finalOkText}
|
||||
</StyledFooterButton>}
|
||||
</ModalContentFooter>
|
||||
|
||||
</ModalContentWrapper>
|
||||
</ModalWrapper>,
|
||||
document.body,
|
||||
)
|
||||
})
|
||||
|
||||
Modal.displayName = 'Modal'
|
||||
@@ -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;
|
||||
`
|
||||
@@ -7,21 +7,17 @@ import {
|
||||
PanelTabContent,
|
||||
} from './panel-tab.styles'
|
||||
|
||||
export interface PanelTabProps<T extends string = string> extends Pick<TabProps<T>, 'defaultActiveId' | 'items' | 'onChange' | 'activeId'> {
|
||||
export interface PanelTabProps<T extends string = string> extends Pick<TabProps<T>, 'defaultActiveId' | 'items' | 'onChange' | 'activeId' | 'tabListStyles' | 'tabItemStyles'> {
|
||||
style?: CSSProperties
|
||||
tabStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export function PanelTab_<T extends string = string>(props: PanelTabProps<T>) {
|
||||
const { style, tabStyle, ...otherProps } = props
|
||||
const { style, ...otherProps } = props
|
||||
|
||||
return (
|
||||
<PanelTabContainer style={style}>
|
||||
<PanelTabContent>
|
||||
<Tab
|
||||
style={tabStyle}
|
||||
{...otherProps}
|
||||
/>
|
||||
<Tab {...otherProps} />
|
||||
</PanelTabContent>
|
||||
</PanelTabContainer>
|
||||
)
|
||||
|
||||
@@ -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<T extends string = string> {
|
||||
label: ReactNode
|
||||
id: T
|
||||
children?: ReactNode
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export interface TabProps<T extends string = string> {
|
||||
defaultActiveId?: T
|
||||
activeId?: T
|
||||
items: TabItem<T>[]
|
||||
style?: CSSProperties
|
||||
tabListStyles?: CSSProperties
|
||||
tabItemStyles?: CSSProperties
|
||||
onChange?: (activeTabId: T) => void
|
||||
}
|
||||
|
||||
@@ -39,16 +39,18 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
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<T>()
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [moreList, setMoreList] = useState<TabItem<T>[]>([])
|
||||
const [moreListVisible, setMoreListVisible] = useState(false)
|
||||
const [tabRef, tabSize] = useElementSizeRealTime<HTMLDivElement>()
|
||||
const showMore = moreList.length > 0
|
||||
|
||||
// motion
|
||||
const indicatorWidth = useMotionValue(0)
|
||||
@@ -58,6 +60,8 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
return activeIdFromProp ?? activeIdFromPrivate ?? defaultActiveId ?? DEFAULT_ACTIVE_ID
|
||||
}, [activeIdFromProp, activeIdFromPrivate, defaultActiveId])
|
||||
|
||||
const activeIdHistory = useRef<T[]>([])
|
||||
|
||||
const setActiveId = useCallback((id: T) => {
|
||||
onChangeFromProp ? onChangeFromProp(id) : setActiveIdFromPrivate(id)
|
||||
}, [onChangeFromProp])
|
||||
@@ -71,6 +75,20 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
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<T> | undefined => {
|
||||
return tabIdTabItemMap[id]
|
||||
}, [tabIdTabItemMap])
|
||||
@@ -88,31 +106,19 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
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<HTMLDivElement>) => {
|
||||
@@ -131,12 +137,11 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
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_<T extends string = string>(props: TabProps<T>) {
|
||||
}, [updateIndicatorPosition, tabSize.width])
|
||||
|
||||
useEffect(() => {
|
||||
const tabsTotalWidth = calcTabChildrenWidth()
|
||||
setShowMore(tabsTotalWidth > tabSize.width)
|
||||
updateMoreList()
|
||||
}, [calcTabChildrenWidth, setShowMore, updateMoreList, tabSize.width])
|
||||
}, [updateMoreList, activeId, tabSize.width])
|
||||
|
||||
return (
|
||||
<TabContainer>
|
||||
<TabListHeader data-show-more={showMore}>
|
||||
<TabListWrapper ref={tabRef} data-show-more={showMore} onWheel={handleTabListScroll}>
|
||||
<TabListWrapper
|
||||
ref={tabRef}
|
||||
data-show-more={showMore}
|
||||
onWheel={handleTabListScroll}
|
||||
style={tabListStyles}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div {...{
|
||||
key: item.id,
|
||||
@@ -181,6 +189,7 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
<TabItemLabel
|
||||
className={activeId === item.id ? 'tab-item-active' : ''}
|
||||
tabIndex={activeId === item.id ? 0 : -1}
|
||||
style={tabItemStyles}
|
||||
>
|
||||
{item.label}
|
||||
</TabItemLabel>
|
||||
@@ -198,18 +207,17 @@ export function Tab_<T extends string = string>(props: TabProps<T>) {
|
||||
|
||||
{showMore && (
|
||||
<MoreWrapper>
|
||||
<MoreIcon onClick={() => setMoreListVisible(!moreListVisible)}>
|
||||
<Icon className="codicon-more" />
|
||||
</MoreIcon>
|
||||
{moreListVisible && (
|
||||
<MoreList>
|
||||
{moreList.map(item => (
|
||||
!item.visible && <MoreListItem key={item.id} onClick={() => handleTabItemClick(item)}>
|
||||
{item.label}
|
||||
</MoreListItem>
|
||||
))}
|
||||
</MoreList>
|
||||
)}
|
||||
<MoreIconWrapper onClick={() => setMoreListVisible(!moreListVisible)}>
|
||||
<StyledMoreIcon className="codicon-more" />
|
||||
</MoreIconWrapper>
|
||||
{moreListVisible && <MoreList>
|
||||
{moreList.map(item => (
|
||||
<MoreListItem key={item.id} onClick={() => handleTabItemClick(item)}>
|
||||
{item.label}
|
||||
</MoreListItem>
|
||||
))}
|
||||
</MoreList>
|
||||
}
|
||||
</MoreWrapper>
|
||||
)}
|
||||
</TabListHeader>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface TreeItemProps<OtherInfo extends TreeItemBaseStateOtherInfo = Tr
|
||||
renderRightSlot?: (props: TreeItemState<OtherInfo>) => React.ReactNode
|
||||
onExpand?: (props: TreeItemState<OtherInfo>) => void
|
||||
onCollapse?: (props: TreeItemState<OtherInfo>) => void
|
||||
onClick?: (props: TreeItemState<OtherInfo>) => void
|
||||
onClick?: (props: TreeItemState<OtherInfo>) => void | boolean
|
||||
onContextMenu?: (props: TreeItemState<OtherInfo>) => void
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ export function TreeItem_<OtherInfo extends TreeItemBaseStateOtherInfo = TreeIte
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const isStop = onClick?.(stateProps)
|
||||
|
||||
if (isStop === false)
|
||||
return
|
||||
|
||||
if (!isLeaf) {
|
||||
if (isExpanded)
|
||||
onCollapse?.({ ...stateProps, isExpanded: false })
|
||||
@@ -63,7 +68,6 @@ export function TreeItem_<OtherInfo extends TreeItemBaseStateOtherInfo = TreeIte
|
||||
else
|
||||
onExpand?.({ ...stateProps, isExpanded: true })
|
||||
}
|
||||
onClick?.(stateProps)
|
||||
}
|
||||
|
||||
const contentVariants: Variants = {
|
||||
|
||||
@@ -46,8 +46,9 @@ export function Tree_<OtherInfo extends TreeItemBaseStateOtherInfo = TreeItemBas
|
||||
onTreeItemContextMenu?.(state)
|
||||
},
|
||||
onClick(state) {
|
||||
item.onClick?.(state)
|
||||
onTreeItemClick?.(state)
|
||||
const isStopA = item.onClick?.(state)
|
||||
const isStopB = onTreeItemClick?.(state)
|
||||
return (isStopA === false || isStopB === false) ? false : undefined
|
||||
},
|
||||
onExpand(state) {
|
||||
item.onExpand?.(state)
|
||||
|
||||
@@ -3,16 +3,13 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useHover<Ref extends RefObject<any>>() {
|
||||
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 extends RefObject<any>>() {
|
||||
}
|
||||
}, [ref.current])
|
||||
|
||||
return [ref, isHover, isHoverRef] as const
|
||||
return [ref, isHover] as const
|
||||
}
|
||||
|
||||
export function useHoverByMouseLocation<Ref extends RefObject<any>>() {
|
||||
|
||||
@@ -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<T extends string | string[]>(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<T extends string | string[]>(key: T): boolean {
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
|
||||
useKeyboard(
|
||||
key,
|
||||
() => setIsPressed(true),
|
||||
() => setIsPressed(false),
|
||||
)
|
||||
|
||||
return isPressed
|
||||
}
|
||||
|
||||
11
packages/gpt-runner-web/client/src/hooks/use-modal.hook.ts
Normal file
11
packages/gpt-runner-web/client/src/hooks/use-modal.hook.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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<BaseRespo
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFilePath(params: CreateFilePathReqParams): Promise<BaseResponse<CreateFilePathResData>> {
|
||||
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<BaseResponse<RenameFilePathResData>> {
|
||||
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<BaseResponse<DeleteFilePathResData>> {
|
||||
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<BaseResponse<GetFileInfoResData>> {
|
||||
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<BaseResponse<SaveFileContentResData>> {
|
||||
return await myFetch(`${getGlobalConfig().serverBaseUrl}/api/editor/save-file-content`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -127,7 +127,7 @@ export const ChatPanel: FC<ChatPanelProps> = memo((props) => {
|
||||
catch (error) {
|
||||
toast.error(getErrorMsg(error))
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
// insert codes
|
||||
const handleInsertCodes = useCallback((value: string) => {
|
||||
@@ -203,7 +203,7 @@ export const ChatPanel: FC<ChatPanelProps> = memo((props) => {
|
||||
inputtingPrompt: t('chat_page.continue_inputting_prompt'),
|
||||
}, false)
|
||||
generateCurrentChatAnswer()
|
||||
}, [chatInstance, updateCurrentChatInstance, generateCurrentChatAnswer])
|
||||
}, [chatInstance, updateCurrentChatInstance, generateCurrentChatAnswer, t])
|
||||
|
||||
// stop
|
||||
const handleStopGenerateAnswer = useCallback(() => {
|
||||
|
||||
@@ -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<ChatSidebarProps> = 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<ChatSidebarProps> = memo((props) => {
|
||||
const handleClickTreeItem = useCallback((props: TreeItemState<GptFileInfoTreeItem>) => {
|
||||
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<GptTreeItemOtherInfo>) => {
|
||||
const { isLeaf, isExpanded, otherInfo } = props
|
||||
@@ -118,8 +134,16 @@ export const ChatSidebar: FC<ChatSidebarProps> = 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<ChatSidebarProps> = memo((props) => {
|
||||
if (otherInfo?.type === GptFileTreeItemType.File) {
|
||||
return <>
|
||||
{/* TODO: implement edit file in web */}
|
||||
{isHovering && MAYBE_IDE && <StyledIcon
|
||||
{isHovering && <StyledIcon
|
||||
title={t('chat_page.edit_btn')}
|
||||
className='codicon-edit'
|
||||
onClick={handleEditGptFile}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
export const EditorWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
@@ -0,0 +1,46 @@
|
||||
import { memo } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import type { FileItemRenderInfo } from '../../shared'
|
||||
import type { EditorProps } from '../../../../../../components/editor'
|
||||
import { Editor } from '../../../../../../components/editor'
|
||||
import type { FileEditorItem } from '../../../../../../store/zustand/file-editor/file-editor-item'
|
||||
import { EditorWrapper } from './file-content-editor.styles'
|
||||
|
||||
export interface FileContentEditorProps extends Omit<EditorProps, 'onSave'> {
|
||||
fileItemRenderInfo: FileItemRenderInfo
|
||||
onItemUpdate?: (item: FileEditorItem) => void
|
||||
onSave?: (item: FileEditorItem) => void
|
||||
}
|
||||
|
||||
export const FileContentEditor: FC<FileContentEditorProps> = memo((props) => {
|
||||
const { fileItemRenderInfo, onItemUpdate, onSave, onChange, ...editorProps } = props
|
||||
|
||||
const { item } = fileItemRenderInfo
|
||||
|
||||
if (!fileItemRenderInfo)
|
||||
return null
|
||||
|
||||
return <EditorWrapper>
|
||||
<Editor
|
||||
filePath={item.fullPath}
|
||||
value={item.editingContent}
|
||||
onChange={(value, e) => {
|
||||
onChange?.(value || '', e)
|
||||
onItemUpdate?.({
|
||||
...item,
|
||||
fixed: true,
|
||||
editingContent: value || '',
|
||||
})
|
||||
}}
|
||||
onSave={() => {
|
||||
if (item.editingContent === item.sourceContent)
|
||||
return
|
||||
onSave?.(item)
|
||||
}}
|
||||
|
||||
{...editorProps}
|
||||
></Editor>
|
||||
</EditorWrapper>
|
||||
})
|
||||
|
||||
FileContentEditor.displayName = 'FileContentEditor'
|
||||
@@ -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);
|
||||
}
|
||||
`
|
||||
@@ -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<FileEditorTabLabelProps> = 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 <MaterialSvgComponent style={{
|
||||
marginLeft: '0.25rem',
|
||||
marginRight: '0.25rem',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
flexShrink: '0',
|
||||
}} />
|
||||
}, [])
|
||||
|
||||
const { displayTitle, item, fileName } = fileItemRenderInfo
|
||||
|
||||
if (!fileItemRenderInfo)
|
||||
return null
|
||||
|
||||
return <TabLabelWrapper title={item.fullPath} onClick={() => {
|
||||
if (isPressedCtrl) {
|
||||
openEditor({
|
||||
path: item.fullPath,
|
||||
})
|
||||
}
|
||||
}}>
|
||||
|
||||
{/* left */}
|
||||
<TabLabelLeft>
|
||||
{renderMaterialIconComponent(fileItemRenderInfo)}
|
||||
</TabLabelLeft>
|
||||
|
||||
{/* center */}
|
||||
<TabLabelCenter
|
||||
$fixed={item.fixed}
|
||||
onDoubleClick={() => {
|
||||
if (!item.fixed) {
|
||||
onItemUpdate?.({
|
||||
...item,
|
||||
fixed: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
</TabLabelCenter>
|
||||
|
||||
{/* right */}
|
||||
<TabLabelRight>
|
||||
<StyledRightIcon
|
||||
ref={rightIconRef}
|
||||
className={
|
||||
item.editingContent !== item.sourceContent && !isRightIconHovering
|
||||
? 'codicon-circle-filled'
|
||||
: 'codicon-chrome-close'}
|
||||
onClick={() => {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}}
|
||||
></StyledRightIcon>
|
||||
</TabLabelRight>
|
||||
</TabLabelWrapper>
|
||||
})
|
||||
|
||||
FileEditorTabLabel.displayName = 'FileEditorTabLabel'
|
||||
@@ -0,0 +1,8 @@
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
export const FileEditorWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`
|
||||
@@ -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<FileEditorProps> = memo((props) => {
|
||||
const { activeFileFullPath, onActiveFileChange } = props
|
||||
const [fileEditorRef, { width: fileEditorWidth }] = useElementSizeRealTime<HTMLDivElement>()
|
||||
|
||||
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<Record<string, FileItemRenderInfo>>(() => {
|
||||
const map: Record<string, FileItemRenderInfo> = {}
|
||||
const fileNameMap = new Map<string, number>()
|
||||
const repeatedFileNames: Set<string> = 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<TabItem<string>[]>(() => {
|
||||
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: <FileEditorTabLabel
|
||||
fileItemRenderInfo={itemRenderInfo}
|
||||
onItemUpdate={onItemUpdate}
|
||||
onRemoveItem={onRemoveItem}
|
||||
onSave={saveFile}
|
||||
></FileEditorTabLabel>,
|
||||
|
||||
children: <FileContentEditor
|
||||
fileItemRenderInfo={itemRenderInfo}
|
||||
onItemUpdate={onItemUpdate}
|
||||
onSave={saveFile}
|
||||
|
||||
options={{
|
||||
minimap: {
|
||||
enabled: showEditorMinimap,
|
||||
},
|
||||
}}
|
||||
></FileContentEditor>,
|
||||
} satisfies TabItem<string>
|
||||
})
|
||||
}, [
|
||||
fileEditorItems,
|
||||
fullPathMapRenderInfo,
|
||||
showEditorMinimap,
|
||||
saveFile,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(saveFileLoading)
|
||||
}, [saveFileLoading])
|
||||
|
||||
return <FileEditorWrapper ref={fileEditorRef}>
|
||||
{isLoading && <LoadingView absolute></LoadingView>}
|
||||
|
||||
<PanelTab
|
||||
items={fileTabItems}
|
||||
activeId={activeFileFullPath}
|
||||
onChange={(id) => {
|
||||
const item = fullPathMapRenderInfo[id]?.item
|
||||
if (item)
|
||||
onActiveFileChange(item)
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
tabListStyles={{
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
tabItemStyles={{
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</FileEditorWrapper>
|
||||
})
|
||||
|
||||
FileEditor.displayName = 'FileEditor'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<FileTreeProps> = 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<FileTreeProps> = 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 || {}
|
||||
|
||||
@@ -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>(MobileTabId.Presets)
|
||||
const [pcTabActiveId, setPcTabActiveId] = useState<PcTabId>(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(() => {
|
||||
</ContentWrapper>
|
||||
}, [activeChatId])
|
||||
|
||||
const renderFileEditor = useCallback(() => {
|
||||
if (!rootPath)
|
||||
return null
|
||||
|
||||
return <ContentWrapper>
|
||||
<FileEditor
|
||||
rootPath={rootPath}
|
||||
activeFileFullPath={activeFileFullPath || ''}
|
||||
onActiveFileChange={(item) => {
|
||||
updateActiveFileFullPath(item.fullPath)
|
||||
}}
|
||||
></FileEditor>
|
||||
</ContentWrapper>
|
||||
}, [activeFileFullPath, rootPath])
|
||||
|
||||
const renderChatPanel = useCallback(() => {
|
||||
return <ChatPanel
|
||||
rootPath={rootPath}
|
||||
@@ -140,28 +176,52 @@ const Chat: FC = memo(() => {
|
||||
return <ErrorView text={fetchProjectInfoRes?.data?.nodeVersionValidMessage}></ErrorView>
|
||||
|
||||
const renderChat = () => {
|
||||
// mobile
|
||||
if (isMobile) {
|
||||
const tabIdViewMap: TabItem<TabId>[] = [
|
||||
const mobileTabItems: TabItem<MobileTabId>[] = [
|
||||
{
|
||||
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 <PanelTab items={tabIdViewMap} activeId={tabActiveId} onChange={setTabActiveId} />
|
||||
if (IS_SAFE) {
|
||||
mobileTabItems.push(
|
||||
{
|
||||
id: MobileTabId.FileEditor,
|
||||
label: t('chat_page.tab_file_editor'),
|
||||
children: renderFileEditor(),
|
||||
})
|
||||
}
|
||||
|
||||
return <PanelTab items={mobileTabItems} activeId={mobileTabActiveId} onChange={setMobileTabActiveId} />
|
||||
}
|
||||
|
||||
// pc
|
||||
const pcTabItems: TabItem<PcTabId>[] = [
|
||||
{
|
||||
id: PcTabId.Chat,
|
||||
label: t('chat_page.tab_chat'),
|
||||
children: renderChatPanel(),
|
||||
},
|
||||
{
|
||||
id: PcTabId.FileEditor,
|
||||
label: t('chat_page.tab_file_editor'),
|
||||
children: renderFileEditor(),
|
||||
},
|
||||
]
|
||||
|
||||
return <FlexRow style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<DragResizeView
|
||||
open={isOpenTreeDrawer}
|
||||
@@ -176,7 +236,16 @@ const Chat: FC = memo(() => {
|
||||
{renderSidebar()}
|
||||
</DragResizeView>
|
||||
|
||||
{renderChatPanel()}
|
||||
{IS_SAFE
|
||||
? <PanelTab
|
||||
items={pcTabItems}
|
||||
activeId={pcTabActiveId}
|
||||
onChange={setPcTabActiveId}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
: renderChatPanel()}
|
||||
|
||||
{showFileTreeOnRightSide
|
||||
? <DragResizeView
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { createContext, useState } from 'react'
|
||||
import { Modal, type ModalProps } from '../../components/modal'
|
||||
|
||||
interface IModalContext {
|
||||
modalProps: ModalProps
|
||||
setModalProps: (props: ModalProps) => void
|
||||
}
|
||||
|
||||
export const ModalContext = createContext<IModalContext | undefined>(undefined)
|
||||
|
||||
interface ModalProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
||||
const [modalProps, setModalProps] = useState<ModalProps>({
|
||||
open: false,
|
||||
title: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ modalProps, setModalProps }}>
|
||||
<Modal {...modalProps} />
|
||||
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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<IFileEditorItem, 'sourceContent' | 'editingContent' | 'fixed' | 'permissions'>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
removeFileEditorItem: (fullPath: string) => void
|
||||
updateFileEditorItem: (fullPath: string, item: Partial<FileEditorItem>) => void
|
||||
getFileEditorItem: (fullPath: string) => FileEditorItem | undefined
|
||||
}
|
||||
|
||||
export type FileEditorState = GetState<FileEditorSlice>
|
||||
|
||||
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)<FileEditorSlice, any>(
|
||||
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,
|
||||
})),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -25,7 +25,10 @@ export const useGlobalStore = createStore('GlobalStore')<GlobalSlice, any>(
|
||||
|
||||
{
|
||||
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,
|
||||
})),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ function buildThemeCssString(themes: typeof themeMap) {
|
||||
finalThemeString += `
|
||||
body[data-theme="${themeName}"] > * {
|
||||
${themeString}
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
${theme['--background']
|
||||
|
||||
@@ -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<Express> {
|
||||
|
||||
app.use(cors())
|
||||
|
||||
app.use(safeCheckMiddleware)
|
||||
|
||||
processControllers(router)
|
||||
|
||||
app.use(express.json({ limit: '25mb' }))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Record<string, any>, any, any, Record<string, any>> {
|
||||
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<Record<string, any>, any, any, Record<string, any>>, res: Response, next: NextFunction) => Promise<void>
|
||||
handler: (req: MyRequest, res: Response, next: NextFunction) => Promise<void>
|
||||
}
|
||||
export interface ControllerConfig {
|
||||
namespacePath: string
|
||||
|
||||
Reference in New Issue
Block a user