This commit is contained in:
2025-05-29 22:16:18 +08:00
parent 60ea5abe45
commit a43cfb4b5f
11 changed files with 472 additions and 1 deletions

View File

@@ -0,0 +1,37 @@
import { useShallow } from 'zustand/shallow';
import { getCurrentContent, useMenuStore } from './store/menu';
import { useEffect, useRef, useState } from 'react';
import { checkText } from './modules/get-content-type';
// import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
export const Content: React.FC = () => {
const { currentPath } = useMenuStore(
useShallow((state) => ({
currentPath: state.currentPath,
})),
);
const [content, setContent] = useState<string | null>(null);
useEffect(() => {
const fetchContent = async () => {
if (currentPath) {
const isText = checkText(currentPath);
if (!isText.isText) {
setContent('This file type is not supported for viewing.');
return;
}
setContent('Loading content...');
const content = await getCurrentContent(currentPath);
setContent(content);
}
};
fetchContent();
}, [currentPath]);
const ref = useRef<HTMLDivElement>(null);
return (
<div className='h-full w-full overflow-y-auto scrollbar px-2'>
<pre>{content}</pre>
</div>
);
};

View File

@@ -1,4 +1,7 @@
import { ToastProvider } from '@/modules/toast/Provider';
import Sidebar from './sidebar';
import { Content } from './content';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
export const App = () => {
return (
@@ -8,11 +11,33 @@ export const App = () => {
);
};
export const SidebarApp = () => {
return (
<ToastProvider>
<Sidebar />
</ToastProvider>
);
};
export const AIEditor = () => {
return (
<div className='flex h-full w-full flex-col items-center justify-center'>
<div className='text-2xl font-bold'>AI Editor</div>
<div className='mt-4 text-gray-500'>This is a placeholder for the AI Editor application.</div>
<div style={{ height: 'calc(100vh - 50px)' }} className='flex w-full'>
<div className='flex w-full'>
<PanelGroup direction='horizontal'>
<Panel minSize={20} defaultSize={30}>
<Sidebar />
</Panel>
<PanelResizeHandle />
<Panel minSize={30}>
<div className='w-full h-full overflow-hidden'>
<Content />
</div>
</Panel>
</PanelGroup>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
export const checkText = (filepath: string) => {
const textExtensions = [
'.txt',
'.md',
'.json',
'.js',
'.ts',
'.html',
'.css',
'.xml',
'.csv',
'.tsx',
'.jsx',
'.mjs',
'.cjs',
'.vue',
'.yaml',
'.yml',
'.log',
'.conf',
'.env',
'.example', //
];
const extension = filepath.split('.').pop()?.toLowerCase();
return { isText: textExtensions.includes(`.${extension}`) };
};

View File

@@ -0,0 +1,135 @@
import React, { useState, useMemo, useEffect } from 'react';
import { File, Folder, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { useMenuStore, init } from './store/menu.ts';
import type { MenuItem } from './store/menu.ts';
// 辅助函数:获取目录的直接子项
const getDirectChildren = (path: string, menu: MenuItem[]): MenuItem[] => {
const pathPrefix = path ? path + '/' : '';
return menu.filter((item) => {
if (!item.path.startsWith(pathPrefix)) return false;
if (item.path === path) return false;
const relativePath = item.path.slice(pathPrefix.length);
return !relativePath.includes('/');
});
};
interface DirectoryItemProps {
item: MenuItem;
level: number;
prefix?: string;
}
const DirectoryItem: React.FC<DirectoryItemProps> = ({ item, level, prefix }) => {
const [expanded, setExpanded] = useState(false);
const { menu } = useMenuStore();
const children = useMemo(() => {
const children = getDirectChildren(item.path, menu);
return children.sort((a, b) => {
if (a.type === 'directory' && b.type === 'file') return -1; // 目录排在前面
if (a.type === 'file' && b.type === 'directory') return 1; // 文件排在后面
return a.path.localeCompare(b.path); // 同类型按路径排序
});
}, [menu, item.path]);
const directory = item.path.replace(prefix || '', '').replace(/\/$/, '') || '/'; // 去掉前缀和末尾斜杠
const newPrefix = item.path + '/';
return (
<div>
<div
className='flex items-center cursor-pointer py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded group'
style={{ paddingLeft: `${level * 12}px` }}
onClick={() => setExpanded(!expanded)}>
{children.length > 0 ? (
expanded ? (
<ChevronDown className='w-4 h-4 mr-1 text-gray-500' />
) : (
<ChevronRight className='w-4 h-4 mr-1 text-gray-500' />
)
) : (
<div className='w-4 h-4 mr-1' />
)}
<Folder className='w-4 h-4 mr-2 text-yellow-500 shrink-0' />
<span className='text-sm truncate'>{directory}</span>
</div>
{expanded && children.length > 0 && (
<div className='ml-2'>
{children.map((child) =>
child.type === 'directory' ? (
<DirectoryItem key={child.path} item={child} prefix={newPrefix} level={level + 1} />
) : (
<FileItem key={child.path} item={child} prefix={newPrefix} level={level + 1} />
),
)}
</div>
)}
</div>
);
};
interface FileItemProps {
item: MenuItem;
level: number;
prefix?: string;
}
const FileItem: React.FC<FileItemProps> = ({ item, level, prefix }) => {
const { currentPath, setCurrentPath } = useMenuStore();
return (
<div
className={`flex items-center cursor-pointer py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${
currentPath === item.path ? 'bg-blue-100 dark:bg-blue-900' : ''
}`}
style={{ paddingLeft: `${level * 12}px` }}
onClick={() => setCurrentPath(item.path)}>
<div className='w-4 h-4 mr-1' /> {/* 占位,保持对齐 */}
<File className='w-4 h-4 mr-2 text-blue-500 shrink-0' />
<span className='text-sm truncate'>{item.path.replace(prefix || '', '')}</span>
</div>
);
};
export const Sidebar: React.FC = () => {
const { menu, isLoading } = useMenuStore();
useEffect(() => {
// 初始化菜单数据
init();
}, []);
// 获取顶层项(根目录下的文件和文件夹)
const rootItems = useMemo(() => {
// return menu.filter((item) => !item.path.includes('/'));
const _menu = menu.filter((item) => {
return !item.path.includes('/');
});
return _menu.sort((a, b) => {
if (a.type === 'directory' && b.type === 'file') return -1; // 目录排在前面
if (a.type === 'file' && b.type === 'directory') return 1; // 文件排在后面
return a.path.localeCompare(b.path); // 同类型按路径排序
});
}, [menu]);
return (
<div className='border-r w-full border-gray-200 dark:border-gray-700 min-h-screen p-2 bg-white dark:bg-gray-900 h-full'>
<div className='flex items-center justify-between mb-4 px-2'>
{/* <h2 className='text-lg font-semibold'>文件</h2> */}
{isLoading && <Loader2 className='w-4 h-4 animate-spin text-gray-500' />}
</div>
<div className='overflow-y-auto scrollbar' style={{ maxHeight: 'calc(100% - 58px)' }}>
{menu.length === 0 && !isLoading ? (
<div className='text-sm text-gray-500 px-2'></div>
) : (
<div className='space-y-1'>
{rootItems.map((item) =>
item.type === 'directory' ? <DirectoryItem key={item.path} item={item} level={1} /> : <FileItem key={item.path} item={item} level={1} />,
)}
</div>
)}
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,104 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { QueryLoginBrowser } from '@/query/query-login/query-login-browser';
import { QueryResources } from '@/query/query-resources/index';
export const queryLogin = new QueryLoginBrowser({ query });
export const queryResources = new QueryResources({
prefix: '/root/resources/',
});
export type MenuItem = {
type: 'file' | 'directory';
name?: string; // type === 'file'
prefix?: string; // type === 'directory'
etag?: string;
lastModified?: string; // ISO date string
size: number;
url: string;
path: string; // a/b/file.md
pathname: string;
};
export type Menu = MenuItem[];
interface MenuState {
menu: Menu;
currentPath: string | null;
isLoading: boolean;
setMenu: (menu: Menu) => void;
setCurrentPath: (path: string | null) => void;
setLoading: (status: boolean) => void;
}
export const useMenuStore = create<MenuState>((set) => ({
menu: [],
currentPath: null,
isLoading: false,
setMenu: (menu) => set({ menu }),
setCurrentPath: (currentPath) => set({ currentPath }),
setLoading: (isLoading) => set({ isLoading }),
}));
class Status {
isInitialized = false;
username = '';
}
const status = new Status();
export const init = async (prefix: string = '') => {
const { setMenu, setCurrentPath, setLoading } = useMenuStore.getState();
let me = await queryLogin.checkLocalUser();
const isInitialized = status.isInitialized;
if (!isInitialized && me) {
status.isInitialized = true;
status.username = me.username!;
queryResources.setUsername(status.username);
}
let recursive = true;
const data = {};
if (recursive) {
data['recursive'] = recursive;
}
const res = await queryResources.getList(prefix, data);
if (res.code === 200) {
const menu = res.data!.map((item: any) => {
if (item.prefix) {
item.type = 'directory';
} else {
item.type = 'file';
}
return item;
});
console.log('init menu', menu);
if (recursive) {
const obj: Record<string, any> = {};
menu.forEach((item) => {
const parts = item.path.split('/');
const dirParts = parts.slice(0, -1);
for (let i = 0; i < dirParts.length; i++) {
const dir = dirParts.slice(0, i + 1).join('/');
if (!dir) continue; // skip root
obj[dir] = obj[dir] || { type: 'directory', path: dir };
}
});
Object.keys(obj).forEach((key) => {
const item = obj[key];
menu.push(item);
});
}
setMenu(menu);
setCurrentPath('');
}
};
export const getCurrentContent = async (currentPath: string): Promise<string | null> => {
if (!currentPath) return null;
const res = await queryResources.fetchFile(currentPath);
if (res.success) {
return res.data;
} else {
console.error('Error fetching content:', res.message);
return null;
}
};