mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
feat(workbench): add file tree and hook up editor
This commit is contained in:
parent
012b5bae80
commit
a7d8693d8c
@ -167,7 +167,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
|
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -13,11 +13,11 @@ import {
|
|||||||
lineNumbers,
|
lineNumbers,
|
||||||
scrollPastEnd,
|
scrollPastEnd,
|
||||||
} from '@codemirror/view';
|
} 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 type { Theme } from '../../../types/theme';
|
||||||
import { classNames } from '../../../utils/classNames';
|
import { classNames } from '../../../utils/classNames';
|
||||||
import { debounce } from '../../../utils/debounce';
|
import { debounce } from '../../../utils/debounce';
|
||||||
import { createScopedLogger } from '../../../utils/logger';
|
import { createScopedLogger, renderLogger } from '../../../utils/logger';
|
||||||
import { BinaryContent } from './BinaryContent';
|
import { BinaryContent } from './BinaryContent';
|
||||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||||
import { indentKeyBinding } from './indent';
|
import { indentKeyBinding } from './indent';
|
||||||
@ -27,7 +27,8 @@ const logger = createScopedLogger('CodeMirrorEditor');
|
|||||||
|
|
||||||
export interface EditorDocument {
|
export interface EditorDocument {
|
||||||
value: string | Uint8Array;
|
value: string | Uint8Array;
|
||||||
loading: boolean;
|
previousValue?: string | Uint8Array;
|
||||||
|
commitPending: boolean;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
scroll?: ScrollPosition;
|
scroll?: ScrollPosition;
|
||||||
}
|
}
|
||||||
@ -58,6 +59,7 @@ interface Props {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
doc?: EditorDocument;
|
doc?: EditorDocument;
|
||||||
|
editable?: boolean;
|
||||||
debounceChange?: number;
|
debounceChange?: number;
|
||||||
debounceScroll?: number;
|
debounceScroll?: number;
|
||||||
autoFocusOnDocumentChange?: boolean;
|
autoFocusOnDocumentChange?: boolean;
|
||||||
@ -69,138 +71,154 @@ interface Props {
|
|||||||
|
|
||||||
type EditorStates = Map<string, EditorState>;
|
type EditorStates = Map<string, EditorState>;
|
||||||
|
|
||||||
export function CodeMirrorEditor({
|
export const CodeMirrorEditor = memo(
|
||||||
id,
|
({
|
||||||
doc,
|
id,
|
||||||
debounceScroll = 100,
|
doc,
|
||||||
debounceChange = 150,
|
debounceScroll = 100,
|
||||||
autoFocusOnDocumentChange = false,
|
debounceChange = 150,
|
||||||
onScroll,
|
autoFocusOnDocumentChange = false,
|
||||||
onChange,
|
editable = true,
|
||||||
theme,
|
onScroll,
|
||||||
settings,
|
onChange,
|
||||||
className = '',
|
theme,
|
||||||
}: Props) {
|
settings,
|
||||||
const [language] = useState(new Compartment());
|
className = '',
|
||||||
const [readOnly] = useState(new Compartment());
|
}: Props) => {
|
||||||
|
renderLogger.debug('CodeMirrorEditor');
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const [languageCompartment] = useState(new Compartment());
|
||||||
const viewRef = useRef<EditorView>();
|
const [readOnlyCompartment] = useState(new Compartment());
|
||||||
const themeRef = useRef<Theme>();
|
const [editableCompartment] = useState(new Compartment());
|
||||||
const docRef = useRef<EditorDocument>();
|
|
||||||
const editorStatesRef = useRef<EditorStates>();
|
|
||||||
const onScrollRef = useRef(onScroll);
|
|
||||||
const onChangeRef = useRef(onChange);
|
|
||||||
|
|
||||||
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;
|
const isBinaryFile = doc?.value instanceof Uint8Array;
|
||||||
onChangeRef.current = onChange;
|
|
||||||
|
|
||||||
docRef.current = doc;
|
onScrollRef.current = onScroll;
|
||||||
themeRef.current = theme;
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
useEffect(() => {
|
docRef.current = doc;
|
||||||
const onUpdate = debounce((update: EditorUpdate) => {
|
themeRef.current = theme;
|
||||||
onChangeRef.current?.(update);
|
|
||||||
}, debounceChange);
|
|
||||||
|
|
||||||
const view = new EditorView({
|
useEffect(() => {
|
||||||
parent: containerRef.current!,
|
const onUpdate = debounce((update: EditorUpdate) => {
|
||||||
dispatchTransactions(transactions) {
|
onChangeRef.current?.(update);
|
||||||
const previousSelection = view.state.selection;
|
}, 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 =
|
const newSelection = view.state.selection;
|
||||||
newSelection !== previousSelection &&
|
|
||||||
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
|
|
||||||
|
|
||||||
if (
|
const selectionChanged =
|
||||||
docRef.current &&
|
newSelection !== previousSelection &&
|
||||||
!docRef.current.loading &&
|
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
|
||||||
(transactions.some((transaction) => transaction.docChanged) || selectionChanged)
|
|
||||||
) {
|
|
||||||
onUpdate({
|
|
||||||
selection: view.state.selection,
|
|
||||||
content: view.state.doc.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = view;
|
||||||
viewRef.current?.destroy();
|
|
||||||
viewRef.current = undefined;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
if (!viewRef.current) {
|
viewRef.current?.destroy();
|
||||||
return;
|
viewRef.current = undefined;
|
||||||
}
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
viewRef.current.dispatch({
|
useEffect(() => {
|
||||||
effects: [reconfigureTheme(theme)],
|
if (!viewRef.current) {
|
||||||
});
|
return;
|
||||||
}, [theme]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
viewRef.current.dispatch({
|
||||||
editorStatesRef.current = new Map<string, EditorState>();
|
effects: [reconfigureTheme(theme)],
|
||||||
}, [id]);
|
});
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editorStates = editorStatesRef.current!;
|
editorStatesRef.current = new Map<string, EditorState>();
|
||||||
const view = viewRef.current!;
|
}, [id]);
|
||||||
const theme = themeRef.current!;
|
|
||||||
|
|
||||||
if (!doc) {
|
useEffect(() => {
|
||||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, [language.of([])]);
|
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);
|
view.setState(state);
|
||||||
|
|
||||||
setNoDocument(view);
|
setEditorDocument(
|
||||||
|
view,
|
||||||
|
theme,
|
||||||
|
editable,
|
||||||
|
languageCompartment,
|
||||||
|
readOnlyCompartment,
|
||||||
|
editableCompartment,
|
||||||
|
autoFocusOnDocumentChange,
|
||||||
|
doc as TextEditorDocument,
|
||||||
|
);
|
||||||
|
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||||
|
|
||||||
return;
|
return (
|
||||||
}
|
<div className={classNames('relative h-full', className)}>
|
||||||
|
{isBinaryFile && <BinaryContent />}
|
||||||
if (doc.value instanceof Uint8Array) {
|
<div className="h-full overflow-hidden" ref={containerRef} />
|
||||||
return;
|
</div>
|
||||||
}
|
);
|
||||||
|
},
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CodeMirrorEditor;
|
export default CodeMirrorEditor;
|
||||||
|
|
||||||
@ -280,8 +298,10 @@ function setNoDocument(view: EditorView) {
|
|||||||
function setEditorDocument(
|
function setEditorDocument(
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
language: Compartment,
|
editable: boolean,
|
||||||
readOnly: Compartment,
|
languageCompartment: Compartment,
|
||||||
|
readOnlyCompartment: Compartment,
|
||||||
|
editableCompartment: Compartment,
|
||||||
autoFocus: boolean,
|
autoFocus: boolean,
|
||||||
doc: TextEditorDocument,
|
doc: TextEditorDocument,
|
||||||
) {
|
) {
|
||||||
@ -297,7 +317,10 @@ function setEditorDocument(
|
|||||||
}
|
}
|
||||||
|
|
||||||
view.dispatch({
|
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) => {
|
getLanguage(doc.filePath).then((languageSupport) => {
|
||||||
@ -306,7 +329,7 @@ function setEditorDocument(
|
|||||||
}
|
}
|
||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects: [language.reconfigure([languageSupport]), reconfigureTheme(theme)],
|
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
@ -65,7 +65,7 @@ function getEditorTheme(settings: EditorSettings) {
|
|||||||
'&.cm-lineNumbers': {
|
'&.cm-lineNumbers': {
|
||||||
fontFamily: 'Roboto Mono, monospace',
|
fontFamily: 'Roboto Mono, monospace',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
minWidth: '28px',
|
minWidth: '40px',
|
||||||
},
|
},
|
||||||
'& .cm-activeLineGutter': {
|
'& .cm-activeLineGutter': {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
|
@ -1,21 +1,52 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { memo } from 'react';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import type { FileMap } from '../../lib/stores/files';
|
||||||
import { themeStore } from '../../lib/stores/theme';
|
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';
|
import { FileTreePanel } from './FileTreePanel';
|
||||||
|
|
||||||
export function EditorPanel() {
|
interface EditorPanelProps {
|
||||||
const theme = useStore(themeStore);
|
files?: FileMap;
|
||||||
|
editorDocument?: EditorDocument;
|
||||||
return (
|
selectedFile?: string | undefined;
|
||||||
<PanelGroup direction="horizontal">
|
isStreaming?: boolean;
|
||||||
<Panel defaultSize={30} minSize={20} collapsible={false}>
|
onEditorChange?: OnEditorChange;
|
||||||
<FileTreePanel />
|
onEditorScroll?: OnEditorScroll;
|
||||||
</Panel>
|
onFileSelect?: (value?: string) => void;
|
||||||
<PanelResizeHandle />
|
|
||||||
<Panel defaultSize={70} minSize={20}>
|
|
||||||
<CodeMirrorEditor theme={theme} settings={{ tabSize: 2 }} />
|
|
||||||
</Panel>
|
|
||||||
</PanelGroup>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
return <div>File Tree</div>;
|
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';
|
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 (
|
return (
|
||||||
<div className="border-r h-full p-4">
|
<div className="border-r h-full">
|
||||||
<FileTree />
|
<FileTree files={files} rootFolder={WORK_DIR} selectedFile={selectedFile} onFileSelect={onFileSelect} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||||
|
import { memo, useCallback, useEffect } from 'react';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
import { workbenchStore } from '../../lib/stores/workbench';
|
import { workbenchStore } from '../../lib/stores/workbench';
|
||||||
import { cubicEasingFn } from '../../utils/easings';
|
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 { IconButton } from '../ui/IconButton';
|
||||||
import { EditorPanel } from './EditorPanel';
|
import { EditorPanel } from './EditorPanel';
|
||||||
import { Preview } from './Preview';
|
import { Preview } from './Preview';
|
||||||
|
|
||||||
interface WorkspaceProps {
|
interface WorkspaceProps {
|
||||||
chatStarted?: boolean;
|
chatStarted?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workbenchVariants = {
|
const workbenchVariants = {
|
||||||
@ -28,8 +35,30 @@ const workbenchVariants = {
|
|||||||
},
|
},
|
||||||
} satisfies Variants;
|
} satisfies Variants;
|
||||||
|
|
||||||
export function Workbench({ chatStarted }: WorkspaceProps) {
|
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||||
|
renderLogger.trace('Workbench');
|
||||||
|
|
||||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
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 (
|
return (
|
||||||
chatStarted && (
|
chatStarted && (
|
||||||
@ -51,7 +80,15 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
|
|||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<PanelGroup direction="vertical">
|
<PanelGroup direction="vertical">
|
||||||
<Panel defaultSize={50} minSize={20}>
|
<Panel defaultSize={50} minSize={20}>
|
||||||
<EditorPanel />
|
<EditorPanel
|
||||||
|
editorDocument={currentDocument}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
files={files}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
onEditorScroll={onEditorScroll}
|
||||||
|
onEditorChange={onEditorChange}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
<PanelResizeHandle />
|
<PanelResizeHandle />
|
||||||
<Panel defaultSize={50} minSize={20}>
|
<Panel defaultSize={50} minSize={20}>
|
||||||
@ -66,4 +103,4 @@ export function Workbench({ chatStarted }: WorkspaceProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
export const getSystemPrompt = (cwd: string = '/home/project') => `
|
import { WORK_DIR } from '../../../utils/constants';
|
||||||
|
|
||||||
|
export const getSystemPrompt = (cwd: string = WORK_DIR) => `
|
||||||
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
|
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
|
||||||
|
|
||||||
<system_constraints>
|
<system_constraints>
|
||||||
|
@ -20,7 +20,7 @@ const messageParser = new StreamingMessageParser({
|
|||||||
workbenchStore.updateArtifact(data, { closed: true });
|
workbenchStore.updateArtifact(data, { closed: true });
|
||||||
},
|
},
|
||||||
onActionOpen: (data) => {
|
onActionOpen: (data) => {
|
||||||
logger.debug('onActionOpen', data.action);
|
logger.trace('onActionOpen', data.action);
|
||||||
|
|
||||||
// we only add shell actions when when the close tag got parsed because only then we have the content
|
// we only add shell actions when when the close tag got parsed because only then we have the content
|
||||||
if (data.action.type !== 'shell') {
|
if (data.action.type !== 'shell') {
|
||||||
@ -28,7 +28,7 @@ const messageParser = new StreamingMessageParser({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onActionClose: (data) => {
|
onActionClose: (data) => {
|
||||||
logger.debug('onActionClose', data.action);
|
logger.trace('onActionClose', data.action);
|
||||||
|
|
||||||
if (data.action.type === 'shell') {
|
if (data.action.type === 'shell') {
|
||||||
workbenchStore.addAction(data);
|
workbenchStore.addAction(data);
|
||||||
|
96
packages/bolt/app/lib/stores/editor.ts
Normal file
96
packages/bolt/app/lib/stores/editor.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { WebContainer } from '@webcontainer/api';
|
||||||
|
import { atom, computed, map } from 'nanostores';
|
||||||
|
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
|
||||||
|
import type { FileMap } from './files';
|
||||||
|
|
||||||
|
export type EditorDocuments = Record<string, EditorDocument>;
|
||||||
|
|
||||||
|
export class EditorStore {
|
||||||
|
#webcontainer: Promise<WebContainer>;
|
||||||
|
|
||||||
|
selectedFile = atom<string | undefined>();
|
||||||
|
documents = map<EditorDocuments>({});
|
||||||
|
|
||||||
|
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents[selectedFile];
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(webcontainerPromise: Promise<WebContainer>) {
|
||||||
|
this.#webcontainer = webcontainerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitFileContent(_filePath: string) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocuments(files: FileMap) {
|
||||||
|
const previousDocuments = this.documents.value;
|
||||||
|
|
||||||
|
this.documents.set(
|
||||||
|
Object.fromEntries<EditorDocument>(
|
||||||
|
Object.entries(files)
|
||||||
|
.map(([filePath, dirent]) => {
|
||||||
|
if (dirent === undefined || dirent.type === 'folder') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
filePath,
|
||||||
|
{
|
||||||
|
value: dirent.content,
|
||||||
|
commitPending: false,
|
||||||
|
filePath,
|
||||||
|
scroll: previousDocuments?.[filePath]?.scroll,
|
||||||
|
},
|
||||||
|
] as [string, EditorDocument];
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<[string, EditorDocument]>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(filePath: string | undefined) {
|
||||||
|
this.selectedFile.set(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollPosition(filePath: string, position: ScrollPosition) {
|
||||||
|
const documents = this.documents.get();
|
||||||
|
const documentState = documents[filePath];
|
||||||
|
|
||||||
|
if (!documentState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.documents.setKey(filePath, {
|
||||||
|
...documentState,
|
||||||
|
scroll: position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFile(filePath: string, content: string): boolean {
|
||||||
|
const documents = this.documents.get();
|
||||||
|
const documentState = documents[filePath];
|
||||||
|
|
||||||
|
if (!documentState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContent = documentState.value;
|
||||||
|
const contentChanged = currentContent !== content;
|
||||||
|
|
||||||
|
if (contentChanged) {
|
||||||
|
this.documents.setKey(filePath, {
|
||||||
|
...documentState,
|
||||||
|
previousValue: !documentState.commitPending ? currentContent : documentState.previousValue,
|
||||||
|
commitPending: documentState.previousValue ? documentState.previousValue !== content : true,
|
||||||
|
value: content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentChanged;
|
||||||
|
}
|
||||||
|
}
|
94
packages/bolt/app/lib/stores/files.ts
Normal file
94
packages/bolt/app/lib/stores/files.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
|
||||||
|
import { map } from 'nanostores';
|
||||||
|
import { bufferWatchEvents } from '../../utils/buffer';
|
||||||
|
import { WORK_DIR } from '../../utils/constants';
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder('utf8', { fatal: true });
|
||||||
|
|
||||||
|
interface File {
|
||||||
|
type: 'file';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
type: 'folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dirent = File | Folder;
|
||||||
|
|
||||||
|
export type FileMap = Record<string, Dirent | undefined>;
|
||||||
|
|
||||||
|
export class FilesStore {
|
||||||
|
#webcontainer: Promise<WebContainer>;
|
||||||
|
|
||||||
|
files = map<FileMap>({});
|
||||||
|
|
||||||
|
constructor(webcontainerPromise: Promise<WebContainer>) {
|
||||||
|
this.#webcontainer = webcontainerPromise;
|
||||||
|
|
||||||
|
this.#init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #init() {
|
||||||
|
const webcontainer = await this.#webcontainer;
|
||||||
|
|
||||||
|
webcontainer.watchPaths(
|
||||||
|
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
||||||
|
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
|
||||||
|
const watchEvents = events.flat(2);
|
||||||
|
|
||||||
|
for (const { type, path, buffer } of watchEvents) {
|
||||||
|
// remove any trailing slashes
|
||||||
|
const sanitizedPath = path.replace(/\/+$/g, '');
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add_dir': {
|
||||||
|
// we intentionally add a trailing slash so we can distinguish files from folders in the file tree
|
||||||
|
this.files.setKey(sanitizedPath, { type: 'folder' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove_dir': {
|
||||||
|
this.files.setKey(sanitizedPath, undefined);
|
||||||
|
|
||||||
|
for (const [direntPath] of Object.entries(this.files)) {
|
||||||
|
if (direntPath.startsWith(sanitizedPath)) {
|
||||||
|
this.files.setKey(direntPath, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'add_file':
|
||||||
|
case 'change': {
|
||||||
|
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove_file': {
|
||||||
|
this.files.setKey(sanitizedPath, undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'update_directory': {
|
||||||
|
// we don't care about these events
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#decodeFileContent(buffer?: Uint8Array) {
|
||||||
|
if (!buffer || buffer.byteLength === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return textDecoder.decode(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,13 @@
|
|||||||
import { atom, map, type MapStore, type WritableAtom } from 'nanostores';
|
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
|
||||||
|
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
|
||||||
import type { BoltAction } from '../../types/actions';
|
import type { BoltAction } from '../../types/actions';
|
||||||
import { unreachable } from '../../utils/unreachable';
|
import { unreachable } from '../../utils/unreachable';
|
||||||
import { ActionRunner } from '../runtime/action-runner';
|
import { ActionRunner } from '../runtime/action-runner';
|
||||||
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
|
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
|
||||||
import { webcontainer } from '../webcontainer';
|
import { webcontainer } from '../webcontainer';
|
||||||
import { chatStore } from './chat';
|
import { chatStore } from './chat';
|
||||||
|
import { EditorStore } from './editor';
|
||||||
|
import { FilesStore, type FileMap } from './files';
|
||||||
import { PreviewsStore } from './previews';
|
import { PreviewsStore } from './previews';
|
||||||
|
|
||||||
const MIN_SPINNER_TIME = 200;
|
const MIN_SPINNER_TIME = 200;
|
||||||
@ -41,6 +44,8 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
|
|||||||
export class WorkbenchStore {
|
export class WorkbenchStore {
|
||||||
#actionRunner = new ActionRunner(webcontainer);
|
#actionRunner = new ActionRunner(webcontainer);
|
||||||
#previewsStore = new PreviewsStore(webcontainer);
|
#previewsStore = new PreviewsStore(webcontainer);
|
||||||
|
#filesStore = new FilesStore(webcontainer);
|
||||||
|
#editorStore = new EditorStore(webcontainer);
|
||||||
|
|
||||||
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||||
|
|
||||||
@ -50,10 +55,52 @@ export class WorkbenchStore {
|
|||||||
return this.#previewsStore.previews;
|
return this.#previewsStore.previews;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get files() {
|
||||||
|
return this.#filesStore.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentDocument(): ReadableAtom<EditorDocument | undefined> {
|
||||||
|
return this.#editorStore.currentDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedFile(): ReadableAtom<string | undefined> {
|
||||||
|
return this.#editorStore.selectedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocuments(files: FileMap) {
|
||||||
|
this.#editorStore.setDocuments(files);
|
||||||
|
}
|
||||||
|
|
||||||
setShowWorkbench(show: boolean) {
|
setShowWorkbench(show: boolean) {
|
||||||
this.showWorkbench.set(show);
|
this.showWorkbench.set(show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrentDocumentContent(newContent: string) {
|
||||||
|
const filePath = this.currentDocument.get()?.filePath;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#editorStore.updateFile(filePath, newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentDocumentScrollPosition(position: ScrollPosition) {
|
||||||
|
const editorDocument = this.currentDocument.get();
|
||||||
|
|
||||||
|
if (!editorDocument) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filePath } = editorDocument;
|
||||||
|
|
||||||
|
this.#editorStore.updateScrollPosition(filePath, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(filePath: string | undefined) {
|
||||||
|
this.#editorStore.setSelectedFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
abortAllActions() {
|
abortAllActions() {
|
||||||
for (const [, artifact] of Object.entries(this.artifacts.get())) {
|
for (const [, artifact] of Object.entries(this.artifacts.get())) {
|
||||||
for (const [, action] of Object.entries(artifact.actions.get())) {
|
for (const [, action] of Object.entries(artifact.actions.get())) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { WebContainer } from '@webcontainer/api';
|
import { WebContainer } from '@webcontainer/api';
|
||||||
|
import { WORK_DIR_NAME } from '../../utils/constants';
|
||||||
|
|
||||||
interface WebContainerContext {
|
interface WebContainerContext {
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@ -20,7 +21,7 @@ if (!import.meta.env.SSR) {
|
|||||||
webcontainer =
|
webcontainer =
|
||||||
import.meta.hot?.data.webcontainer ??
|
import.meta.hot?.data.webcontainer ??
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => WebContainer.boot({ workdirName: 'project' }))
|
.then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME }))
|
||||||
.then((webcontainer) => {
|
.then((webcontainer) => {
|
||||||
webcontainerContext.loaded = true;
|
webcontainerContext.loaded = true;
|
||||||
return webcontainer;
|
return webcontainer;
|
||||||
|
29
packages/bolt/app/utils/buffer.ts
Normal file
29
packages/bolt/app/utils/buffer.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export function bufferWatchEvents<T extends unknown[]>(timeInMs: number, cb: (events: T[]) => unknown) {
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
let events: T[] = [];
|
||||||
|
|
||||||
|
// keep track of the processing of the previous batch so we can wait for it
|
||||||
|
let processing: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
const scheduleBufferTick = () => {
|
||||||
|
timeoutId = self.setTimeout(async () => {
|
||||||
|
// we wait until the previous batch is entirely processed so events are processed in order
|
||||||
|
await processing;
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
processing = Promise.resolve(cb(events));
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = undefined;
|
||||||
|
events = [];
|
||||||
|
}, timeInMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (...args: T) => {
|
||||||
|
events.push(args);
|
||||||
|
|
||||||
|
if (!timeoutId) {
|
||||||
|
scheduleBufferTick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
2
packages/bolt/app/utils/constants.ts
Normal file
2
packages/bolt/app/utils/constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const WORK_DIR_NAME = 'project';
|
||||||
|
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
|
@ -85,3 +85,5 @@ function getColorForLevel(level: DebugLevel): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const renderLogger = createScopedLogger('Render');
|
||||||
|
4
packages/bolt/app/utils/mobile.ts
Normal file
4
packages/bolt/app/utils/mobile.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export function isMobile() {
|
||||||
|
// we use sm: as the breakpoint for mobile. It's currently set to 640px
|
||||||
|
return globalThis.innerWidth < 640;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user