feat(gpt-runner-web): add file editor

This commit is contained in:
JinmingYang
2023-07-07 23:42:11 +08:00
parent 139297eb47
commit 7d0b3dffd5
53 changed files with 1485 additions and 164 deletions

View File

@@ -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>>

View File

@@ -26,6 +26,7 @@ export enum ClientEventName {
UpdateIdeActiveFilePath = 'updateIdeActiveFilePath',
UpdateUserSelectedText = 'updateUserSelectedText',
OpenFileInIde = 'openFileInIde',
OpenFileInFileEditor = 'openFileInFileEditor',
}
export enum GptFileTreeItemType {

View File

@@ -32,6 +32,10 @@ export interface ClientEventData {
[ClientEventName.OpenFileInIde]: {
filePath: string
}
[ClientEventName.OpenFileInFileEditor]: {
fileFullPath: string
}
}
export type EventEmitterMap = {

View File

@@ -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

View File

@@ -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>

View File

@@ -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)

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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": "保存しない場合、変更は失われます。"
}
}

View File

@@ -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": "如果你不保存,你的改动将会丢失."
}
}

View File

@@ -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": "如果你不保存,你的改動將會丟失."
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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%"

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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;
`

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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`

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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>>() {

View File

@@ -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
}

View 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
}

View File

@@ -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),
})
}

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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}

View File

@@ -0,0 +1,6 @@
import { styled } from 'styled-components'
export const EditorWrapper = styled.div`
width: 100%;
height: 100%;
`

View File

@@ -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'

View File

@@ -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);
}
`

View File

@@ -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'

View File

@@ -0,0 +1,8 @@
import { styled } from 'styled-components'
export const FileEditorWrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
`

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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 || {}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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)
}
}

View File

@@ -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,
})),
},
),
)

View File

@@ -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,
})),
},
),
)

View File

@@ -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)
}

View File

@@ -12,8 +12,6 @@ function buildThemeCssString(themes: typeof themeMap) {
finalThemeString += `
body[data-theme="${themeName}"] > * {
${themeString}
color: var(--foreground);
background: var(--background);
}
${theme['--background']

View File

@@ -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' }))

View File

@@ -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,
})
},
},
],
}

View File

@@ -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

View File

@@ -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)
})
})
}

View File

@@ -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, {

View File

@@ -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()
}

View File

@@ -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