feat(workbench): add file tree and hook up editor

This commit is contained in:
Dominic Elm
2024-07-18 23:07:04 +02:00
parent 012b5bae80
commit a7d8693d8c
17 changed files with 806 additions and 148 deletions

View File

@@ -167,7 +167,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
</div>
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>
</div>
);

View File

@@ -13,11 +13,11 @@ import {
lineNumbers,
scrollPastEnd,
} from '@codemirror/view';
import { useEffect, useRef, useState, type MutableRefObject } from 'react';
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
import type { Theme } from '../../../types/theme';
import { classNames } from '../../../utils/classNames';
import { debounce } from '../../../utils/debounce';
import { createScopedLogger } from '../../../utils/logger';
import { createScopedLogger, renderLogger } from '../../../utils/logger';
import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
@@ -27,7 +27,8 @@ const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument {
value: string | Uint8Array;
loading: boolean;
previousValue?: string | Uint8Array;
commitPending: boolean;
filePath: string;
scroll?: ScrollPosition;
}
@@ -58,6 +59,7 @@ interface Props {
theme: Theme;
id?: unknown;
doc?: EditorDocument;
editable?: boolean;
debounceChange?: number;
debounceScroll?: number;
autoFocusOnDocumentChange?: boolean;
@@ -69,138 +71,154 @@ interface Props {
type EditorStates = Map<string, EditorState>;
export function CodeMirrorEditor({
id,
doc,
debounceScroll = 100,
debounceChange = 150,
autoFocusOnDocumentChange = false,
onScroll,
onChange,
theme,
settings,
className = '',
}: Props) {
const [language] = useState(new Compartment());
const [readOnly] = useState(new Compartment());
export const CodeMirrorEditor = memo(
({
id,
doc,
debounceScroll = 100,
debounceChange = 150,
autoFocusOnDocumentChange = false,
editable = true,
onScroll,
onChange,
theme,
settings,
className = '',
}: Props) => {
renderLogger.debug('CodeMirrorEditor');
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const [languageCompartment] = useState(new Compartment());
const [readOnlyCompartment] = useState(new Compartment());
const [editableCompartment] = useState(new Compartment());
const isBinaryFile = doc?.value instanceof Uint8Array;
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
onScrollRef.current = onScroll;
onChangeRef.current = onChange;
const isBinaryFile = doc?.value instanceof Uint8Array;
docRef.current = doc;
themeRef.current = theme;
onScrollRef.current = onScroll;
onChangeRef.current = onChange;
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
}, debounceChange);
docRef.current = doc;
themeRef.current = theme;
const view = new EditorView({
parent: containerRef.current!,
dispatchTransactions(transactions) {
const previousSelection = view.state.selection;
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
}, debounceChange);
view.update(transactions);
const view = new EditorView({
parent: containerRef.current!,
dispatchTransactions(transactions) {
const previousSelection = view.state.selection;
const newSelection = view.state.selection;
view.update(transactions);
const selectionChanged =
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
const newSelection = view.state.selection;
if (
docRef.current &&
!docRef.current.loading &&
(transactions.some((transaction) => transaction.docChanged) || selectionChanged)
) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
const selectionChanged =
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
editorStatesRef.current!.set(docRef.current.filePath, view.state);
}
},
});
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
viewRef.current = view;
editorStatesRef.current!.set(docRef.current.filePath, view.state);
}
},
});
return () => {
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
viewRef.current = view;
useEffect(() => {
if (!viewRef.current) {
return;
}
return () => {
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
viewRef.current.dispatch({
effects: [reconfigureTheme(theme)],
});
}, [theme]);
useEffect(() => {
if (!viewRef.current) {
return;
}
useEffect(() => {
editorStatesRef.current = new Map<string, EditorState>();
}, [id]);
viewRef.current.dispatch({
effects: [reconfigureTheme(theme)],
});
}, [theme]);
useEffect(() => {
const editorStates = editorStatesRef.current!;
const view = viewRef.current!;
const theme = themeRef.current!;
useEffect(() => {
editorStatesRef.current = new Map<string, EditorState>();
}, [id]);
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [language.of([])]);
useEffect(() => {
const editorStates = editorStatesRef.current!;
const view = viewRef.current!;
const theme = themeRef.current!;
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [
languageCompartment.of([]),
readOnlyCompartment.of([]),
editableCompartment.of([]),
]);
view.setState(state);
setNoDocument(view);
return;
}
if (doc.value instanceof Uint8Array) {
return;
}
if (doc.filePath === '') {
logger.warn('File path should not be empty');
}
let state = editorStates.get(doc.filePath);
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
languageCompartment.of([]),
readOnlyCompartment.of([EditorState.readOnly.of(!editable)]),
editableCompartment.of([EditorView.editable.of(editable)]),
]);
editorStates.set(doc.filePath, state);
}
view.setState(state);
setNoDocument(view);
setEditorDocument(
view,
theme,
editable,
languageCompartment,
readOnlyCompartment,
editableCompartment,
autoFocusOnDocumentChange,
doc as TextEditorDocument,
);
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
return;
}
if (doc.value instanceof Uint8Array) {
return;
}
if (doc.filePath === '') {
logger.warn('File path should not be empty');
}
let state = editorStates.get(doc.filePath);
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, [
language.of([]),
readOnly.of([EditorState.readOnly.of(doc.loading)]),
]);
editorStates.set(doc.filePath, state);
}
view.setState(state);
setEditorDocument(view, theme, language, readOnly, autoFocusOnDocumentChange, doc as TextEditorDocument);
}, [doc?.value, doc?.filePath, doc?.loading, autoFocusOnDocumentChange]);
return (
<div className={classNames('relative h-full', className)}>
{isBinaryFile && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
}
return (
<div className={classNames('relative h-full', className)}>
{isBinaryFile && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
},
);
export default CodeMirrorEditor;
@@ -280,8 +298,10 @@ function setNoDocument(view: EditorView) {
function setEditorDocument(
view: EditorView,
theme: Theme,
language: Compartment,
readOnly: Compartment,
editable: boolean,
languageCompartment: Compartment,
readOnlyCompartment: Compartment,
editableCompartment: Compartment,
autoFocus: boolean,
doc: TextEditorDocument,
) {
@@ -297,7 +317,10 @@ function setEditorDocument(
}
view.dispatch({
effects: [readOnly.reconfigure([EditorState.readOnly.of(doc.loading)])],
effects: [
readOnlyCompartment.reconfigure([EditorState.readOnly.of(!editable)]),
editableCompartment.reconfigure([EditorView.editable.of(editable)]),
],
});
getLanguage(doc.filePath).then((languageSupport) => {
@@ -306,7 +329,7 @@ function setEditorDocument(
}
view.dispatch({
effects: [language.reconfigure([languageSupport]), reconfigureTheme(theme)],
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
});
requestAnimationFrame(() => {

View File

@@ -65,7 +65,7 @@ function getEditorTheme(settings: EditorSettings) {
'&.cm-lineNumbers': {
fontFamily: 'Roboto Mono, monospace',
fontSize: '13px',
minWidth: '28px',
minWidth: '40px',
},
'& .cm-activeLineGutter': {
background: 'transparent',

View File

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

View File

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

View File

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

View File

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