feat(workbench): add file tree and hook up editor
This commit is contained in:
@@ -1,21 +1,52 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import type { FileMap } from '../../lib/stores/files';
|
||||
import { themeStore } from '../../lib/stores/theme';
|
||||
import CodeMirrorEditor from '../editor/codemirror/CodeMirrorEditor';
|
||||
import { renderLogger } from '../../utils/logger';
|
||||
import { isMobile } from '../../utils/mobile';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '../editor/codemirror/CodeMirrorEditor';
|
||||
import { FileTreePanel } from './FileTreePanel';
|
||||
|
||||
export function EditorPanel() {
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
return (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={30} minSize={20} collapsible={false}>
|
||||
<FileTreePanel />
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel defaultSize={70} minSize={20}>
|
||||
<CodeMirrorEditor theme={theme} settings={{ tabSize: 2 }} />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
editorDocument?: EditorDocument;
|
||||
selectedFile?: string | undefined;
|
||||
isStreaming?: boolean;
|
||||
onEditorChange?: OnEditorChange;
|
||||
onEditorScroll?: OnEditorScroll;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
}
|
||||
|
||||
export const EditorPanel = memo(
|
||||
({ files, editorDocument, selectedFile, onFileSelect, onEditorChange, onEditorScroll }: EditorPanelProps) => {
|
||||
renderLogger.trace('EditorPanel');
|
||||
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
return (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={25} minSize={10} collapsible={true}>
|
||||
<FileTreePanel files={files} selectedFile={selectedFile} onFileSelect={onFileSelect} />
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel defaultSize={75} minSize={20}>
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={true}
|
||||
settings={{ tabSize: 2 }}
|
||||
doc={editorDocument}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onScroll={onEditorScroll}
|
||||
onChange={onEditorChange}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,281 @@
|
||||
export function FileTree() {
|
||||
return <div>File Tree</div>;
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { FileMap } from '../../lib/stores/files';
|
||||
import { classNames } from '../../utils/classNames';
|
||||
import { renderLogger } from '../../utils/logger';
|
||||
|
||||
const NODE_PADDING_LEFT = 12;
|
||||
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//];
|
||||
|
||||
interface Props {
|
||||
files?: FileMap;
|
||||
selectedFile?: string;
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
rootFolder?: string;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileTree = memo(
|
||||
({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className }: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
||||
|
||||
const fileList = useMemo(
|
||||
() => buildFileList(files, rootFolder, computedHiddenFiles),
|
||||
[files, rootFolder, computedHiddenFiles],
|
||||
);
|
||||
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedFolders((prevCollapsed) => {
|
||||
const newCollapsed = new Set<string>();
|
||||
|
||||
for (const folder of fileList) {
|
||||
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
|
||||
newCollapsed.add(folder.fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return newCollapsed;
|
||||
});
|
||||
}, [fileList]);
|
||||
|
||||
const filteredFileList = useMemo(() => {
|
||||
const list = [];
|
||||
|
||||
let lastDepth = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const fileOrFolder of fileList) {
|
||||
const depth = fileOrFolder.depth;
|
||||
|
||||
// if the depth is equal we reached the end of the collaped group
|
||||
if (lastDepth === depth) {
|
||||
lastDepth = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// ignore collapsed folders
|
||||
if (collapsedFolders.has(fileOrFolder.fullPath)) {
|
||||
lastDepth = Math.min(lastDepth, depth);
|
||||
}
|
||||
|
||||
// ignore files and folders below the last collapsed folder
|
||||
if (lastDepth < depth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list.push(fileOrFolder);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [fileList, collapsedFolders]);
|
||||
|
||||
const toggleCollapseState = (fullPath: string) => {
|
||||
setCollapsedFolders((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
|
||||
if (newSet.has(fullPath)) {
|
||||
newSet.delete(fullPath);
|
||||
} else {
|
||||
newSet.add(fullPath);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
return (
|
||||
<File
|
||||
key={fileOrFolder.id}
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
onClick={() => {
|
||||
onFileSelect?.(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'folder': {
|
||||
return (
|
||||
<Folder
|
||||
key={fileOrFolder.id}
|
||||
folder={fileOrFolder}
|
||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||
onClick={() => {
|
||||
toggleCollapseState(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default FileTree;
|
||||
|
||||
interface FolderProps {
|
||||
folder: FolderNode;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className="group bg-white hover:bg-gray-100 text-md"
|
||||
depth={depth}
|
||||
iconClasses={classNames({
|
||||
'i-ph:caret-right scale-98': collapsed,
|
||||
'i-ph:caret-down scale-98': !collapsed,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{name}
|
||||
</NodeButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileProps {
|
||||
file: FileNode;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({ file: { depth, name }, onClick, selected }: FileProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-white hover:bg-gray-50': !selected,
|
||||
'bg-gray-100': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'text-gray-600': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{name}
|
||||
</NodeButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
depth: number;
|
||||
iconClasses: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded ${className ?? ''}`}
|
||||
style={{ paddingLeft: `${12 + depth * NODE_PADDING_LEFT}px` }}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
|
||||
<span className="whitespace-nowrap">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Node = FileNode | FolderNode;
|
||||
|
||||
interface BaseNode {
|
||||
id: number;
|
||||
depth: number;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
interface FileNode extends BaseNode {
|
||||
kind: 'file';
|
||||
}
|
||||
|
||||
interface FolderNode extends BaseNode {
|
||||
kind: 'folder';
|
||||
}
|
||||
|
||||
function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array<string | RegExp>): Node[] {
|
||||
const folderPaths = new Set<string>();
|
||||
const fileList: Node[] = [];
|
||||
|
||||
let defaultDepth = 0;
|
||||
|
||||
if (rootFolder === '/') {
|
||||
defaultDepth = 1;
|
||||
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
|
||||
}
|
||||
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
const segments = filePath.split('/').filter((segment) => segment);
|
||||
const fileName = segments.at(-1);
|
||||
|
||||
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentPath = '';
|
||||
|
||||
let i = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
const name = segments[i];
|
||||
const fullPath = (currentPath += `/${name}`);
|
||||
|
||||
if (!fullPath.startsWith(rootFolder)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i === segments.length - 1 && dirent?.type === 'file') {
|
||||
fileList.push({
|
||||
kind: 'file',
|
||||
id: fileList.length,
|
||||
name,
|
||||
fullPath,
|
||||
depth: depth + defaultDepth,
|
||||
});
|
||||
} else if (!folderPaths.has(fullPath)) {
|
||||
folderPaths.add(fullPath);
|
||||
|
||||
fileList.push({
|
||||
kind: 'folder',
|
||||
id: fileList.length,
|
||||
name,
|
||||
fullPath,
|
||||
depth: depth + defaultDepth,
|
||||
});
|
||||
}
|
||||
|
||||
i++;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
|
||||
return hiddenFiles.some((pathOrRegex) => {
|
||||
if (typeof pathOrRegex === 'string') {
|
||||
return fileName === pathOrRegex;
|
||||
}
|
||||
|
||||
return pathOrRegex.test(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { memo } from 'react';
|
||||
import type { FileMap } from '../../lib/stores/files';
|
||||
import { WORK_DIR } from '../../utils/constants';
|
||||
import { renderLogger } from '../../utils/logger';
|
||||
import { FileTree } from './FileTree';
|
||||
|
||||
export function FileTreePanel() {
|
||||
interface FileTreePanelProps {
|
||||
files?: FileMap;
|
||||
selectedFile?: string;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
}
|
||||
|
||||
export const FileTreePanel = memo(({ files, selectedFile, onFileSelect }: FileTreePanelProps) => {
|
||||
renderLogger.trace('FileTreePanel');
|
||||
|
||||
return (
|
||||
<div className="border-r h-full p-4">
|
||||
<FileTree />
|
||||
<div className="border-r h-full">
|
||||
<FileTree files={files} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { workbenchStore } from '../../lib/stores/workbench';
|
||||
import { cubicEasingFn } from '../../utils/easings';
|
||||
import { renderLogger } from '../../utils/logger';
|
||||
import type {
|
||||
OnChangeCallback as OnEditorChange,
|
||||
OnScrollCallback as OnEditorScroll,
|
||||
} from '../editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const workbenchVariants = {
|
||||
@@ -28,8 +35,30 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export function Workbench({ chatStarted }: WorkspaceProps) {
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||
|
||||
const files = useStore(workbenchStore.files);
|
||||
|
||||
useEffect(() => {
|
||||
workbenchStore.setDocuments(files);
|
||||
}, [files]);
|
||||
|
||||
const onEditorChange = useCallback<OnEditorChange>((update) => {
|
||||
workbenchStore.setCurrentDocumentContent(update.content);
|
||||
}, []);
|
||||
|
||||
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
|
||||
workbenchStore.setCurrentDocumentScrollPosition(position);
|
||||
}, []);
|
||||
|
||||
const onFileSelect = useCallback((filePath: string | undefined) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
@@ -51,7 +80,15 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={50} minSize={20}>
|
||||
<EditorPanel />
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
/>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel defaultSize={50} minSize={20}>
|
||||
@@ -66,4 +103,4 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user